diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9be0e917dca..014410c88cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,20 +12,20 @@ jobs: lint: strategy: matrix: - package: ['linode-manager', '@linode/api-v4', '@linode/validation'] + package: ["linode-manager", "@linode/api-v4", "@linode/validation"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: "18.14" - - uses: actions/cache@v3 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile - - run: yarn workspace ${{ matrix.package }} run lint + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "20.17" + - uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn --frozen-lockfile + - run: yarn workspace ${{ matrix.package }} run lint build-validation: runs-on: ubuntu-latest @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | @@ -104,7 +104,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | @@ -132,7 +132,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" # Download the validation and api-v4 artifacts (built packages) - uses: actions/download-artifact@v3 @@ -168,7 +168,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | @@ -191,7 +191,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | @@ -208,7 +208,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | @@ -234,7 +234,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | @@ -296,7 +296,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index fda1fed0802..84399b8323f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -9,12 +9,12 @@ jobs: steps: - uses: actions/checkout@v3 with: - ref: ${{ github.base_ref }} # The base branch of the PR (develop) + ref: ${{ github.base_ref }} # The base branch of the PR (develop) - - name: Use Node.js v18.14.0 + - name: Use Node.js v20.17 LTS uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: @@ -54,10 +54,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Use Node.js v18.14.0 + - name: Use Node.js v20.17 LTS uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml index dee947804c6..0a73b196138 100644 --- a/.github/workflows/coverage_badge.yml +++ b/.github/workflows/coverage_badge.yml @@ -13,10 +13,10 @@ jobs: - name: Checkout Code uses: actions/checkout@v3 - - name: Use Node.js v18.14.0 + - name: Use Node.js v20.17 LTS uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: @@ -39,7 +39,7 @@ jobs: - name: Generate Coverage Badge uses: jaywcjlove/coverage-badges-cli@7f0781807ef3e7aba97a145beca881d36451b7b7 # v1.1.1 with: - label: '@linode/manager coverage' + label: "@linode/manager coverage" source: ./packages/manager/coverage/coverage-summary.json output: ./packages/manager/coverage/badges.svg @@ -52,4 +52,4 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.COVERAGE_BUCKET_ACCESS }} AWS_SECRET_ACCESS_KEY: ${{ secrets.COVERAGE_BUCKET_SECRET }} AWS_REGION: us-east-1 - SOURCE_DIR: ./packages/manager/coverage \ No newline at end of file + SOURCE_DIR: ./packages/manager/coverage diff --git a/.github/workflows/coverage_comment.yml b/.github/workflows/coverage_comment.yml index c5d0970f431..07fe3682a9b 100644 --- a/.github/workflows/coverage_comment.yml +++ b/.github/workflows/coverage_comment.yml @@ -17,10 +17,10 @@ jobs: - name: Checkout Code uses: actions/checkout@v3 - - name: Use Node.js v18.14.0 + - name: Use Node.js v20.17 LTS uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - name: Download PR Number Artifact uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e #v2.28.0 @@ -45,7 +45,7 @@ jobs: - name: Set PR Number Environment Variables run: | - echo "PR_NUMBER=$(cat pr_number.txt)" >> $GITHUB_ENV + echo "PR_NUMBER=$(cat pr_number.txt)" >> $GITHUB_ENV - name: Generate Coverage Comment run: | diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index 003ff965966..61176d8fb15 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18.14" + node-version: "20.17" - uses: actions/cache@v3 with: path: | diff --git a/.nvmrc b/.nvmrc index 617bcf916bf..65da8ce3917 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.14.1 +20.17 diff --git a/Jenkinsfile-component-tests.groovy b/Jenkinsfile-component-tests.groovy new file mode 100644 index 00000000000..90ce63039e7 --- /dev/null +++ b/Jenkinsfile-component-tests.groovy @@ -0,0 +1,3 @@ +library 'ui-builder' + +testManagerComponents() diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..0a8bb822749 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Akamai uses HackerOne for responsible disclosure using separate, invite-only programs for specific scopes + +## Akamai CDN + + + +## Akamai Connected Cloud / Linode + + + +Policy: + +## In addition, we welcome _all_ types of security reports via email + +Contact: mailto:security@akamai.com + +Encryption: + +## Please send abuse reports to + +Contact: mailto:abuse@akamai.com + +Hiring: + +Preferred-Languages: en diff --git a/docker-compose.yml b/docker-compose.yml index 51f2f01e6a7..5285fdd036d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,9 @@ x-e2e-env: CY_TEST_SPLIT_RUN_TOTAL: ${CY_TEST_SPLIT_RUN_TOTAL} CY_TEST_SPLIT_RUN_INDEX: ${CY_TEST_SPLIT_RUN_INDEX} + # Cypress performance. + CY_TEST_ACCOUNT_CACHE_DIR: ${CY_TEST_ACCOUNT_CACHE_DIR} + # Cypress reporting. CY_TEST_JUNIT_REPORT: ${CY_TEST_JUNIT_REPORT} CY_TEST_USER_REPORT: ${CY_TEST_USER_REPORT} @@ -63,6 +66,7 @@ x-e2e-env: x-e2e-volumes: &default-volumes - ./.git:/home/node/app/.git + - ./cache:/home/node/app/cache - ./packages/manager:/home/node/app/packages/manager - ./packages/validation:/home/node/app/packages/validation - ./packages/api-v4:/home/node/app/packages/api-v4 @@ -83,6 +87,8 @@ x-e2e-runners: condition: service_healthy env_file: ./packages/manager/.env volumes: *default-volumes + # TODO Stop using entrypoint, use CMD instead. + # (Or just make `yarn` the entrypoint, but either way stop forcing `cy:e2e`). entrypoint: ['yarn', 'cy:e2e'] services: @@ -114,6 +120,16 @@ services: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH} + # Component test runner. + # Does not require any Cloud Manager environment to run. + component: + <<: *default-runner + depends_on: [] + environment: + CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} + CY_TEST_JUNIT_REPORT: ${CY_TEST_JUNIT_REPORT} + entrypoint: ['yarn', '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. e2e_heimdall: diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f005663f28e..1b45eb69450 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -17,7 +17,7 @@ 5. After your OAuth App has been created, copy the ID (not the secret). 6. In `packages/manager`, copy the contents of `.env.example` and paste them into a new file called `.env`. 7. In `.env` set `REACT_APP_CLIENT_ID` to the ID from step 5. -8. Install Node.js 18.14.1. We recommend using [Volta](https://volta.sh/): +8. Install Node.js 20.17 LTS. We recommend using [Volta](https://volta.sh/): ```bash @@ -25,10 +25,10 @@ ## Add volta to your .*rc file, or open a new terminal window. - $ volta install node@18.14.1 + $ volta install node@20.17 $ node --version - ## v18.14.1 + ## v20.17.0 ``` diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 805729ae65d..5635774c867 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -220,6 +220,14 @@ Environment variables related to Cypress logging and reporting, as well as repor | `CY_TEST_FAIL_ON_MANAGED` | Fail affected tests when Managed is enabled | `1` | Unset; disabled by default | | `CY_TEST_GENWEIGHTS` | Generate and output test weights to the given path | `./weights.json` | Unset; disabled by default | +###### Performance +Environment variables that can be used to improve test performance in some scenarios. + +| Environment Variable | Description | Example | Default | +|---------------------------------|-----------------------------------------------|--------------------|----------------------------| +| `CY_TEST_ACCOUNT_CACHE_DIR` | Directory containing test account cache data | `./cache/accounts` | Unset; disabled by default | + + ### Writing End-to-End Tests 1. Look here for [Cypress Best Practices](https://docs.cypress.io/guides/references/best-practices) diff --git a/docs/development-guide/09-mocking-data.md b/docs/development-guide/09-mocking-data.md index b12b902ae99..2d6ad76504b 100644 --- a/docs/development-guide/09-mocking-data.md +++ b/docs/development-guide/09-mocking-data.md @@ -6,9 +6,9 @@ This guide covers various methods of mocking data while developing or testing Cl Often when developing a feature you'll need your account or resources to be in a specific state. In other words, you'll need to be receiving specific data from the API. -The best way to do this is to _mock the API_. This is made simple using **factories** and the **mock service worker**. +The best way to do this is to _mock the API_. This is made simple using [**factories**](https://github.com/linode/manager/tree/develop/packages/manager/src/factories) and the [**mock service worker**](https://github.com/linode/manager/tree/develop/packages/manager/src/mocks) tooling suite. -**Factories** +### Factories We use [factory.ts](https://www.npmjs.com/package/factory.ts) to generate mock data. With factory.ts you define a base "factory" for a given type, then use the factory to generate real TypeScript objects: ```ts @@ -41,31 +41,43 @@ const linodeList = linodeFactory.buildList(10, { region: "eu-west" }); // [{ id: 3, label: 'linode-3', region: 'eu-west' }, ...9 more ] ``` -**Mock Service Worker** -The [Mock Service Worker](https://mswjs.io/) package intercepts requests at the network level and returns the response you define. +### Intercepting Requests -To enable the MSW, open the Local Dev Tools and check the "Mock Service Worker" checkbox. Request handlers are defined in `packages/manager/src/mocks/serverHandlers.ts`. Here's an example request handler: +The [Mock Service Worker](https://mswjs.io/) package intercepts requests at the network level and returns the response you defined in the relevant factory. +Generic example: ```ts -// packages/manager/src/mocks/serverHandlers.ts - -import { rest } from "msw"; +import { http } from "msw"; const handlers = [ - http.get("*/profile", () => { - // - const profile = profileFactory.build({ restricted: true }); - return HttpResponse.json((profile)); + http.get("*/entity/:id", ({ params }) => { + const id = Number(params.id); + const entity = entityFactory.build({ id }); + + return HttpResponse.json(entity); }), - // ... other handlers ]; ``` -When the MSW is enabled, any GET request that matches `*/profile` will be intercepted, and the response will be the `profile` JSON object we gave to the `res()` function. +In this example, when MSW is enabled, any GET request that matches `*/entity/:id` will be intercepted, and the response will be the `entity` JSON object we built from a factory. + +The application treats these as _real_ network requests and will behave as though this data is coming from the actual API. + +## Dev Tools & MSW -The great thing about this is that the application treats these as _real_ network requests and will behave as though this data is coming from the actual API. +To enable the MSW, open the Local Dev Tools (development only, by clicking the 🛠️ icon in the lower left of your screen). You will be presented with a variety of options to facilitate local development. Click the "Enable MSW" checkbox to get started, then choose a Base Preset: +- "**Preset Mocking**": Will give you the ability to use predefined mock types, available in the "Presets" column underneath. Those presets return static data for commonly used non-dynamic data, and a parameter to customize the API's response time. +- "**CRUD**": The primary mocking mode for Cloud Manager. This mode is an API-like mocking behavior, storing and persisting mock data in the `indexDB` browser storage (`Developer Tools > Application > IndexedDB > MockDB`). Two features are available while using this mode: + - **`mockState`**: the data a user inputs via Cloud Manager UI. When a handler is defined (`src/mocks/handlers/handler.ts`), it will be stored in the corresponding content type and persist in local storage, unless reset in dev tools or cleared manually. + - **`seedState`**: the data stored by content seeders (`src/mocks/seeds/seed.ts`) for which the count can be customized in the UI (left panel). This way of populating data is particularly helpful for testing large accounts, pagination and filtering.

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

Please follow existing examples to add handlers and seeders and ensure to use `mswDB` utils to keep your stateful data synced with the `indexedDB` storage. ## Mocking feature flags @@ -74,26 +86,16 @@ Cloud Manager uses [LaunchDarkly](https://github.com/launchdarkly/react-client-s To run Cloud Manager without feature flags enabled, either: - Run the app without `REACT_APP_LAUNCH_DARKLY_ID` defined in `.env`, or: -- [Block network requests](https://developers.google.com/web/updates/2017/04/devtools-release-notes#block-requests) to `*launchdarkly*`. +- In the browser Developer Tools, block network requests to `*launchdarkly*`. To run Cloud Manager with _specific values_ assigned to feature flags: -1. Run Cloud Manager without feature flags using a method listed above. -2. Open `src/containers/withFeatureFlagProvider.container.ts`. -3. Supply an `options.bootstrap` map to `withLDProvider`. - -**Example:** +1. Use the Cloud Manager Dev Tools Feature Flags panel +2. Run Cloud Manager without feature flags using a method listed above. -```js -options: { - bootstrap: { - isFeatureEnabled: true; // <-- Mocked flags here. - } -} ``` - ## Changing user preferences Since [user preferences are tied to OAuth clients](https://developers.linode.com/api/v4/profile-preferences), to change Cloud Manager preferences you must grab the short-lived token from the app and use that to curl. -As a convenience, there is a preference editor in Cloud accessible by going to `/profile/settings?preferenceEditor=true`. It allows you to enter arbitrary JSON and submit it (validating that it's valid JSON first). This makes it easier to quickly edit preferences when developing features that depend on it. +As a convenience, there is a preference editor in Cloud accessible by going to `/profile/settings?preferenceEditor=true`. It allows you to enter arbitrary JSON and submit it (validating that it's valid JSON first). This makes it easier to quickly edit preferences when developing features that depend on it. A quick link to those preferences is also available in the Cloud Manager Dev Tools. diff --git a/docs/development-guide/10-local-dev-tools.md b/docs/development-guide/10-local-dev-tools.md index 172269fb849..35bc1c47ce1 100644 --- a/docs/development-guide/10-local-dev-tools.md +++ b/docs/development-guide/10-local-dev-tools.md @@ -16,9 +16,9 @@ The flags on/off values are stored in local storage for convenience and will be By default, the boolean flags checkboxes represent their true values as returned by Launch Darkly (dev environment). Hitting the reset button will bring them back to those default values and clear local storage. -## Theme Select +## MSW Tools -The theme select in dev tools is a convenient way to store the theme choice while MSW is enabled (it won't affect your actual Application settings/preferences). The default is "system". +Please refer to the [Mocking Data](https://github.com/linode/manager/blob/develop/docs/development-guide/09-mocking-data.md) section of this documentation to get familiar with the usage of these tools. ## Writing a new tool diff --git a/docs/tooling/react-query.md b/docs/tooling/react-query.md index e26cef8fb41..e4ecc2a8137 100644 --- a/docs/tooling/react-query.md +++ b/docs/tooling/react-query.md @@ -161,14 +161,14 @@ export const useCreateLinodeMutation = () => { ## Frequently Asked Questions -### Are we storing dupdate data in the cache? Why? +### Are we storing duplicated data in the cache? Why? Yes, there is potential for the same data to exist many times in the cache. For example, we have a query `useVolumesQuery` with the query key `["volumes", "paginated", { page: 1 }]` that contains the first 100 volumes on your account. -One of those same volumes could also be stored in the cache by using `useVolumeQuery` with query key `["linodes", "linode", 5]`. -This creates a senerio where the same volume is cached by React Query under multiple query keys. +One of those same volumes could also be stored in the cache by using `useVolumeQuery` with query key `["volumes", "volume", 5]`. +This creates a scenario where the same volume is cached by React Query under multiple query keys. -This is a legitimate disadvantage of React Query's caching strategy. **We must be aware of this when we perform cache updates (using invalidations or manually updating the cache) so that the entity is update everywhere in the cache.** +This is a legitimate disadvantage of React Query's caching strategy. **We must be aware of this when we perform cache updates (using invalidations or manually updating the cache) so that the entity is updated everywhere in the cache.** Some data fetching tools like Apollo Client are able to intelligently detect duplicate entities and merge them. React Query does not do this. See [this tweet](https://twitter.com/tannerlinsley/status/1557395389531074560) from the creator of React Query. \ No newline at end of file diff --git a/package.json b/package.json index aec58e96361..8a2e4147fcc 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,7 @@ "license": "Apache-2.0", "devDependencies": { "husky": "^3.0.1", - "npm-run-all": "^4.1.5", - "postinstall": "^0.6.0", - "typescript": "^5.4.5" + "typescript": "^5.5.4" }, "husky": { "hooks": { @@ -37,6 +35,8 @@ "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": "node scripts/changelog/changeset.mjs", "generate-changelogs": "node scripts/changelog/generate-changelogs.mjs", @@ -70,7 +70,7 @@ }, "version": "0.0.0", "volta": { - "node": "18.14.1" + "node": "20.17.0" }, "dependencies": {} } diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index e897c5dcad3..023a393e3ba 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,19 @@ +## [2024-09-16] - v0.126.0 + + +### Added: + +- LinodeCapabilities type used for `capabilities` property of Linode interface ([#10920](https://github.com/linode/manager/pull/10920)) + +### Tech Stories: + +- Update vitest to latest ([#10843](https://github.com/linode/manager/pull/10843)) + +### Upcoming Features: + +- Change 'bs_encryption_supported' property on Linode object to 'capabilities' ([#10837](https://github.com/linode/manager/pull/10837)) +- Add beta API root for CloudPulse endpoints ([#10851](https://github.com/linode/manager/pull/10851)) + ## [2024-09-03] - v0.125.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index e692d259f3d..ea692180f5e 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.125.0", + "version": "0.126.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -67,7 +67,7 @@ "lint-staged": "^15.2.9", "prettier": "~2.2.1", "tsup": "^8.2.4", - "vitest": "^1.6.0" + "vitest": "^2.0.5" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 4e266b064e0..c11000ff945 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -179,6 +179,7 @@ export interface Grant { } export type GlobalGrantTypes = | 'account_access' + | 'add_databases' | 'add_domains' | 'add_firewalls' | 'add_images' @@ -255,6 +256,7 @@ export type NotificationType = | 'reboot_scheduled' | 'outage' | 'maintenance' + | 'maintenance_scheduled' | 'payment_due' | 'ticket_important' | 'ticket_abuse' diff --git a/packages/api-v4/src/cloudpulse/dashboards.ts b/packages/api-v4/src/cloudpulse/dashboards.ts index 32b9bbf3c31..c8802747e52 100644 --- a/packages/api-v4/src/cloudpulse/dashboards.ts +++ b/packages/api-v4/src/cloudpulse/dashboards.ts @@ -1,7 +1,7 @@ import { ResourcePage } from 'src/types'; import Request, { setMethod, setURL } from '../request'; import { Dashboard } from './types'; -import { API_ROOT } from 'src/constants'; +import { BETA_API_ROOT as API_ROOT } from 'src/constants'; // Returns the list of all the dashboards available export const getDashboards = (serviceType: string) => diff --git a/packages/api-v4/src/cloudpulse/services.ts b/packages/api-v4/src/cloudpulse/services.ts index 0ace1f4ceea..5eb06faa0e5 100644 --- a/packages/api-v4/src/cloudpulse/services.ts +++ b/packages/api-v4/src/cloudpulse/services.ts @@ -1,4 +1,4 @@ -import { API_ROOT } from 'src/constants'; +import { BETA_API_ROOT as API_ROOT } from 'src/constants'; import Request, { setData, setMethod, setURL } from '../request'; import { JWEToken, diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 3221a8c61b6..933f1978c86 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -28,15 +28,15 @@ export interface DatabaseEngine { } export type DatabaseStatus = + | 'active' + | 'degraded' + | 'failed' | 'provisioning' | 'resizing' - | 'active' - | 'suspending' - | 'suspended' - | 'resuming' | 'restoring' - | 'failed' - | 'degraded'; + | 'resuming' + | 'suspended' + | 'suspending'; export type DatabaseBackupType = 'snapshot' | 'auto'; diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 9eabb9f5e75..aa74b71ac70 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -19,7 +19,7 @@ export interface Linode { id: number; alerts: LinodeAlerts; backups: LinodeBackups; - bs_encryption_supported?: boolean; // @TODO BSE: Remove optionality once BSE is fully rolled out + capabilities?: LinodeCapabilities[]; // @TODO BSE: Remove optionality once BSE is fully rolled out created: string; disk_encryption?: EncryptionStatus; // @TODO LDE: Remove optionality once LDE is fully rolled out region: string; @@ -54,6 +54,8 @@ export interface LinodeBackups { last_successful: string | null; } +export type LinodeCapabilities = 'Block Storage Encryption'; + export type Window = | 'Scheduling' | 'W0' diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index c17934076bb..e95c7dc3f13 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -1,6 +1,7 @@ import { COUNTRY_CODE_TO_CONTINENT_CODE } from './constants'; export type Capabilities = + | 'Backups' | 'Bare Metal' | 'Block Storage' | 'Block Storage Encryption' diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 5ea09efff9d..55197641f90 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -13,7 +13,7 @@ import { wrapWithTheme } from '../src/utilities/testHelpers'; import { useDarkMode } from 'storybook-dark-mode'; import { DocsContainer as BaseContainer } from '@storybook/addon-docs'; import { themes } from '@storybook/theming'; -import { worker } from '../src/mocks/testBrowser'; +import { storybookWorker } from '../src/mocks/mswWorkers'; import '../src/index.css'; // TODO: M3-6705 Remove this when replacing @reach/tabs with MUI Tabs @@ -49,7 +49,7 @@ const preview: Preview = { ], loaders: [ async () => ({ - msw: await worker?.start(), + msw: await storybookWorker?.start(), }), ], parameters: { diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 879802e170d..5b2d1e28112 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,92 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-09-16] - v1.128.0 + + +### Added: + +- Gravatar sunset banner for existing Gravatar users ([#10859](https://github.com/linode/manager/pull/10859)) +- New Marketplace app for September 2024 ([#10874](https://github.com/linode/manager/pull/10874)) +- Support for quoted strings in Search v2 ([#10894](https://github.com/linode/manager/pull/10894)) +- SelectableTableRow story in Storybook ([#10870](https://github.com/linode/manager/pull/10870)) +- DisplayPrice story in Storybook ([#10904](https://github.com/linode/manager/pull/10904)) +- CheckoutSummary story in Storybook ([#10905](https://github.com/linode/manager/pull/10905)) +- CopyableTextField story and cleaned up components ([#10912](https://github.com/linode/manager/pull/10912)) +- Tracking metrics for LD DX Tools AB Test ([#10906](https://github.com/linode/manager/pull/10906)) + +### Changed: + +- Restricted access UX for Databases ([#10794](https://github.com/linode/manager/pull/10794)) +- Update image related copy as part of Image Service Gen2 ([#10835](https://github.com/linode/manager/pull/10835)) +- Disable Region in OS tab for unsupported distributed images and fix helper text positioning ([#10848](https://github.com/linode/manager/pull/10848)) +- Avatars for users without Gravatars ([#10859](https://github.com/linode/manager/pull/10859)) +- Refactor and improve the User Details page ([#10861](https://github.com/linode/manager/pull/10861)) +- Hide Beta price notice for Gecko LA and rename Ga code references to LA ([#10896](https://github.com/linode/manager/pull/10896)) +- Lower Events historical data fetching to 7 days ([#10899](https://github.com/linode/manager/pull/10899)) +- Update security policy ([#10902](https://github.com/linode/manager/pull/10902)) +- "Contact support" links to new support ticket in event messages ([#10910](https://github.com/linode/manager/pull/10910)) +- Invalid Tax Id notification ([#10928](https://github.com/linode/manager/pull/10928)) + +### Fixed: + +- Helper text copy in NodeBalancer Create form “Algorithm” field ([#10855](https://github.com/linode/manager/pull/10855)) +- Regions to be searched by ID when Gecko is enabled ([#10871](https://github.com/linode/manager/pull/10871)) +- Weblish line wrapping ([#10893](https://github.com/linode/manager/pull/10893)) +- Search queries containing `and` on Linode Create v2's StackScript tab not being respected ([#10894](https://github.com/linode/manager/pull/10894)) +- Typo with toast success notification when updating Reverse DNS ([#10895](https://github.com/linode/manager/pull/10895)) +- Linode Migrate Datacenter Started event message referring to the wrong region ([#10901](https://github.com/linode/manager/pull/10901)) +- DisplayPrice story crash when Currency component's minimumFractionDigits is negative ([#10913](https://github.com/linode/manager/pull/10913)) +- Linode Create v2 not handling deprecated and EOL Images ([#10914](https://github.com/linode/manager/pull/10914)) +- API Tokens Table style regression ([#10918](https://github.com/linode/manager/pull/10918)) +- Incorrect avatar displaying in Notification Center for a small subset of events ([#10923](https://github.com/linode/manager/pull/10923)) + +### Tech Stories: + +- Introduce Mock Service Worker V2 ([#10610](https://github.com/linode/manager/pull/10610)) +- Replace lodash set utility function to handle security threat raised by Dependabot ([#10814](https://github.com/linode/manager/pull/10814)) +- Remove `eventMessages` feature flag logic and legacy code ([#10839](https://github.com/linode/manager/pull/10839)) +- Refactor `useToastNotification` async toasts ([#10841](https://github.com/linode/manager/pull/10841)) +- Update TypeScript and Vitest to latest ([#10843](https://github.com/linode/manager/pull/10843)) +- Remove global error interceptors ([#10850](https://github.com/linode/manager/pull/10850)) +- Update Node.js from `18.14` to `20.17` ([#10866](https://github.com/linode/manager/pull/10866)) +- Remove `placementGroups` feature flag and conditional rendering ([#10877](https://github.com/linode/manager/pull/10877)) +- Resolve "Incomplete string escape or encoding" codeQL alert in `generate-ansibleConfig.ts` ([#10887](https://github.com/linode/manager/pull/10887)) +- Remove `linodeCreateRefactor` feature flag ([#10921](https://github.com/linode/manager/pull/10921)) + +### Tests: + +- Add Cypress integration test for Secure VMs firewall generation ([#10802](https://github.com/linode/manager/pull/10802)) +- Add tests for NodeBalancer Create flow ([#10825](https://github.com/linode/manager/pull/10825)) +- Add unit test for LinodeVolumeAddDrawer and E2E test for client library update notices in Create/Attach Volume drawer ([#10837](https://github.com/linode/manager/pull/10837)) +- Add new tests for for selecting "All" Scopes ([#10852](https://github.com/linode/manager/pull/10852)) +- Add unit tests for NodeBalancerConfigPanel ([#10855](https://github.com/linode/manager/pull/10855)) +- Add `CY_TEST_ACCOUNT_CACHE_DIR` environment variable to enable retrieval of test account cache data ([#10867](https://github.com/linode/manager/pull/10867)) +- Allow tests to fall back on cached account data when API request fails ([#10867](https://github.com/linode/manager/pull/10867)) +- Add Cypress integration test for Object Storage Gen2: E2 Endpoint ([#10879](https://github.com/linode/manager/pull/10879)) +- Add Cypress integration test for Object Storage Gen2: E3 Endpoint ([#10880](https://github.com/linode/manager/pull/10880)) +- Add Cypress test for empty Linode landing page and restricted user Linode landing page ([#10882](https://github.com/linode/manager/pull/10882)) +- Update region selection helpers to account for upcoming Gecko improvements ([#10888](https://github.com/linode/manager/pull/10888)) +- Update remaining Linode Create Cypress tests run against Linode Create v2 ([#10889](https://github.com/linode/manager/pull/10889)) +- Add unit tests for SelectableTableRow component ([#10890](https://github.com/linode/manager/pull/10890)) +- Clean up feature flag mocks ([#10892](https://github.com/linode/manager/pull/10892)) +- Add Cypress test to confirm toast when updating RDNS, add unit tests for RDNS drawers ([#10895](https://github.com/linode/manager/pull/10895)) +- Add Cypress integration test for Object Storage Gen2: E1 Endpoint ([#10907](https://github.com/linode/manager/pull/10907)) +- Add unit tests for AttachVolumeDrawer component ([#10909](https://github.com/linode/manager/pull/10909)) +- Add unit tests for NodeBalancersLanding package ([#10911](https://github.com/linode/manager/pull/10911)) +- Support running component tests via CI ([#10926](https://github.com/linode/manager/pull/10926)) + +### Upcoming Features: + +- Support Volume Encryption and associated notices in Create/Attach Volume drawer ([#10837](https://github.com/linode/manager/pull/10837)) +- Add new `CloudPulseDashboardWithFilters` component that will be used as a reusable component in service provider pages ([#10845](https://github.com/linode/manager/pull/10845)) +- Fix Demo feedback and missing changes across ACLP ([#10851](https://github.com/linode/manager/pull/10851)) +- Add conditional client library update required reboot notice to Volume Create page ([#10868](https://github.com/linode/manager/pull/10868)) +- Add DBaaS V2 Create enhancements ([#10872](https://github.com/linode/manager/pull/10872)) +- Revert the “View Code Snippets” button copy to the original text ([#10886](https://github.com/linode/manager/pull/10886)) +- Add “Encrypt Volume” checkbox in Attach Volume drawer ([#10909](https://github.com/linode/manager/pull/10909)) +- Update BSE capability for Linodes to be `Block Storage Encryption` instead of `blockstorage_encryption` ([#10920](https://github.com/linode/manager/pull/10920)) + ## [2024-09-03] - v1.127.0 diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index da157f3e810..2cfa22aca4e 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -1,7 +1,7 @@ # `manager` # # Serves Cloud Manager. -FROM node:18.14-bullseye-slim as manager +FROM node:20.17-bullseye-slim as manager ENV NODE_ENV=development WORKDIR /home/node/app VOLUME /home/node/app diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index f5edf432f71..09322be48ba 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -16,6 +16,7 @@ import { splitCypressRun } from './cypress/support/plugins/split-run'; import { enableJunitReport } from './cypress/support/plugins/junit-report'; import { generateTestWeights } from './cypress/support/plugins/generate-weights'; import { logTestTagInfo } from './cypress/support/plugins/test-tagging-info'; +import cypressViteConfig from './cypress/vite.config'; /** * Exports a Cypress configuration object. @@ -45,6 +46,28 @@ export default defineConfig({ retries: process.env['CI'] && !process.env['CY_TEST_DISABLE_RETRIES'] ? 2 : 0, experimentalMemoryManagement: true, + + component: { + devServer: { + framework: 'react', + bundler: 'vite', + viteConfig: cypressViteConfig, + }, + indexHtmlFile: './cypress/support/component/index.html', + supportFile: './cypress/support/component/setup.tsx', + specPattern: './cypress/component/**/*.spec.tsx', + viewportWidth: 500, + viewportHeight: 500, + + setupNodeEvents(on, config) { + return setupPlugins(on, config, [ + loadEnvironmentConfig, + discardPassedTestRecordings, + enableJunitReport('Component', true), + ]); + }, + }, + e2e: { experimentalRunAllSpecs: true, @@ -70,7 +93,7 @@ export default defineConfig({ regionOverrideCheck, logTestTagInfo, splitCypressRun, - enableJunitReport, + enableJunitReport(), generateTestWeights, ]); }, diff --git a/packages/manager/cypress/component/poc/beta-chip.spec.tsx b/packages/manager/cypress/component/poc/beta-chip.spec.tsx new file mode 100644 index 00000000000..962e9ccf2b4 --- /dev/null +++ b/packages/manager/cypress/component/poc/beta-chip.spec.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { BetaChip } from 'src/components/BetaChip/BetaChip'; +import { componentTests, visualTests } from 'support/util/components'; +import { checkComponentA11y } from 'support/util/accessibility'; + +componentTests('BetaChip', () => { + visualTests((mount) => { + it('renders "BETA" text indicator with primary color', () => { + mount(); + cy.findByText('beta').should('be.visible'); + }); + + it('renders "BETA" text indicator with default color', () => { + mount(); + cy.findByText('beta').should('be.visible'); + }); + + it('passes aXe check with primary color', () => { + mount(); + checkComponentA11y(); + }); + + it('passes aXe check with default color', () => { + mount(); + checkComponentA11y(); + }); + }); +}); diff --git a/packages/manager/cypress/component/poc/region-select.spec.tsx b/packages/manager/cypress/component/poc/region-select.spec.tsx new file mode 100644 index 00000000000..e15411dece2 --- /dev/null +++ b/packages/manager/cypress/component/poc/region-select.spec.tsx @@ -0,0 +1,480 @@ +import * as React from 'react'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { componentTests, visualTests } from 'support/util/components'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { accountAvailabilityFactory, regionFactory } from 'src/factories'; +import { ui } from 'support/ui'; +import { mockGetAccountAvailability } from 'support/intercepts/account'; +import { createSpy } from 'support/util/components'; + +componentTests('RegionSelect', (mount) => { + beforeEach(() => { + mockGetAccountAvailability([]); + }); + + describe('Interactions', () => { + describe('Open menu', () => { + /* + * - Region selection drop-down can be opened by clicking arrow button. + */ + it('can open drop-down menu by clicking drop-down arrow', () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + }); + + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('be.visible'); + }); + + /* + * - Region selection drop-down can be opened by typing into text field. + */ + it('can open menu by typing into text field', () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + }); + + mount( + {}} + /> + ); + + // Focus text field by clicking "Region" label. + cy.findByText('Region').should('be.visible').click(); + + cy.focused().type(region.label[0]); + + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('be.visible'); + }); + }); + + describe('Close menu', () => { + /* + * - Region selection drop-down can be dismissed by pressing the ESC key. + */ + it('can close menu with ESC key', () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + }); + + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('be.visible'); + + cy.get('input').type('{esc}'); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + it('can close autocomplete popper by clicking away', () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + }); + mount( + <> + Other Element + {}} + /> + + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('be.visible'); + + cy.get('#other-element').click(); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + }); + + describe('Selection', () => { + const regionToPreselect = regionFactory.build(); + const regionToSelect = regionFactory.build(); + const otherRegions = regionFactory.buildList(10); + + const regions = [regionToPreselect, regionToSelect, ...otherRegions]; + + it('can select a region initially', () => { + mount( + {}} + /> + ); + + cy.get('input').should('have.attr', 'placeholder', 'Select a Region'); + cy.findByText('Region').should('be.visible').click(); + cy.focused().type(regionToSelect.label[0]); + + ui.autocompletePopper + .findByTitle(`${regionToSelect.label} (${regionToSelect.id})`) + .scrollIntoView() + .should('be.visible') + .click(); + + // Confirm that selection change is reflected by input field value, and that + // the autocomplete popper has been dismissed. + cy.get('input').should( + 'have.attr', + 'value', + `${regionToSelect.label} (${regionToSelect.id})` + ); + + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /* + * - User can can selection after having already selected a region. + */ + it('can change region selection', () => { + mount( + {}} + /> + ); + + cy.get('input').should( + 'have.attr', + 'value', + `${regionToPreselect.label} (${regionToPreselect.id})` + ); + + cy.findByText('Region').should('be.visible').click(); + + cy.focused().type(regionToSelect.label[0]); + + ui.autocompletePopper + .findByTitle(`${regionToSelect.label} (${regionToSelect.id})`) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('input').should( + 'have.attr', + 'value', + `${regionToSelect.label} (${regionToSelect.id})` + ); + + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + it('can clear region selection', () => { + mount( + {}} + /> + ); + + cy.get('input').should( + 'have.attr', + 'value', + `${regionToSelect.label} (${regionToSelect.id})` + ); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('input').should('have.attr', 'value', ''); + + cy.get('input').should('have.attr', 'placeholder', 'Select a Region'); + }); + + it('cannot clear region selection when clearable is disabled', () => { + mount( + {}} + /> + ); + + cy.get('input').should( + 'have.attr', + 'value', + `${regionToSelect.label} (${regionToSelect.id})` + ); + + cy.findByLabelText('Clear').should('not.exist'); + }); + + it('cannot clear region selection when no region is selected', () => { + mount( + {}} + /> + ); + + cy.get('input').should('have.attr', 'value', ''); + + cy.get('input').should('have.attr', 'placeholder', 'Select a Region'); + cy.findByLabelText('Clear').should('not.exist'); + }); + + it('calls `onChange` callback when region is initially selected', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByText('Region').should('be.visible').click(); + + cy.focused().type(regionToSelect.label[0]); + + ui.autocompletePopper + .findByTitle(`${regionToSelect.label} (${regionToSelect.id})`) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + + it('calls `onChange` callback when region is cleared', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + }); + }); + + describe('Logic', () => { + // TODO Gecko tests. + const regionsWithObj = regionFactory.buildList(5, { + capabilities: ['Object Storage'], + }); + const regionsWithoutObj = regionFactory.buildList(5, { + capabilities: [], + }); + const regionWithoutAvailability = regionFactory.build({ + capabilities: ['Object Storage'], + }); + const regions = [ + ...regionsWithObj, + ...regionsWithoutObj, + regionWithoutAvailability, + ]; + + it('excludes regions without availability (DC Get Well)', () => { + const mockAvailability = accountAvailabilityFactory.build({ + region: regionWithoutAvailability.id, + unavailable: ['Object Storage'], + }); + + mockGetAccountAvailability([mockAvailability]); + // TODO Remove `dcGetWell` flag override when feature flag is removed from codebase. + mount( + {}} + />, + { + dcGetWell: true, + } + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText( + `${regionWithoutAvailability.label} (${regionWithoutAvailability.id})` + ) + .as('regionItem') + .scrollIntoView(); + + cy.get('@regionItem').should('be.visible'); + + cy.findByText( + `${regionWithoutAvailability.label} (${regionWithoutAvailability.id})` + ) + .closest('li') + .should('have.attr', 'data-qa-disabled-item', 'true'); + }); + + it('only lists regions with the specified capability', () => { + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + regionsWithObj.forEach((region) => { + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .scrollIntoView() + .should('be.visible'); + }); + regionsWithoutObj.forEach((region) => { + ui.autocompletePopper.find().within(() => { + cy.findByText(`${region.label} (${region.id})`).should('not.exist'); + }); + }); + }); + + it('lists all regions when no capability is specified', () => { + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + regions.forEach((region) => { + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .scrollIntoView() + .should('be.visible'); + }); + }); + }); + + visualTests((mount) => { + describe('Accessibility checks', () => { + const selectedRegion = regionFactory.build(); + const regions = [selectedRegion, ...regionFactory.buildList(5)]; + + it('passes aXe check when menu is closed without an item selected', () => { + mount( + {}} + /> + ); + checkComponentA11y(); + }); + + it('passes aXe check when menu is closed with an item selected', () => { + mount( + {}} + /> + ); + checkComponentA11y(); + }); + + it('passes aXe check when menu is open', () => { + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + checkComponentA11y(); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index 270f5568f23..7df2637bbc7 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 @@ -24,6 +24,8 @@ describe('Personal access tokens', () => { * - Confirms that user is shown the token secret upon successful PAT creation * - Confirms that new personal access token is shown in list * - Confirms that user can open and close "View Scopes" drawer + * - Confirm that the “Child account access” grant is not visible in the list of permissions. + * - Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. */ it('can create personal access tokens', () => { const token = appTokenFactory.build({ @@ -63,6 +65,9 @@ describe('Personal access tokens', () => { .findByTitle('Add Personal Access Token') .should('be.visible') .within(() => { + // Confirm that the “Child account access” grant is not visible in the list of permissions. + cy.findAllByText('Child Account Access').should('not.exist'); + // Confirm submit button is disabled without specifying scopes. ui.buttonGroup .findButtonByTitle('Create Token') @@ -147,7 +152,12 @@ describe('Personal access tokens', () => { }); // Confirm that new PAT is shown in list and "View Scopes" drawer works. - cy.wait('@getTokens'); + // Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + cy.wait('@getTokens').then((xhr) => { + const actualTokenData = xhr.response?.body.data; + const actualTokenScopes = actualTokenData[0].scopes; + expect(actualTokenScopes).to.equal(token.scopes); + }); cy.findByText(token.label) .should('be.visible') .closest('tr') 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 1d36175f276..fc41156e199 100644 --- a/packages/manager/cypress/e2e/core/account/user-profile.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-profile.spec.ts @@ -36,8 +36,8 @@ describe('User Profile', () => { cy.visitWithLogin(`account/users/${activeUsername}`); cy.wait('@getUser'); - cy.findByText('Username').should('be.visible'); - cy.findByText('Email').should('be.visible'); + cy.findByLabelText('Username').should('be.visible'); + cy.findByLabelText('Email').should('be.visible'); cy.findByText('Delete User').should('be.visible'); // Confirm the currently active user cannot be deleted. @@ -125,8 +125,8 @@ describe('User Profile', () => { cy.wait('@getUser'); - cy.findByText('Username').should('be.visible'); - cy.findByText('Email').should('be.visible'); + cy.findByLabelText('Username').should('be.visible'); + cy.findByLabelText('Email').should('be.visible'); cy.findByText('Delete User').should('be.visible'); ui.button.findByTitle('Delete').should('be.visible').should('be.enabled'); @@ -210,8 +210,8 @@ describe('User Profile', () => { cy.wait('@getUser'); - cy.findByText('Username').should('be.visible'); - cy.findByText('Email').should('be.visible'); + cy.findByLabelText('Username').should('be.visible'); + cy.findByLabelText('Email').should('be.visible'); cy.findByText('Delete User').should('be.visible'); cy.get('[id="username"]') 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 4597d099fda..eb9db1dcfaa 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 @@ -444,7 +444,6 @@ describe('Users landing page', () => { }); mockGetUsers([mockUser]).as('getUsers'); - mockGetUser(mockUser); mockGetUserGrantsUnrestrictedAccess(mockUser.username); mockAddUser(newUser).as('addUser'); @@ -488,6 +487,8 @@ describe('Users landing page', () => { .should('be.enabled') .click(); + mockGetUser(newUser).as('getUser'); + // confirm to add a new user ui.drawer .findByTitle('Add a User') @@ -535,10 +536,8 @@ describe('Users landing page', () => { cy.wait('@addUser').then((intercept) => { expect(intercept.request.body['restricted']).to.equal(newUser.restricted); }); - cy.wait('@getUsers'); - // the new user is displayed in the user list - cy.findByText(newUser.username).should('be.visible'); + cy.wait('@getUser'); // redirects to the new user's "User Permissions" page cy.url().should('endWith', `/users/${newUser.username}/permissions`); 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 c73550b7a13..346023aa089 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -2,13 +2,8 @@ import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories/account'; import type { Account } from '@linode/api-v4'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { TAX_ID_HELPER_TEXT } from 'src/features/Billing/constants'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import type { Flags } from 'src/featureFlags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ @@ -72,11 +67,10 @@ const checkAccountContactDisplay = (accountInfo: Account) => { describe('Billing Contact', () => { beforeEach(() => { mockAppendFeatureFlags({ - taxId: makeFeatureFlagData({ + taxId: { enabled: true, - }), + }, }); - mockGetFeatureFlagClientstream(); }); it('Edit Contact Info', () => { // mock the user's account data and confirm that it is displayed correctly upon page load 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 089e766fb93..282a1bf48f5 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -1,16 +1,10 @@ import { ui } from 'support/ui'; -import { fbtClick, getClick } from 'support/helpers'; -import { regionFactory } from '@src/factories'; +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 { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; - import type { Region } from '@linode/api-v4'; +import { mockCreateLinode } from 'support/intercepts/linodes'; const mockRegions: Region[] = [ regionFactory.build({ @@ -102,15 +96,7 @@ describe('GDPR agreement', () => { cy.get('[data-testid="eu-agreement-checkbox"]').should('not.exist'); }); - it('needs the agreement checked to validate the form', () => { - // This test does not apply to Linode Create v2 because - // Linode Create v2 allows you to press "Create Linode" - // without checking the GDPR checkbox. (The user will - // get a validation error if they have not agreed). - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); + it('needs the agreement checked to submit the form', () => { mockGetRegions(mockRegions).as('getRegions'); mockGetAccountAgreements({ privacy_policy: false, @@ -120,26 +106,50 @@ describe('GDPR agreement', () => { const linodeLabel = randomLabel(); cy.visitWithLogin('/linodes/create'); - cy.wait(['@getAgreements', '@getRegions']); + cy.wait(['@getRegions']); // Paris should have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('fr-par').click(); - cy.get('[data-testid="eu-agreement-checkbox"]').should('be.visible'); - // Fill out the form - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"]'); - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); + cy.wait('@getAgreements'); + + cy.findByText('Shared CPU').click(); + + cy.get('[id="g6-nanode-1"]').click(); + + cy.findByLabelText('Linode Label').clear().type(linodeLabel); - // expect the button to be disabled - cy.get('[data-qa-deploy-linode="true"]').should('be.disabled'); + cy.findByLabelText('Root Password').type(rootpass); + + cy.get('[data-testid="eu-agreement-checkbox"]') + .scrollIntoView() + .should('be.visible'); + + cy.findByText('Create Linode') + .scrollIntoView() + .should('be.enabled') + .should('be.visible') + .click(); + + cy.findByText( + 'You must agree to the EU agreement to deploy to this region.' + ).should('be.visible'); // check the agreement - getClick('#gdpr-checkbox'); + cy.get('#gdpr-checkbox').click(); + + cy.findByText( + 'You must agree to the EU agreement to deploy to this region.' + ).should('not.exist'); + + mockCreateLinode(linodeFactory.build()).as('createLinode'); + + cy.findByText('Create Linode') + .should('be.enabled') + .should('be.visible') + .click(); - // expect the button to be enabled - cy.get('[data-qa-deploy-linode="true"]').should('not.be.disabled'); + cy.wait('@createLinode'); }); }); 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 f9eb2a840cb..46f3867d7a1 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,9 +1,5 @@ import 'cypress-file-upload'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { randomItem, @@ -48,9 +44,8 @@ describe('close support tickets', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets([mockTicket]); mockGetSupportTicket(mockTicket).as('getSupportTicket'); mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); @@ -107,9 +102,8 @@ describe('close support tickets', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets([mockTicket]); mockGetSupportTicket(mockTicket).as('getSupportTicket'); mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); 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 bffc53d6cef..5644f5deea4 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 @@ -2,11 +2,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import 'cypress-file-upload'; import { interceptGetProfile } from 'support/intercepts/profile'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { randomItem, @@ -69,9 +65,8 @@ describe('open support tickets', () => { */ it('can open a support ticket', () => { mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(false), + supportTicketSeverity: false, }); - mockGetFeatureFlagClientstream(); const image = 'test_screenshot.png'; const ticketDescription = 'this is a test ticket'; @@ -159,9 +154,8 @@ describe('open support tickets', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockCreateSupportTicket(mockTicket).as('createTicket'); mockGetSupportTickets([]); mockGetSupportTicket(mockTicket); 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 2c511d9810f..7fc84b7a74b 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,9 +1,5 @@ import { interceptGetProfile } from 'support/intercepts/profile'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { randomItem, randomLabel, @@ -38,9 +34,8 @@ describe('support tickets landing page', () => { */ it('shows the empty message when there are no tickets.', () => { mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(false), + supportTicketSeverity: false, }); - mockGetFeatureFlagClientstream(); interceptGetProfile().as('getProfile'); @@ -92,9 +87,8 @@ describe('support tickets landing page', () => { const mockTickets = [mockTicket, mockAnotherTicket]; mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets(mockTickets); cy.visitWithLogin('/support/tickets'); @@ -156,9 +150,8 @@ describe('support tickets landing page', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets([mockTicket]); mockGetSupportTicket(mockTicket).as('getSupportTicket'); mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); @@ -251,9 +244,8 @@ describe('support tickets landing page', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets([mockTicket]); mockGetSupportTicket(mockTicket).as('getSupportTicket'); mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); 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 1415f28d93c..b118a4cad41 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -5,7 +5,6 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomPhrase } from 'support/util/random'; import { mockGetRegions } from 'support/intercepts/regions'; @@ -127,7 +126,7 @@ describe('create image (e2e)', () => { it('displays notice informing user that Images are not encrypted, provided the LDE feature is enabled and the selected linode is not in an Edge region', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), + linodeDiskEncryption: true, }).as('getFeatureFlags'); // Mock responses @@ -164,7 +163,7 @@ describe('create image (e2e)', () => { it('does not display a notice informing user that Images are not encrypted if the LDE feature is disabled', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(false), + linodeDiskEncryption: false, }).as('getFeatureFlags'); // Mock responses @@ -201,7 +200,7 @@ describe('create image (e2e)', () => { it('does not display a notice informing user that Images are not encrypted if the selected linode is in an Edge region', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), + linodeDiskEncryption: true, }).as('getFeatureFlags'); // Mock responses diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index fbd28300d66..25339973135 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -81,9 +81,7 @@ const eventIntercept = ( * @param message - Expected failure message. */ const assertFailed = (label: string, id: string, message: string) => { - ui.toast.assertMessage( - `There was a problem uploading image ${label}: ${message}` - ); + ui.toast.assertMessage(`Image ${label} could not be uploaded: ${message}`); cy.get(`[data-qa-image-cell="${id}"]`).within(() => { fbtVisible(label); diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index 1d5409eea74..9ec97afd8fb 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -6,19 +6,23 @@ import { } from 'support/intercepts/images'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import type { Image } from '@linode/api-v4'; +import type { Image, Region } from '@linode/api-v4'; import { extendRegion } from 'support/util/regions'; -describe('Manage Image Regions', () => { +describe('Manage Image Replicas', () => { /** * Adds two new regions to an Image (region3 and region4) * and removes one existing region (region 1). */ it("updates an Image's regions", () => { - const region1 = extendRegion(regionFactory.build({ site_type: 'core' })); - const region2 = extendRegion(regionFactory.build({ site_type: 'core' })); - const region3 = extendRegion(regionFactory.build({ site_type: 'core' })); - const region4 = extendRegion(regionFactory.build({ site_type: 'core' })); + const regionOptions: Partial = { + site_type: 'core', + capabilities: ['Object Storage'], + }; + const region1 = extendRegion(regionFactory.build(regionOptions)); + const region2 = extendRegion(regionFactory.build(regionOptions)); + const region3 = extendRegion(regionFactory.build(regionOptions)); + const region4 = extendRegion(regionFactory.build(regionOptions)); const image = imageFactory.build({ size: 50, @@ -41,28 +45,28 @@ describe('Manage Image Regions', () => { .closest('tr') .within(() => { // Verify total size is rendered - cy.findByText(`${image.total_size} MB`).should('be.visible'); + cy.findByText(`0.1 GB`).should('be.visible'); // 100 / 1024 = 0.09765 // Verify capabilities are rendered cy.findByText('Distributed').should('be.visible'); - // Verify the first region is rendered - cy.findByText(region1.label + ',').should('be.visible'); - - // Click the "+1" - cy.findByText('+1').should('be.visible').should('be.enabled').click(); + // Verify the number of regions is rendered and click it + cy.findByText(`${image.regions.length} Regions`) + .should('be.visible') + .should('be.enabled') + .click(); }); - // Verify the Manage Regions drawer opens and contains basic content + // Verify the Manage Replicas drawer opens and contains basic content ui.drawer - .findByTitle(`Manage Regions for ${image.label}`) + .findByTitle(`Manage Replicas for ${image.label}`) .should('be.visible') .within(() => { // Verify the Image regions render cy.findByText(region1.label).should('be.visible'); cy.findByText(region2.label).should('be.visible'); - cy.findByText('Image will be available in these regions (2)').should( + cy.findByText('Image will be replicated in these regions (2)').should( 'be.visible' ); @@ -72,7 +76,7 @@ describe('Manage Image Regions', () => { .should('be.visible') .should('be.disabled'); - // Close the Manage Regions drawer + // Close the Manage Replicas drawer ui.button .findByTitle('Cancel') .should('be.visible') @@ -91,9 +95,9 @@ describe('Manage Image Regions', () => { .click(); }); - // Click "Manage Regions" option in the action menu + // Click "Manage Replicas" option in the action menu ui.actionMenuItem - .findByTitle('Manage Regions') + .findByTitle('Manage Replicas') .should('be.visible') .should('be.enabled') .click(); @@ -142,7 +146,7 @@ describe('Manage Image Regions', () => { // Click outside of the Region Multi-Select to close the popover ui.drawer - .findByTitle(`Manage Regions for ${image.label}`) + .findByTitle(`Manage Replicas for ${image.label}`) .click() .within(() => { // Verify the existing image regions render @@ -155,7 +159,7 @@ describe('Manage Image Regions', () => { cy.findAllByText('unsaved').should('be.visible'); // Verify the count is now 3 - cy.findByText('Image will be available in these regions (4)').should( + cy.findByText('Image will be replicated in these regions (4)').should( 'be.visible' ); @@ -172,7 +176,7 @@ describe('Manage Image Regions', () => { cy.findByText(region1.label).should('not.exist'); // Verify the count is now 3 - cy.findByText('Image will be available in these regions (3)').should( + cy.findByText('Image will be replicated in these regions (3)').should( 'be.visible' ); @@ -190,19 +194,17 @@ describe('Manage Image Regions', () => { .closest('tr') .within(() => { // Verify the new size is shown - cy.findByText('150 MB'); + cy.findByText('0.15 GB'); // 150 / 2014 = 0.1464 - // Verify the first region is rendered - cy.findByText(region2.label + ',').should('be.visible'); - - cy.findByText('+2').should('be.visible').should('be.enabled'); - - // Verify the regions count is now "+2" and open the drawer - cy.findByText('+2').should('be.visible').should('be.enabled').click(); + // Verify the new number of regions is shown and click it + cy.findByText(`${updatedImage.regions.length} Regions`) + .should('be.visible') + .should('be.enabled') + .click(); }); ui.drawer - .findByTitle(`Manage Regions for ${image.label}`) + .findByTitle(`Manage Replicas for ${image.label}`) .click() .within(() => { // "Unsaved" regions should transition to "pending replication" because diff --git a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts index f6e0a0e1cdc..1ee83565388 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -107,6 +107,6 @@ describe('create image (using mocks)', () => { cy.wait('@getEvents'); // Verify a success toast shows - ui.toast.assertMessage('Image My Config successfully created.'); + ui.toast.assertMessage('Image My Config has been created.'); }); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index 24c7fcbfeb0..b97a4b77baf 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 @@ -12,20 +12,15 @@ import { import { getRegionById } from 'support/util/regions'; import { readDownload } from 'support/util/downloads'; import { ui } from 'support/ui'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; describe('LKE landing page', () => { it('does not display a Disk Encryption info banner if the LDE feature is disabled', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(false), + linodeDiskEncryption: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ @@ -51,9 +46,8 @@ describe('LKE landing page', () => { it('displays a Disk Encryption info banner if the LDE feature is enabled', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), + linodeDiskEncryption: true, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index cd9a78f2258..c64e43abba6 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -106,7 +106,7 @@ describe('clone linode', () => { ui.toast.assertMessage(`Your Linode ${newLinodeLabel} is being created.`); ui.toast.assertMessage( - `Linode ${linode.label} successfully cloned to ${newLinodeLabel}.`, + `Linode ${linode.label} has been cloned to ${newLinodeLabel}.`, { timeout: CLONE_TIMEOUT } ); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts index bf7774cc0bd..1cc4bfa5f59 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 @@ -7,23 +7,10 @@ import { MOBILE_VIEWPORTS } from 'support/constants/environment'; import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { ui } from 'support/ui'; import { mockCreateLinode } from 'support/intercepts/linodes'; describe('Linode create mobile smoke', () => { - // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - MOBILE_VIEWPORTS.forEach((viewport) => { /* * - Confirms Linode create flow can be completed on common mobile screen sizes diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts new file mode 100644 index 00000000000..069fc806b06 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts @@ -0,0 +1,91 @@ +import { ui } from 'support/ui'; +import { regionFactory } from '@src/factories'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { extendRegion } from 'support/util/regions'; + +import type { ExtendedRegion } from 'support/util/regions'; + +const mockRegions: ExtendedRegion[] = [ + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + country: 'uk', + id: 'eu-west', + label: 'London, UK', + }) + ), + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + }) + ), + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-east', + label: 'Newark, NJ', + }) + ), + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + }) + ), +]; + +describe('Linode Create Region Select', () => { + /* + * Region select test. + * + * TODO: Cypress + * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 + * + * - Confirms that region select dropdown is visible and interactive. + * - Confirms that region select dropdown is populated with expected regions. + * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. + * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. + */ + it('region select', () => { + mockGetRegions(mockRegions).as('getRegions'); + + cy.visitWithLogin('linodes/create'); + + cy.wait('@getRegions'); + + // Confirm that region select dropdown is visible and interactive. + ui.regionSelect.find().click(); + cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); + + // Confirm that region select dropdown are grouped by region, + // sorted alphabetically, with North America first. + cy.get('.MuiAutocomplete-groupLabel') + .should('have.length', 3) + .should((group) => { + expect(group[0]).to.contain('North America'); + expect(group[1]).to.contain('Asia'); + expect(group[2]).to.contain('Europe'); + }); + + // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. + cy.get('[data-qa-option]').should('exist').should('have.length', 4); + mockRegions.forEach((region) => { + cy.get('[data-qa-option]').contains(region.label); + }); + + // Select an option + cy.findByTestId('eu-west').click(); + // Confirm the popper is closed + cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); + + // Confirm that the selected region is displayed in the input field. + cy.findByLabelText('Region').should('have.value', 'UK, London (eu-west)'); + + // Confirm that selecting a valid region updates the Plan Selection panel. + expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); + }); +}); 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 fc0ac2643a5..40cc3f15e78 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,12 +6,8 @@ import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { linodeCreatePage } from 'support/ui/pages'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; - -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { chooseRegion } from 'support/util/regions'; describe('Create Linode', () => { /* @@ -22,10 +18,9 @@ describe('Create Linode', () => { // TODO Delete these mocks once `apicliDxToolsAdditions` feature flag is retired. beforeEach(() => { mockAppendFeatureFlags({ - apicliDxToolsAdditions: makeFeatureFlagData(true), - linodeCreateRefactor: makeFeatureFlagData(true), + apicliDxToolsAdditions: true, + testdxtoolabexperiment: 'Create using command line', }); - mockGetFeatureFlagClientstream(); }); it(`view code snippets in create linode flow`, () => { const linodeLabel = randomLabel(); @@ -42,7 +37,7 @@ describe('Create Linode', () => { // View Code Snippets and confirm it's provisioned as expected. ui.button - .findByTitle('View Code Snippets') + .findByTitle('Create using command line') .should('be.visible') .should('be.enabled') .click(); @@ -149,15 +144,15 @@ describe('Create Linode', () => { }); }); }); + describe('Create Linode flow with apicliDxToolsAdditions disabled', () => { // Enable the `apicliDxToolsAdditions` feature flag. // TODO Delete these mocks and test once `apicliDxToolsAdditions` feature flag is retired. beforeEach(() => { mockAppendFeatureFlags({ - apicliDxToolsAdditions: makeFeatureFlagData(false), - linodeCreateRefactor: makeFeatureFlagData(true), + apicliDxToolsAdditions: false, + testdxtoolabexperiment: 'Create using command line', }); - mockGetFeatureFlagClientstream(); }); it(`view code snippets in create linode flow`, () => { const linodeLabel = randomLabel(); @@ -202,5 +197,75 @@ describe('Create Linode', () => { .click(); }); }); + it('creates a linode via CLI', () => { + const linodeLabel = randomLabel(); + const linodePass = randomString(32); + const linodeRegion = chooseRegion(); + + cy.visitWithLogin('/linodes/create'); + + ui.regionSelect.find().click(); + ui.autocompletePopper + .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) + .should('exist') + .click(); + + cy.get('[id="g6-dedicated-2"]').click(); + + cy.findByLabelText('Linode Label').should( + 'have.value', + `debian-${linodeRegion.id}` + ); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .should('be.enabled') + .clear() + .type(linodeLabel); + + cy.findByLabelText('Root Password') + .should('be.visible') + .should('be.enabled') + .type(linodePass); + + ui.button + .findByTitle('Create using command line') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + // Switch to cURL view if necessary. + cy.findByText('cURL').should('be.visible').click(); + + // Confirm that cURL command has expected details. + [ + `"region": "${linodeRegion.id}"`, + `"type": "g6-dedicated-2"`, + `"label": "${linodeLabel}"`, + `"root_pass": "${linodePass}"`, + ].forEach((line: string) => + cy.findByText(line, { exact: false }).should('be.visible') + ); + + cy.findByText('Linode CLI').should('be.visible').click(); + + [ + `--region ${linodeRegion.id}`, + '--type g6-dedicated-2', + `--label ${linodeLabel}`, + `--root_pass ${linodePass}`, + ].forEach((line: string) => cy.contains(line).should('be.visible')); + + ui.buttonGroup + .findButtonByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); }); }); 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 23d62bf666f..c6d8befe082 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts @@ -1,27 +1,14 @@ import { linodeFactory } from 'src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockCreateLinode, mockGetLinodeDetails, } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; describe('Create Linode with Add-ons', () => { - // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms UI flow to create a Linode with backups using mock API data. * - Confirms that backups is reflected in create summary section. diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts new file mode 100644 index 00000000000..bb2b40b205d --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts @@ -0,0 +1,118 @@ +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; +import { getRegionById } from 'support/util/regions'; +import { linodeFactory } from '@src/factories'; +import { + dcPricingPlanPlaceholder, + dcPricingMockLinodeTypes, + dcPricingDocsLabel, + dcPricingDocsUrl, +} from 'support/constants/dc-specific-pricing'; +import { + mockCreateLinode, + mockGetLinodeType, + mockGetLinodeTypes, +} from 'support/intercepts/linodes'; + +describe('Create Linode with DC-specific pricing', () => { + /* + * - Confirms DC-specific pricing UI flow works as expected during Linode creation. + * - Confirms that pricing docs link is shown in "Region" section. + * - Confirms that backups pricing is correct when selecting a region with a different price structure. + */ + it('shows DC-specific pricing information during create flow', () => { + const linodeLabel = randomLabel(); + const initialRegion = getRegionById('us-west'); + const newRegion = getRegionById('us-east'); + + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: initialRegion.id, + type: dcPricingMockLinodeTypes[0].id, + }); + + const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( + (linodeType) => linodeType.id === newRegion.id + )!; + const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === newRegion.id + )!; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes']); + + mockCreateLinode(mockLinode).as('linodeCreated'); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + ui.button.findByTitle('Create Linode').click(); + + // A message is shown to instruct users to select a region in order to view plans and prices + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + 'Plan is required.' + ); + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + dcPricingPlanPlaceholder + ); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); + cy.findByText('Shared CPU').click(); + cy.get(`[id="${dcPricingMockLinodeTypes[0].id}"]`).click(); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]').within(() => { + cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirm that the checkout summary at the bottom of the page reflects the correct price. + cy.get('[data-qa-linode-create-summary="true"]').within(() => { + cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + // Confirm there is a docs link to the pricing page. + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); + + ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); + cy.findByText('Shared CPU').click(); + cy.get(`[id="${dcPricingMockLinodeTypes[0].id}"]`).click(); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]').within(() => { + cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. + cy.get('[data-qa-linode-create-summary="true"]').within(() => { + cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + }); +}); 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 new file mode 100644 index 00000000000..cf8707c1ec9 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts @@ -0,0 +1,80 @@ +import { ui } from 'support/ui'; +import { accountFactory, regionFactory } from '@src/factories'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + checkboxTestId, + headerTestId, +} from 'src/components/Encryption/Encryption'; + +describe('Create Linode with Disk Encryption', () => { + it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); + }); + + it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegion = regionFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegionWithoutDiskEncryption = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid="${headerTestId}"]`).should('exist'); + + // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected + ui.regionSelect.find().click(); + ui.select + .findItemByText( + `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` + ) + .click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); + + ui.regionSelect.find().click(); + ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts index 099ffbe79ef..70a759c140a 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -1,8 +1,8 @@ -import { linodeFactory, firewallFactory } from 'src/factories'; import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; + linodeFactory, + firewallFactory, + firewallTemplateFactory, +} from 'src/factories'; import { mockCreateLinode, mockGetLinodeDetails, @@ -10,22 +10,15 @@ import { import { mockGetFirewalls, mockCreateFirewall, + mockGetTemplate, + mockCreateFirewallError, } from 'support/intercepts/firewalls'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; describe('Create Linode with Firewall', () => { - // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms UI flow to create a Linode with an existing Firewall using mock API data. * - Confirms that Firewall is reflected in create summary section. @@ -179,4 +172,177 @@ describe('Create Linode with Firewall', () => { // Confirm toast notification should appear on Linode create. ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); }); + + /* + * - Mocks the internal header to enable the Generate Compliant Firewall banner. + * - Confirms that Firewall is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies the selected Firewall to be attached. + */ + it('can generate and assign a compliant Firewall during Linode Create flow', () => { + cy.intercept( + { + middleware: true, + url: /\/v4(?:beta)?\/.*/, + }, + (req) => { + // Re-add internal-only header + req.on('response', (res) => { + res.headers['akamai-internal-account'] = '*'; + }); + } + ); + + const linodeRegion = chooseRegion({ capabilities: ['Cloud Firewall'] }); + + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockTemplate = firewallTemplateFactory.build({ + slug: 'akamai-non-prod', + }); + + mockCreateFirewall(mockFirewall).as('createFirewall'); + mockGetFirewalls([mockFirewall]).as('getFirewall'); + mockGetTemplate(mockTemplate).as('getTemplate'); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Creating the linode without a firewall should display a warning. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.disabled'); + + cy.findByLabelText( + 'I am authorized to create a Linode without a Cloud Firewall' + ).click(); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled'); + + cy.findByText('Generate Compliant Firewall').should('be.visible').click(); + + ui.dialog + .findByTitle('Generate an Akamai Compliant Firewall') + .should('be.visible') + .within(() => { + cy.findByText('Generate Firewall Now') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Generating Firewall'); + cy.findByText('Complete!'); + cy.findByText('OK').should('be.visible').should('be.enabled').click(); + }); + cy.wait('@createFirewall'); + + cy.findByText(mockFirewall.label).should('be.visible'); + + // Confirm Firewall assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Firewall Assigned').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const firewallId = requestPayload['firewall_id']; + expect(firewallId).to.equal(mockFirewall.id); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); + + /* + * - Mocks the internal header to enable the Generate Compliant Firewall banner. + * - Mocks an error response to the Create Firewall call. + */ + it('displays errors encountered while trying to generate a compliant firewall', () => { + cy.intercept( + { + middleware: true, + url: /\/v4(?:beta)?\/.*/, + }, + (req) => { + // Re-add internal-only header + req.on('response', (res) => { + res.headers['akamai-internal-account'] = '*'; + }); + } + ); + + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + const mockTemplate = firewallTemplateFactory.build({ + slug: 'akamai-non-prod', + }); + + const mockError = 'Mock error'; + + mockGetFirewalls([mockFirewall]).as('getFirewall'); + mockGetTemplate(mockTemplate).as('getTemplate'); + mockCreateFirewallError(mockError).as('createFirewall'); + + cy.visitWithLogin('/linodes/create'); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled'); + + cy.findByText('Generate Compliant Firewall').should('be.visible').click(); + + ui.dialog + .findByTitle('Generate an Akamai Compliant Firewall') + .should('be.visible') + .within(() => { + cy.findByText('Generate Firewall Now') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Generating Firewall'); + cy.findByText(mockError); + cy.findByText('Retry').should('be.visible').should('be.enabled'); + cy.findByText('Close').should('be.visible').should('be.enabled'); + }); + cy.wait('@createFirewall'); + }); }); diff --git a/packages/manager/cypress/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 07a04310671..b613f8cf384 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 @@ -3,11 +3,6 @@ import { linodeFactory, sshKeyFactory, } from 'src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; @@ -17,14 +12,6 @@ import { ui } from 'support/ui'; import { mockCreateSSHKey } from 'support/intercepts/profile'; describe('Create Linode with SSH Key', () => { - // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms UI flow when creating a Linode with an authorized SSH key. * - Confirms that existing SSH keys are listed on page and can be selected. diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts index 21096becdf3..8951ed66e9b 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -1,8 +1,4 @@ import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; import { mockCreateLinode, @@ -11,18 +7,10 @@ import { import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; describe('Create Linode with user data', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms UI flow to create a Linode with cloud-init user data specified. * - Confirms that outgoing API request contains expected user data payload. @@ -130,6 +118,8 @@ describe('Create Linode with user data', () => { vendor: 'Debian', // `cloud-init` is omitted from Image capabilities. capabilities: [], + // null eol so that the image is not deprecated + eol: null, }); mockGetImage(mockImage.id, mockImage); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index 65361c25d01..d80d2a963b9 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 @@ -2,11 +2,6 @@ import { linodeFactory, regionFactory, VLANFactory } from 'src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { chooseRegion } from 'support/util/regions'; import { randomIp, @@ -18,14 +13,6 @@ import { mockGetVLANs } from 'support/intercepts/vlans'; import { mockCreateLinode } from 'support/intercepts/linodes'; describe('Create Linode with VLANs', () => { - // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Uses mock API data to confirm VLAN attachment UI flow during Linode create. * - Confirms that outgoing Linode create API request contains expected data for VLAN. 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 238be908e06..44041fc9dc6 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 @@ -4,10 +4,6 @@ import { subnetFactory, vpcFactory, } from 'src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockCreateLinode, mockGetLinodeDetails, @@ -21,7 +17,6 @@ import { } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; import { linodeCreatePage, vpcCreateDrawer } from 'support/ui/pages'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomIp, randomLabel, @@ -32,14 +27,6 @@ import { import { chooseRegion } from 'support/util/regions'; describe('Create Linode with VPCs', () => { - // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms UI flow to create a Linode with an existing VPC assigned using mock API data. * - Confirms that VPC assignment is reflected in create summary section. 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 bf86857ff8c..f57807153d6 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -9,15 +9,10 @@ import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { cleanUp } from 'support/util/cleanup'; import { linodeCreatePage } from 'support/ui/pages'; import { authenticate } from 'support/api/authentication'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { interceptCreateLinode, mockCreateLinodeError, } from 'support/intercepts/linodes'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { interceptGetProfile } from 'support/intercepts/profile'; import { Region, VLAN, Config, Disk } from '@linode/api-v4'; import { getRegionById } from 'support/util/regions'; @@ -62,15 +57,6 @@ describe('Create Linode', () => { cleanUp('ssh-keys'); }); - // Enable the `linodeCreateRefactor` feature flag. - // TODO Delete these mocks once `linodeCreateRefactor` feature flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * End-to-end tests to create Linodes for each available plan type. */ diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts deleted file mode 100644 index 48ece191c37..00000000000 --- a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts +++ /dev/null @@ -1,609 +0,0 @@ -/** - * @file Integration tests and end-to-end tests for legacy Linode Create flow. - */ -// TODO Delete this test file when `linodeCreateRefactor` feature flag is retired. -// Move out any tests (e.g. region select test) for flows that aren't covered by new tests in the meantime. - -import { - containsVisible, - fbtClick, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; -import { ui } from 'support/ui'; -import { randomString, randomLabel, randomNumber } from 'support/util/random'; -import { chooseRegion } from 'support/util/regions'; -import { getRegionById } from 'support/util/regions'; -import { - accountFactory, - subnetFactory, - vpcFactory, - linodeFactory, - linodeConfigFactory, - regionFactory, - VLANFactory, - LinodeConfigInterfaceFactory, - LinodeConfigInterfaceFactoryWithVPC, -} from '@src/factories'; -import { authenticate } from 'support/api/authentication'; -import { cleanUp } from 'support/util/cleanup'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { - dcPricingPlanPlaceholder, - dcPricingMockLinodeTypes, - dcPricingDocsLabel, - dcPricingDocsUrl, -} from 'support/constants/dc-specific-pricing'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { - interceptCreateLinode, - mockCreateLinode, - mockGetLinodeType, - mockGetLinodeTypes, - mockGetLinodeDisks, - mockGetLinodeVolumes, -} from 'support/intercepts/linodes'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - checkboxTestId, - headerTestId, -} from 'src/components/Encryption/Encryption'; -import { extendRegion } from 'support/util/regions'; - -import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; -import type { ExtendedRegion } from 'support/util/regions'; - -const mockRegions: ExtendedRegion[] = [ - extendRegion( - regionFactory.build({ - capabilities: ['Linodes'], - country: 'uk', - id: 'eu-west', - label: 'London, UK', - }) - ), - extendRegion( - regionFactory.build({ - capabilities: ['Linodes'], - country: 'sg', - id: 'ap-south', - label: 'Singapore, SG', - }) - ), - extendRegion( - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-east', - label: 'Newark, NJ', - }) - ), - extendRegion( - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-central', - label: 'Dallas, TX', - }) - ), -]; - -authenticate(); -describe('create linode', () => { - before(() => { - cleanUp('linodes'); - }); - - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(false), - apicliDxToolsAdditions: makeFeatureFlagData(false), - }); - }); - - /* - * Region select test. - * - * TODO: Cypress - * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 - * - * - Confirms that region select dropdown is visible and interactive. - * - Confirms that region select dropdown is populated with expected regions. - * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. - * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. - */ - it('region select', () => { - mockGetRegions(mockRegions).as('getRegions'); - - cy.visitWithLogin('linodes/create'); - - cy.wait(['@getRegions']); - - // Confirm that region select dropdown is visible and interactive. - ui.regionSelect.find().click(); - cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); - - // Confirm that region select dropdown are grouped by region, - // sorted alphabetically, with North America first. - cy.get('.MuiAutocomplete-groupLabel') - .should('have.length', 3) - .should((group) => { - expect(group[0]).to.contain('North America'); - expect(group[1]).to.contain('Asia'); - expect(group[2]).to.contain('Europe'); - }); - - // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. - cy.get('[data-qa-option]').should('exist').should('have.length', 4); - mockRegions.forEach((region) => { - cy.get('[data-qa-option]').contains(region.label); - }); - - // Select an option - cy.findByTestId('eu-west').click(); - // Confirm the popper is closed - cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); - // Confirm that the selected region is displayed in the input field. - cy.get('[data-testid="textfield-input"]').should( - 'have.value', - 'UK, London (eu-west)' - ); - - // Confirm that selecting a valid region updates the Plan Selection panel. - expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); - }); - - it('creates a nanode', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.get('[data-qa-deploy-linode]'); - interceptCreateLinode().as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - ui.regionSelect.find().click(); - ui.regionSelect - .findItemByRegionLabel( - chooseRegion({ capabilities: ['Vlans', 'Linodes'] }).label - ) - .click(); - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"]'); - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); - containsVisible('PROVISIONING'); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it('creates a linode via CLI', () => { - const linodeLabel = randomLabel(); - const linodePass = randomString(32); - const linodeRegion = chooseRegion(); - - cy.visitWithLogin('/linodes/create'); - - ui.regionSelect.find().click(); - ui.autocompletePopper - .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) - .should('exist') - .click(); - - cy.get('[id="g6-dedicated-2"]').click(); - - cy.findByLabelText('Linode Label') - .should('be.visible') - .should('be.enabled') - .clear() - .type(linodeLabel); - - cy.findByLabelText('Root Password') - .should('be.visible') - .should('be.enabled') - .type(linodePass); - - ui.button - .findByTitle('Create using command line') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Create Linode') - .should('be.visible') - .within(() => { - // Switch to cURL view if necessary. - cy.findByText('cURL') - .should('be.visible') - .should('have.attr', 'data-selected'); - - // Confirm that cURL command has expected details. - [ - `"region": "${linodeRegion.id}"`, - `"type": "g6-dedicated-2"`, - `"label": "${linodeLabel}"`, - `"root_pass": "${linodePass}"`, - '"booted": true', - ].forEach((line: string) => - cy.findByText(line, { exact: false }).should('be.visible') - ); - - cy.findByText('Linode CLI').should('be.visible').click(); - - [ - `--region ${linodeRegion.id}`, - '--type g6-dedicated-2', - `--label ${linodeLabel}`, - `--root_pass ${linodePass}`, - `--booted true`, - ].forEach((line: string) => cy.contains(line).should('be.visible')); - - ui.buttonGroup - .findButtonByTitle('Close') - .should('be.visible') - .should('be.enabled') - .click(); - }); - }); - - /* - * - Confirms DC-specific pricing UI flow works as expected during Linode creation. - * - Confirms that pricing docs link is shown in "Region" section. - * - Confirms that backups pricing is correct when selecting a region with a different price structure. - */ - it('shows DC-specific pricing information during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const initialRegion = getRegionById('us-west'); - const newRegion = getRegionById('us-east'); - - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: initialRegion.id, - type: dcPricingMockLinodeTypes[0].id, - }); - - const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( - (linodeType) => linodeType.id === newRegion.id - )!; - const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === newRegion.id - )!; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes']); - - mockCreateLinode(mockLinode).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - getClick('[data-qa-deploy-linode]'); - - // A message is shown to instruct users to select a region in order to view plans and prices - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - 'Plan is required.' - ); - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - dcPricingPlanPlaceholder - ); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); - }); - // Confirm that the checkout summary at the bottom of the page reflects the correct price. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - // Confirm there is a docs link to the pricing page. - cy.findByText(dcPricingDocsLabel) - .should('be.visible') - .should('have.attr', 'href', dcPricingDocsUrl); - - ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); - }); - // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { - const region: Region = getRegionById('us-southeast'); - const mockNoVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes'], - }); - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - mockGetRegions([mockNoVPCRegion]).as('getRegions'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait('@getLinodeTypes'); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible( - 'Allow Linode to communicate in an isolated environment.' - ); - // Helper text appears if VPC is not available in selected region. - containsVisible('VPC is not available in the selected region.'); - }); - }); - - it('assigns a VPC to the linode during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const region: Region = getRegionById('us-southeast'); - const diskLabel: string = 'Debian 10 Disk'; - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: region.id, - type: dcPricingMockLinodeTypes[0].id, - }); - const mockVLANs: VLAN[] = VLANFactory.buildList(2); - const mockSubnet = subnetFactory.build({ - id: randomNumber(2), - label: randomLabel(), - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - region: 'us-southeast', - subnets: [mockSubnet], - label: randomLabel(), - }); - const mockVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes', 'VPCs', 'Vlans'], - }); - const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ - ipam_address: null, - purpose: 'public', - }); - const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); - const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - purpose: 'vpc', - active: true, - }); - const mockConfig: Config = linodeConfigFactory.build({ - id: randomNumber(), - interfaces: [ - // The order of this array is significant. Index 0 (eth0) should be public. - mockPublicConfigInterface, - mockVlanConfigInterface, - mockVpcConfigInterface, - ], - }); - const mockDisks: Disk[] = [ - { - id: 44311273, - status: 'ready', - label: diskLabel, - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', - filesystem: 'ext4', - size: 81408, - }, - { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', - filesystem: 'swap', - size: 512, - }, - ]; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - apicliDxToolsAdditions: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockVPCRegion]).as('getRegions'); - - mockGetVLANs(mockVLANs); - mockGetVPC(mockVPC).as('getVPC'); - mockGetVPCs([mockVPC]).as('getVPCs'); - mockCreateLinode(mockLinode).as('linodeCreated'); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); - mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); - mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes', '@getVPCs']); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present, and the VPC in the same region of - // the linode can be selected. - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible('Assign this Linode to an existing VPC.'); - // select VPC - cy.findByLabelText('Assign VPC') - .should('be.visible') - .focus() - .clear() - .type(`${mockVPC.label}{downArrow}{enter}`); - // select subnet - cy.findByPlaceholderText('Select Subnet') - .should('be.visible') - .type(`${mockSubnet.label}{downArrow}{enter}`); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - - ui.button.findByTitle('Create Linode').click(); - - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - - fbtClick('Configurations'); - //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); - - // Confirm that VLAN and VPC have been assigned. - cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible(`${mockConfig.label} – GRUB 2`); - containsVisible('eth0 – Public Internet'); - containsVisible(`eth2 – VPC: ${mockVPC.label}`); - }); - }); - - it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(false), - apicliDxToolsAdditions: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes'], - }); - - mockGetAccount(mockAccount).as('getAccount'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getAccount']); - - // Check if section is visible - cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); - }); - - it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), - apicliDxToolsAdditions: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - const mockRegion = regionFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - const mockRegionWithoutDiskEncryption = regionFactory.build({ - capabilities: ['Linodes'], - }); - - const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getAccount']); - - // Check if section is visible - cy.get(`[data-testid="${headerTestId}"]`).should('exist'); - - // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected - ui.regionSelect.find().click(); - ui.select - .findItemByText( - `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` - ) - .click(); - - cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); - - ui.regionSelect.find().click(); - ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); - - cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); - }); -}); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index a6ea27fbc80..2e73ca80de0 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -381,7 +381,7 @@ describe('Linode Config management', () => { // Confirm toast message and that UI updates to reflect clone in progress. ui.toast.assertMessage( - `Linode ${sourceLinode.label} successfully cloned to ${destLinode.label}.` + `Linode ${sourceLinode.label} has been cloned to ${destLinode.label}.` ); cy.findByText(/CLONING \(\d+%\)/).should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts new file mode 100644 index 00000000000..d17336e1110 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -0,0 +1,83 @@ +import { linodeFactory, ipAddressFactory } from '@src/factories'; + +import { + mockGetLinodeDetails, + mockGetLinodeIPAddresses, + mockGetLinodeFirewalls, +} from 'support/intercepts/linodes'; +import { mockUpdateIPAddress } from 'support/intercepts/networking'; +import { ui } from 'support/ui'; + +describe('linode networking', () => { + /** + * - Confirms the success toast message after editing RDNS + */ + it('checks for the toast message upon editing an RDNS', () => { + const mockLinode = linodeFactory.build(); + const linodeIPv4 = mockLinode.ipv4[0]; + const mockRDNS = `${linodeIPv4}.ip.linodeusercontent.com`; + const ipAddress = ipAddressFactory.build({ + address: linodeIPv4, + linode_id: mockLinode.id, + rdns: mockRDNS, + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeFirewalls(mockLinode.id, []).as('getLinodeFirewalls'); + mockGetLinodeIPAddresses(mockLinode.id, { + ipv4: { + public: [ipAddress], + private: [], + shared: [], + reserved: [], + }, + }).as('getLinodeIPAddresses'); + mockUpdateIPAddress(linodeIPv4, mockRDNS).as('updateIPAddress'); + + cy.visitWithLogin(`linodes/${mockLinode.id}/networking`); + cy.wait(['@getLinode', '@getLinodeFirewalls', '@getLinodeIPAddresses']); + + cy.findByLabelText('IPv4 Addresses') + .should('be.visible') + .within(() => { + // confirm table headers + cy.get('thead').findByText('Address').should('be.visible'); + cy.get('thead').findByText('Type').should('be.visible'); + cy.get('thead').findByText('Default Gateway').should('be.visible'); + cy.get('thead').findByText('Subnet Mask').should('be.visible'); + cy.get('thead').findByText('Reverse DNS').should('be.visible'); + }); + + // confirm row for Linode's (first) IPv4 address exists and open up the RDNS drawer + cy.get(`[data-qa-ip="${linodeIPv4}"]`) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('IPv4 – Public').should('be.visible'); + cy.findByText(mockRDNS).should('be.visible'); + + // open up the edit RDNS drawer + ui.button.findByTitle('Edit RDNS').should('be.visible').click(); + }); + + // confirm RDNS drawer is visible + ui.drawer + .findByTitle('Edit Reverse DNS') + .should('be.visible') + .within(() => { + cy.findByText('Leave this field blank to reset RDNS').should( + 'be.visible' + ); + + // click Save button - this test is only to confirm the toast message + // and intentionally doesn't edit the RDNS form. Note - although we're using + // mocks here, with actual data, I would get an error each time I tried to edit the RDNS + cy.findByText('Save').should('be.visible').should('be.enabled').click(); + }); + + cy.wait(['@updateIPAddress']); + + // confirm RDNS toast message + ui.toast.assertMessage(`Successfully updated RDNS for ${linodeIPv4}`); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 6cd87caaf14..0e256bbbb41 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -148,7 +148,9 @@ describe('linode storage tab', () => { cy.wait('@deleteDisk').its('response.statusCode').should('eq', 200); cy.findByText('Deleting', { exact: false }).should('be.visible'); ui.button.findByTitle('Add a Disk').should('be.enabled'); - ui.toast.assertMessage(`Disk ${diskName} successfully deleted.`); + ui.toast.assertMessage( + `Disk ${diskName} on Linode ${linode.label} has been deleted.` + ); cy.findByLabelText('List of Disks').within(() => { cy.contains(diskName).should('not.exist'); }); @@ -209,7 +211,9 @@ describe('linode storage tab', () => { cy.wait('@resizeDisk').its('response.statusCode').should('eq', 200); ui.toast.assertMessage('Disk queued for resizing.'); // cy.findByText('Resizing', { exact: false }).should('be.visible'); - ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); + ui.toast.assertMessage( + `Disk ${diskName} on Linode ${linode.label} has been resized.` + ); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index 5a6480c18ec..b4f2d50bca8 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -12,13 +12,6 @@ import { mockGetRegionAvailability, } from 'support/intercepts/regions'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; - -import type { Flags } from 'src/featureFlags'; const mockRegions = [ regionFactory.build({ @@ -363,12 +356,6 @@ describe('displays specific linode plans for GPU', () => { mockGetRegionAvailability(mockRegions[0].id, mockRegionAvailability).as( 'getRegionAvailability' ); - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - planDivider: true, - }), - }); - mockGetFeatureFlagClientstream(); }); it('Should render divided tables when GPU divider enabled', () => { diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index 0d003ddd864..02e2cfc7e17 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -3,7 +3,6 @@ import { containsVisible, fbtVisible, getClick } from 'support/helpers'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; -import { mockGetFeatureFlagClientstream } from 'support/intercepts/feature-flags'; import { interceptLinodeResize } from 'support/intercepts/linodes'; authenticate(); @@ -13,8 +12,6 @@ describe('resize linode', () => { }); it('resizes a linode by increasing size: warm migration', () => { - mockGetFeatureFlagClientstream().as('getClientStream'); - // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the @@ -40,7 +37,6 @@ describe('resize linode', () => { }); it('resizes a linode by increasing size: cold migration', () => { - mockGetFeatureFlagClientstream().as('getClientStream'); // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the @@ -67,7 +63,6 @@ describe('resize linode', () => { }); it('resizes a linode by increasing size when offline: cold migration', () => { - mockGetFeatureFlagClientstream().as('getClientStream'); // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the @@ -186,7 +181,9 @@ describe('resize linode', () => { }); // Wait until the disk resize is done. - ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); + ui.toast.assertMessage( + `Disk ${diskName} on Linode ${linode.label} has been resized.` + ); interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index f25d7ac4ef1..d654d58ea32 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 @@ -15,11 +15,17 @@ import { apiMatcher } from 'support/util/intercepts'; import { chooseRegion, getRegionById } from 'support/util/regions'; import { authenticate } from 'support/api/authentication'; import { mockGetLinodes } from 'support/intercepts/linodes'; -import { userPreferencesFactory } from '@src/factories'; +import { userPreferencesFactory, profileFactory } from '@src/factories'; +import { accountUserFactory } from '@src/factories/accountUsers'; +import { grantsFactory } from '@src/factories/grants'; +import { mockGetUser } from 'support/intercepts/account'; import { mockGetUserPreferences, mockUpdateUserPreferences, + mockGetProfile, + mockGetProfileGrants, } from 'support/intercepts/profile'; +import { randomLabel } from 'support/util/random'; const mockLinodes = new Array(5).fill(null).map( (_item: null, index: number): Linode => { @@ -386,3 +392,107 @@ describe('linode landing checks', () => { cy.findByText('Created:').should('not.exist'); }); }); + +describe('linode landing checks for empty state', () => { + beforeEach(() => { + // Mock setup to display the Linode landing page in an empty state + mockGetLinodes([]).as('getLinodes'); + }); + + it('checks empty state on linode landing page', () => { + // Login and wait for application to load + cy.visitWithLogin(routes.linodeLanding); + cy.wait('@getLinodes'); + cy.url().should('endWith', routes.linodeLanding); + + // Aliases created for accessing child elements during assertions + cy.get('div[data-qa-placeholder-container="resources-section"]').as( + 'resourcesSection' + ); + cy.get('@resourcesSection') + .get('h1[data-qa-header]') + .contains('Linodes') + .as('linodesHeader'); + + // Assert that fields with Linodes and Cloud-based virtual machines text are visible + cy.get('@linodesHeader').should('be.visible'); + cy.get('@linodesHeader') + .next('h2') + .should('be.visible') + .should('have.text', 'Cloud-based virtual machines'); + + //Assert that recommended section is visible - Getting Started Guides, Deploy an App and Video Playlist + cy.get('@resourcesSection') + .contains('h2', 'Getting Started Guides') + .should('be.visible'); + cy.get('@resourcesSection') + .contains('h2', 'Deploy an App') + .should('be.visible'); + cy.get('@resourcesSection') + .contains('h2', 'Video Playlist') + .should('be.visible'); + + // Assert that Create Linode button is visible and enabled + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .and('be.enabled'); + + // Assert that List of Liondes table does not exist + cy.get('table[aria-label="List of Linodes"]').should('not.exist'); + + // Assert that Docs link does not exist + cy.get( + 'a[aria-label="Docs - link opens in a new tab"][data-testid="external-link"]' + ).should('not.exist'); + + // Assert that Download CSV button does not exist + cy.get('span[data-testid="loadingIcon"]') + .contains('Download CSV') + .should('not.exist'); + }); + + it('checks restricted user has no access to create linode on linode landing page', () => { + // Mock setup for user profile, account user, and user grants with restricted permissions, + // simulating a default user without the ability to add Linodes. + const mockProfile = profileFactory.build({ + 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(routes.linodeLanding); + cy.wait('@getLinodes'); + cy.url().should('endWith', routes.linodeLanding); + + // Assert that Create Linode button is visible and disabled + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .and('be.disabled') + .trigger('mouseover'); + + // Assert that tooltip is visible with message + ui.tooltip + .findByText( + "You don't have permissions to create Linodes. Please contact your account administrator to request the necessary permissions." + ) + .should('be.visible'); + }); +}); 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 new file mode 100644 index 00000000000..81b60e72f1a --- /dev/null +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts @@ -0,0 +1,251 @@ +import { entityTag } from 'support/constants/cypress'; +import { createTestLinode } from 'support/util/linodes'; +import { randomLabel } from 'support/util/random'; +import { chooseRegion, getRegionById } from 'support/util/regions'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; +import { authenticate } from 'support/api/authentication'; +import type { Linode } from '@linode/api-v4'; +import { nodeBalancerFactory } from 'src/factories'; +import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; + +authenticate(); +describe('create NodeBalancer to test the submission of multiple nodes and multiple configs', () => { + before(() => { + cleanUp(['tags', 'node-balancers', 'linodes']); + }); + + /* + * - Confirms NodeBalancer create flow when adding multiple Backend Nodes. + * - Confirms Summary field displays correct Node number. + */ + it('creates a NodeBalancer with multiple Backend Nodes', async () => { + const region = chooseRegion(); + const linodePayload = { + region: region.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + const linode: Linode = await createTestLinode(linodePayload); + const nodeBal = nodeBalancerFactory.build({ + label: randomLabel(), + region: region.id, + ipv4: linode.ipv4[1], + }); + + const linodePayload_2 = { + region: region.id, + private_ip: true, + }; + const linode_2: Linode = await createTestLinode(linodePayload_2); + const nodeBal_2 = nodeBalancerFactory.build({ + label: randomLabel(), + region: region.id, + ipv4: linode_2.ipv4[1], + }); + const regionName = getRegionById(nodeBal.region).label; + + // catch request + 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); + + // this will create the NB in newark, where the default Linode was created + ui.regionSelect.find().click().clear().type(`${regionName}{enter}`); + + // node backend config + cy.findByText('Label').click().type(randomLabel()); + cy.findByLabelText('IP Address') + .should('be.visible') + .click() + .type(nodeBal.ipv4); + ui.autocompletePopper + .findByTitle(nodeBal.ipv4) + .should('be.visible') + .click(); + cy.findByLabelText('Weight') + .should('be.visible') + .click() + .clear() + .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); + ui.autocompletePopper + .findByTitle(nodeBal_2.ipv4) + .should('be.visible') + .click(); + cy.get('[data-testid="textfield-input"]') + .last() + .should('be.visible') + .click() + .clear() + .type('50'); + + // Confirm Summary info + cy.get('[data-qa-summary="true"]').within(() => { + cy.contains(`Nodes 2`).should('be.visible'); + }); + + cy.get('[data-qa-deploy-nodebalancer]').click(); + cy.wait('@createNodeBalancer').its('response.statusCode').should('eq', 200); + }); + + /* + * - Confirms NodeBalancer create flow when adding additional config. + * - Confirms Summary field displays correct Config number. + */ + it('creates a NodeBalancer with an additional config', async () => { + const region = chooseRegion(); + const linodePayload = { + region: region.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + const linode: Linode = await createTestLinode(linodePayload); + const nodeBal = nodeBalancerFactory.build({ + label: randomLabel(), + region: region.id, + ipv4: linode.ipv4[1], + }); + + const linodePayload_2 = { + region: region.id, + private_ip: true, + }; + const linode_2: Linode = await createTestLinode(linodePayload_2); + const nodeBal_2 = nodeBalancerFactory.build({ + label: randomLabel(), + region: region.id, + ipv4: linode_2.ipv4[1], + }); + const regionName = getRegionById(nodeBal.region).label; + + // catch request + 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); + + // This will create the NB in newark, where the default Linode was created + ui.regionSelect.find().click().clear().type(`${regionName}{enter}`); + + // Node backend config + cy.findByText('Label').click().type(randomLabel()); + cy.findByLabelText('IP Address') + .should('be.visible') + .click() + .type(nodeBal.ipv4); + ui.autocompletePopper + .findByTitle(nodeBal.ipv4) + .should('be.visible') + .click(); + + // Add another configuration + cy.get('[data-testid="Button"]') + .contains('Add another Configuration') + .click(); + cy.get('[data-qa-panel="Configuration - Port "]').within(() => { + cy.get('[data-testid="textfield-input"]').first().click().type('8080'); + }); + cy.findAllByText('Label').last().click().type(randomLabel()); + cy.findAllByText('IP Address') + .last() + .should('be.visible') + .click() + .type(nodeBal_2.ipv4); + ui.autocompletePopper + .findByTitle(nodeBal_2.ipv4) + .should('be.visible') + .click(); + + // Confirm Summary info + cy.get('[data-qa-summary="true"]').within(() => { + cy.contains('Configs 2').should('be.visible'); + }); + + cy.get('[data-qa-deploy-nodebalancer]').click(); + cy.wait('@createNodeBalancer').its('response.statusCode').should('eq', 200); + }); + + /* + * - Confirms Port field displays error if same port number used in additional config. + * - Confirms Label field displays error if label is empty in additional config. + * - Confirms IP field displays error if ip is empty in additional config. + */ + it('displays errors during adding new config', async () => { + const region = chooseRegion(); + const linodePayload = { + region: region.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + const linode: Linode = await createTestLinode(linodePayload); + const nodeBal = nodeBalancerFactory.build({ + label: randomLabel(), + region: region.id, + ipv4: linode.ipv4[1], + }); + 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); + + // This will create the NB in newark, where the default Linode was created + ui.regionSelect.find().click().clear().type(`${regionName}{enter}`); + + // Node backend config + cy.findByText('Label').click().type(randomLabel()); + cy.findByLabelText('IP Address') + .should('be.visible') + .click() + .type(nodeBal.ipv4); + ui.autocompletePopper + .findByTitle(nodeBal.ipv4) + .should('be.visible') + .click(); + + // Add another configuration + cy.get('[data-testid="Button"]') + .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-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('Must be a valid private IPv4 address.') + .scrollIntoView() + .should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts index e1f3804b6a5..afd0300cf3b 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts @@ -1,7 +1,219 @@ +/** + * @file Integration tests for Cloud Manager's events fetching and polling behavior. + */ + +import { mockGetEvents, mockGetEventsPolling } from 'support/intercepts/events'; +import { DateTime } from 'luxon'; import { eventFactory } from 'src/factories'; -import { mockGetEvents } from 'support/intercepts/events'; +import { randomNumber } from 'support/util/random'; +import { Interception } from 'cypress/types/net-stubbing'; import { mockGetVolumes } from 'support/intercepts/volumes'; +describe('Event fetching and polling', () => { + /** + * - Confirms that Cloud Manager makes a request to the events endpoint on page load. + * - Confirms API filters are applied to the request to limit the number and type of events retrieved. + */ + it('Makes initial fetch to events endpoint', () => { + const mockNow = DateTime.now(); + + mockGetEvents([]).as('getEvents'); + + cy.clock(mockNow.toJSDate()); + cy.visitWithLogin('/'); + cy.wait('@getEvents').then((xhr) => { + const filters = xhr.request.headers['x-filter']; + const lastWeekTimestamp = mockNow + .minus({ weeks: 1 }) + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const timestampFilter = `"created":{"+gt":"${lastWeekTimestamp}"`; + + /* + * Confirm that initial fetch request contains filters to achieve + * each of the following behaviors: + * + * - Exclude `profile_update` events. + * - Retrieve a maximum of 25 events. + * - Sort events by their created date. + * - Only retrieve events created within the past week. + */ + expect(filters).to.contain(timestampFilter); + expect(filters).to.contain('"+neq":"profile_update"'); + expect(filters).to.contain('"+order_by":"id"'); + }); + }); + + /** + * - Confirms that Cloud Manager makes subsequent events requests after the initial request. + * - Confirms API filters are applied to polling requests which differ from the initial request. + */ + it('Polls events endpoint after initial fetch', () => { + const mockEvent = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + mockGetEvents([mockEvent]).as('getEvents'); + cy.visitWithLogin('/'); + + cy.wait(['@getEvents', '@getEvents']); + cy.get('@getEvents.all').then((xhrRequests: unknown) => { + // Cypress types for `cy.get().then(...)` seem to be wrong. + // Types suggest that `cy.get()` can only yield a jQuery HTML element, but + // when the alias is an HTTP route it yields the request and response data. + const secondRequest = (xhrRequests as Interception[])[1]; + const filters = secondRequest.request.headers['x-filter']; + + /* + * Confirm that polling fetch request contains filters to achieve + * each of the following behaviors: + * + * - Exclude `profile_update` events. + * - Only retrieve events created more recently than the most recent event in the initial fetch. + * - Exclude the most recent event that was included in the initial fetch. + * - Sort events by their ID (TODO). + */ + expect(filters).to.contain('"action":{"+neq":"profile_update"}'); + expect(filters).to.contain(`"created":{"+gte":"${mockEvent.created}"}`); + expect(filters).to.contain(`{"id":{"+neq":${mockEvent.id}}}]`); + expect(filters).to.contain('"+order_by":"id"'); + }); + }); + + /** + * - Confirms that Cloud Manager polls the events endpoint 16 times per second. + * - Confirms that Cloud Manager makes a request to the events endpoint after 16 seconds. + * - Confirms that Cloud Manager does not make a request to the events endpoint before 16 seconds have passed. + * - Confirms Cloud polling rate when there are no in-progress events. + */ + it('Polls events at a 16-second interval', () => { + // Expect Cloud to poll the events endpoint every 16 seconds, + // and configure the test to check if a request has been made + // every simulated second for 16 samples total. + const expectedPollingInterval = 16_000; + const pollingSamples = 16; + const mockNow = DateTime.now(); + const mockNowTimestamp = mockNow + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const mockEvent = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + mockGetEvents([mockEvent]).as('getEventsInitialFetches'); + + // We need access to the `clock` object directly since we cannot call `cy.clock()` inside + // a `should(() => {})` callback because Cypress commands are disallowed there. + cy.clock(mockNow.toJSDate()).then((clock) => { + cy.visitWithLogin('/'); + + // Confirm that Cloud manager polls the requests endpoint no more than + // once every 16 seconds. + mockGetEventsPolling([mockEvent], mockNowTimestamp).as('getEventsPoll'); + for (let i = 0; i < pollingSamples; i += 1) { + cy.log( + `Confirming that Cloud has not made events request... (${ + i + 1 + }/${pollingSamples})` + ); + cy.get('@getEventsPoll.all').should('have.length', 0); + cy.tick(expectedPollingInterval / pollingSamples, { log: false }); + } + + cy.tick(50); + cy.wait('@getEventsPoll'); + cy.get('@getEventsPoll.all').should('have.length', 1); + }); + }); + + /** + * - Confirms that Cloud Manager polls the events endpoint 2 times per second when there are in-progress events. + * - Confirms that Cloud Manager makes a request to the events endpoint after 2 seconds. + * - Confirms that Cloud Manager does not make a request to the events endpoint before 2 seconds have passed. + * - Confirms Cloud polling rate when there are in-progress events. + */ + it('Polls in-progress events at a 2-second interval', () => { + // When in-progress events are present, expect Cloud to poll the + // events endpoint every 2 seconds, and configure the test to check + // if a request has been made every simulated tenth of a second for + // 20 samples total. + const expectedPollingInterval = 2_000; + const pollingSamples = 20; + const mockNow = DateTime.now(); + const mockNowTimestamp = mockNow + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const mockEventBasic = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + const mockEventInProgress = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now().minus({ minutes: 6 }).toISO(), + duration: 0, + rate: null, + percent_complete: 50, + }); + + const mockEvents = [mockEventBasic, mockEventInProgress]; + + // Visit Cloud Manager, and wait for Cloud to fire its first two + // requests to the `events` endpoint: the initial request, and the + // initial polling request. + mockGetEvents(mockEvents).as('getEventsInitialFetches'); + + // We need access to the `clock` object directly since we cannot call `cy.clock()` inside + // a `should(() => {})` callback because Cypress commands are disallowed there. + cy.clock(Date.now()).then((clock) => { + cy.visitWithLogin('/'); + + // Confirm that Cloud manager polls the requests endpoint no more than once + // every 2 seconds. + mockGetEventsPolling(mockEvents, mockNowTimestamp).as('getEventsPoll'); + for (let i = 0; i < pollingSamples; i += 1) { + cy.log( + `Confirming that Cloud has not made events request... (${ + i + 1 + }/${pollingSamples})` + ); + cy.get('@getEventsPoll.all').should('have.length', 0); + cy.tick(expectedPollingInterval / pollingSamples, { log: false }); + } + + cy.tick(50); + cy.wait('@getEventsPoll'); + cy.get('@getEventsPoll.all').should('have.length', 1); + }); + }); +}); + describe('Event Handlers', () => { it('invokes event handlers when new events are polled and makes the correct number of requests', () => { // See https://github.com/linode/manager/pull/10824 diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts new file mode 100644 index 00000000000..79c1638848f --- /dev/null +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts @@ -0,0 +1,309 @@ +/** + * @file Integration tests for Cloud Manager's events menu. + */ + +import { mockGetEvents, mockMarkEventSeen } from 'support/intercepts/events'; +import { ui } from 'support/ui'; +import { eventFactory } from 'src/factories'; +import { buildArray } from 'support/util/arrays'; +import { DateTime } from 'luxon'; +import { randomLabel, randomNumber } from 'support/util/random'; + +describe('Notifications Menu', () => { + /* + * - Confirms that the notification menu shows all events when 20 or fewer exist. + */ + it('Shows all recent events when there are 20 or fewer', () => { + const mockEvents = buildArray(randomNumber(1, 20), (index) => { + return eventFactory.build({ + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + rate: null, + seen: false, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + mockGetEvents(mockEvents).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // 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'); + }); + }); + }); + + /* + * - Confirms that the notification menu shows no more than 20 events. + * - Confirms that only the most recently created events are shown. + */ + it('Shows the 20 most recently created events', () => { + const mockEvents = buildArray(25, (index) => { + return eventFactory.build({ + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + rate: null, + seen: false, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + const shownEvents = mockEvents.slice(0, 20); + const hiddenEvents = mockEvents.slice(20); + + mockGetEvents(mockEvents).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that first 20 events in response are displayed. + shownEvents.forEach((event) => { + cy.get(`[data-qa-event="${event.id}"]`) + .scrollIntoView() + .should('be.visible'); + }); + + // Confirm that last 5 events in response are not displayed. + hiddenEvents.forEach((event) => { + cy.get(`[data-qa-event="${event.id}"]`).should('not.exist'); + }); + }); + }); + + /* + * - Confirms that notification menu contains a notice when no recent events exist. + */ + it('Shows notice when there are no recent events', () => { + mockGetEvents([]).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Use RegEx here to account for cases where the period is and is not present. + // Period is displayed in Notifications Menu v2, but omitted in v1. + cy.findByText(/No recent events to display\.?/).should('be.visible'); + }); + }); + + /* + * - Confirms that events in menu are marked as seen upon viewing. + * - Uses typical mock data setup where IDs are ordered (descending) and all create dates are unique. + * - Confirms that events are reflected in the UI as being seen or unseen. + */ + it('Marks events in menu as seen', () => { + const mockEvents = buildArray(10, (index) => { + return eventFactory.build({ + // The event with the highest ID is expected to come first in the array. + id: 5000 - index, + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + seen: false, + rate: null, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + // In this case, we know that the first event in the mocked events response + // will contain the highest event ID. + const highestEventId = mockEvents[0].id; + + mockGetEvents(mockEvents).as('getEvents'); + mockMarkEventSeen(highestEventId).as('markEventsSeen'); + + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm notification menu opens + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that UI reflects that every event is unseen. + cy.get('[data-qa-event-seen="false"]').should('have.length', 10); + }); + + // Dismiss the notifications menu by clicking the bell button again. + ui.appBar.find().within(() => { + // This time we have to pass `force: true` to cy.click() + // because otherwise Cypress thinks the element is blocked because + // of the notifications menu popover. + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click({ force: true }); + }); + + // Confirm that Cloud fires a request to the `/events/:id/seen` endpoint, + // where `id` corresponds to the mocked event with the highest ID. + // If Cloud attempts to mark the wrong event ID as seen, this assertion + // will fail. + cy.log(`Waiting for request to '/events/${highestEventId}/seen'`); + cy.wait('@markEventsSeen'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that UI reflects that every event is now seen. + cy.get('[data-qa-event-seen="true"]').should('have.length', 10); + }); + }); + + /* + * - Confirms event seen logic for non-typical event ordering edge case. + * - Confirms that Cloud marks the correct event as seen even when it's not the first result. + */ + it('Marks events in menu as seen with duplicate created dates and out-of-order IDs', () => { + /* + * When several events are triggered simultaneously, they may have the + * same `created` timestamp. Cloud asks for events to be sorted by created + * date when fetching from the API, but when events have identical timestamps, + * there is no guarantee in which order they will be returned. + * + * As a result, we have to account for cases where the most recent event + * in reality (e.g. as determined by its ID) is not returned first by the API. + * This is especially relevant when marking events as 'seen', as we have + * to explicitly mark the event with the highest ID as seen when the user + * closes their notification menu. + */ + const createTime = DateTime.local().minus({ minutes: 2 }).toISO(); + const mockEvents = buildArray(10, (index) => { + return eventFactory.build({ + // Events are not guaranteed to be ordered by ID; simulate this by using random IDs. + id: randomNumber(1000, 9999), + action: 'linode_delete', + // To simulate multiple events occurring simultaneously, give all + // events the same created timestamp. + created: createTime, + percent_complete: null, + seen: false, + rate: null, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + // Sort the mockEvents array by id in descending order to simulate API response + mockEvents.sort((a, b) => b.id - a.id); + + const highestEventId = mockEvents[0].id; + + mockGetEvents(mockEvents).as('getEvents'); + mockMarkEventSeen(highestEventId).as('markEventsSeen'); + + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm notification menu opens; we don't care about its contents. + cy.get('[data-qa-notification-menu]').should('be.visible'); + + // Dismiss the notifications menu by clicking the bell button again. + ui.appBar.find().within(() => { + // This time we have to pass `force: true` to cy.click() + // because otherwise Cypress thinks the element is blocked because + // of the notifications menu popover. + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click({ force: true }); + }); + + // Confirm that Cloud fires a request to the `/events/:id/seen` endpoint, + // where `id` corresponds to the mocked event with the highest ID. + // If Cloud attempts to mark the wrong event ID as seen, this assertion + // will fail. + cy.log(`Waiting for request to '/events/${highestEventId}/seen'`); + cy.wait('@markEventsSeen'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts index ee659c4cd4d..1648b3102fa 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts @@ -1,108 +1,10 @@ -import { Event, EventAction } from '@linode/api-v4'; import { eventFactory } from '@src/factories/events'; -import { RecPartial } from 'factory.ts'; -import { containsClick, getClick } from 'support/helpers'; import { mockGetEvents } from 'support/intercepts/events'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; +import { EventActionKeys } from '@linode/api-v4'; -const eventActions: RecPartial[] = [ - 'backups_cancel', - 'backups_enable', - 'backups_restore', - 'community_like', - 'community_question_reply', - 'disk_create', - 'disk_delete', - 'disk_duplicate', - 'disk_resize', - 'disk_update', - 'database_resize', - 'database_low_disk_space', - 'entity_transfer_accept', - 'entity_transfer_cancel', - 'entity_transfer_create', - 'entity_transfer_fail', - 'entity_transfer_stale', - 'firewall_create', - 'firewall_delete', - 'firewall_device_add', - 'firewall_device_remove', - 'firewall_disable', - 'firewall_enable', - 'firewall_update', - 'host_reboot', - 'image_delete', - 'image_update', - 'image_upload', - 'lassie_reboot', - 'linode_addip', - 'linode_boot', - 'linode_clone', - 'linode_config_create', - 'linode_config_delete', - 'linode_config_update', - 'linode_create', - 'linode_delete', - 'linode_deleteip', - 'linode_migrate', - 'linode_migrate_datacenter', - 'linode_mutate', - 'linode_reboot', - 'linode_rebuild', - 'linode_resize', - 'linode_shutdown', - 'linode_snapshot', - 'linode_update', - 'lke_node_create', - 'longviewclient_create', - 'longviewclient_delete', - 'longviewclient_update', - 'nodebalancer_config_create', - 'nodebalancer_config_delete', - 'nodebalancer_config_update', - 'nodebalancer_create', - 'nodebalancer_delete', - 'nodebalancer_update', - 'stackscript_create', - 'stackscript_delete', - 'stackscript_publicize', - 'stackscript_revise', - 'stackscript_update', - 'subnet_create', - 'subnet_delete', - 'subnet_update', - 'tax_id_invalid', - 'tax_id_valid', - 'tfa_disabled', - 'tfa_enabled', - 'user_ssh_key_add', - 'user_ssh_key_delete', - 'user_ssh_key_update', - 'volume_clone', - 'volume_create', - 'volume_detach', - 'volume_resize', - 'vpc_create', - 'vpc_delete', - 'vpc_update', - // 'linode_migrate_datacenter_create', - // 'linode_mutate_create', - // 'linode_resize_create', - // 'profile_update', - // 'volume_delete', - // unwanted 'account_update', - // unwanted 'account_settings_update', - // unwanted 'credit_card_updated', - // unwanted 'profile_update', - // unwanted 'ticket_attachment_upload', - // unwanted 'volume_update', -]; +import type { Event } from '@linode/api-v4'; -const events: Event[] = eventActions.map((action) => { +const events: Event[] = EventActionKeys.map((action) => { return eventFactory.build({ action, message: `${action + ' message'}`, @@ -114,27 +16,23 @@ const events: Event[] = eventActions.map((action) => { }); describe('verify notification types and icons', () => { - before(() => { - // TODO eventMessagesV2: delete when flag is removed and update test - mockAppendFeatureFlags({ - eventMessagesV2: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); - }); - it(`notifications`, () => { mockGetEvents(events).as('mockEvents'); cy.visitWithLogin('/linodes'); cy.wait('@mockEvents').then(() => { - getClick('button[aria-label="Notifications"]'); + cy.get('button[aria-label="Notifications"]').click(); for (let i = 0; i < 20; i++) { + // Skip account_agreement_eu_model action since it is a special case + if (events[i].action === 'account_agreement_eu_model') { + return; + } const text = [`${events[i].message}`, `${events[i].entity?.label}`]; const regex = new RegExp(`${text.join('|')}`, 'g'); cy.get(`[data-test-id="${events[i].action}"]`).within(() => { cy.contains(regex); }); } - containsClick('View all events'); + cy.get('button[aria-label="View all events"]').click(); // Clicking "View all events" navigates to Events page at /events cy.url().should('endWith', '/events'); events.forEach((event) => { 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 aa7cf9ee38b..85688840e98 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 @@ -5,15 +5,11 @@ import { createObjectStorageBucketFactoryLegacy } from 'src/factories/objectStorage'; import { authenticate } from 'support/api/authentication'; import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptGetAccessKeys, interceptCreateAccessKey, } from 'support/intercepts/object-storage'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -41,9 +37,8 @@ describe('object storage access key end-to-end tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); cy.visitWithLogin('/object-storage/access-keys'); cy.wait('@getKeys'); @@ -136,9 +131,8 @@ describe('object storage access key end-to-end tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); interceptGetAccessKeys().as('getKeys'); interceptCreateAccessKey().as('createKey'); 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 ea45c89d99b..3182a4c5bed 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 @@ -6,10 +6,7 @@ import { objectStorageKeyFactory, objectStorageBucketFactory, } from 'src/factories/objectStorage'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateAccessKey, mockDeleteAccessKey, @@ -17,7 +14,6 @@ import { mockGetBucketsForRegion, mockUpdateAccessKey, } from 'support/intercepts/object-storage'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomDomainName, randomLabel, @@ -48,9 +44,8 @@ describe('object storage access keys smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); mockGetAccessKeys([]).as('getKeys'); mockCreateAccessKey(mockAccessKey).as('createKey'); @@ -120,9 +115,8 @@ describe('object storage access keys smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); // Mock initial GET request to include an access key. mockGetAccessKeys([accessKey]).as('getKeys'); @@ -172,9 +166,8 @@ describe('object storage access keys smoke tests', () => { }) ); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(true), + objMultiCluster: true, }); - mockGetFeatureFlagClientstream(); }); /* diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 240e88153bb..5073da56424 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -31,11 +31,7 @@ import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetAccessKeys } from 'support/intercepts/object-storage'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; // Various messages, notes, and warnings that may be shown when enabling Object Storage // under different circumstances. @@ -62,9 +58,8 @@ describe('Object Storage enrollment', () => { it('can enroll in Object Storage', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); const mockAccountSettings = accountSettingsFactory.build({ managed: false, diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 4e6604f32ee..1c17e3e7596 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 @@ -25,11 +25,7 @@ import { import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; import { cleanUp } from 'support/util/cleanup'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; // Message shown on-screen when user navigates to an empty bucket. const emptyBucketMessage = 'This bucket is empty.'; @@ -186,9 +182,8 @@ describe('object storage end-to-end tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/object-storage'); cy.wait(['@getFeatureFlags', '@getBuckets', '@getNetworkUtilization']); 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 29947ac1374..e189ad3a87d 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 @@ -16,11 +16,7 @@ import { mockUploadBucketObjectS3, mockCreateBucketError, } from 'support/intercepts/object-storage'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; import { accountFactory, regionFactory } from 'src/factories'; @@ -68,9 +64,8 @@ describe('object storage smoke tests', () => { }) ); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(true), + objMultiCluster: true, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetRegions(mockRegions).as('getRegions'); mockGetBuckets([]).as('getBuckets'); @@ -89,7 +84,7 @@ describe('object storage smoke tests', () => { .within(() => { // Enter label. cy.contains('Label').click().type(mockBucket.label); - + cy.log(`${mockRegionWithObj.label}`); cy.contains('Region').click().type(mockRegionWithObj.label); ui.autocompletePopper @@ -167,10 +162,9 @@ describe('object storage smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), - gecko2: makeFeatureFlagData(false), + objMultiCluster: false, + gecko2: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetBuckets([]).as('getBuckets'); @@ -305,9 +299,8 @@ describe('object storage smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); mockGetBuckets([bucketMock]).as('getBuckets'); mockDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); @@ -359,9 +352,8 @@ describe('object storage smoke tests', () => { }) ); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(true), + objMultiCluster: true, }); - mockGetFeatureFlagClientstream(); mockGetBuckets([bucketMock]).as('getBuckets'); mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); 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 9b28f1f4cd5..dce48e600f3 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 @@ -16,9 +16,28 @@ import { regionFactory, } from 'src/factories'; import { chooseRegion } from 'support/util/regions'; -import type { ObjectStorageEndpoint } from '@linode/api-v4'; +import type { + ObjectStorageEndpoint, + ObjectStorageEndpointTypes, +} from '@linode/api-v4'; describe('Object Storage Gen2 create bucket tests', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: true }, + }).as('getFeatureFlags'); + mockGetAccount( + accountFactory.build({ + capabilities: [ + 'Object Storage', + 'Object Storage Endpoint Types', + 'Object Storage Access Key Regions', + ], + }) + ).as('getAccount'); + }); + // Moved these constants to top of scope - they will likely be used for other obj storage gen2 bucket create tests const mockRegions = regionFactory.buildList(10, { capabilities: ['Object Storage'], @@ -53,23 +72,45 @@ describe('Object Storage Gen2 create bucket tests', () => { }), ]; + const checkRateLimitsTable = (endpointType: ObjectStorageEndpointTypes) => { + const expectedHeaders = ['Limits', 'GET', 'PUT', 'LIST', 'DELETE', 'OTHER']; + const expectedBasicValues = ['Basic', '2,000', '500', '100', '200', '400']; + const expectedHighValues = + endpointType === 'E3' + ? ['High', '20,000', '2,000', '400', '400', '1,000'] + : ['High', '5,000', '1,000', '200', '200', '800']; + + cy.get('[data-testid="bucket-rate-limit-table"]').within(() => { + expectedHeaders.forEach((header, index) => { + cy.get('th').eq(index).should('contain.text', header); + }); + + cy.contains('tr', 'Basic').within(() => { + expectedBasicValues.forEach((value, index) => { + cy.get('td').eq(index).should('contain.text', value); + }); + }); + + cy.contains('tr', 'High').within(() => { + expectedHighValues.forEach((value, index) => { + cy.get('td').eq(index).should('contain.text', value); + }); + }); + + // Check that Basic radio button is checked + cy.findByLabelText('Basic').should('be.checked'); + }); + }; + /** * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E0 * Confirms all endpoints are displayed regardless if there's multiple of the same type * Confirms S3 endpoint hostname displayed to differentiate between identical options in the dropdown */ - it('can create a bucket with endpoint type 0', () => { + it('can create a bucket with E0 endpoint type', () => { const endpointTypeE0 = 'Legacy (E0)'; const bucketLabel = randomLabel(); - //wait for the newly 'created' mocked bucket to appear - const mockBucket = objectStorageBucketFactoryGen2.build({ - label: bucketLabel, - region: mockRegion.id, - endpoint_type: 'E0', - s3_endpoint: undefined, - }); - mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); mockCreateBucket({ @@ -79,20 +120,6 @@ describe('Object Storage Gen2 create bucket tests', () => { region: mockRegion.id, }).as('createBucket'); - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: true }, - }).as('getFeatureFlags'); - mockGetAccount( - accountFactory.build({ - capabilities: [ - 'Object Storage', - 'Object Storage Endpoint Types', - 'Object Storage Access Key Regions', - ], - }) - ).as('getAccount'); - mockGetObjectStorageEndpoints(mockEndpoints).as( 'getObjectStorageEndpoints' ); @@ -107,6 +134,13 @@ describe('Object Storage Gen2 create bucket tests', () => { '@getObjectStorageEndpoints', ]); + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E0', + s3_endpoint: undefined, + }); + ui.drawer .findByTitle('Create Bucket') .should('be.visible') @@ -151,6 +185,8 @@ describe('Object Storage Gen2 create bucket tests', () => { // Confirm bucket rate limit table should not exist when E0 endpoint is selected cy.get('[data-testid="bucket-rate-limit-table"]').should('not.exist'); + mockGetBuckets([mockBucket]).as('getBuckets'); + ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') @@ -158,7 +194,7 @@ describe('Object Storage Gen2 create bucket tests', () => { .click(); }); - mockGetBuckets([mockBucket]).as('getBuckets'); + // Wait for the newly 'created' mocked bucket to appear cy.wait(['@getBuckets']); // Confirm request body has expected data @@ -197,4 +233,350 @@ describe('Object Storage Gen2 create bucket tests', () => { cy.wait(['@deleteBucket', '@getBuckets']); cy.findByText(bucketLabel).should('not.exist'); }); + + /** + * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E1 + */ + it('can create a bucket with E1 endpoint type', () => { + const endpointTypeE1 = 'Standard (E1)'; + const bucketLabel = randomLabel(); + + mockGetBuckets([]).as('getBuckets'); + mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); + mockCreateBucket({ + label: bucketLabel, + endpoint_type: 'E1', + cors_enabled: true, + region: mockRegion.id, + }).as('createBucket'); + + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + + mockGetRegions(mockRegions); + + cy.visitWithLogin('/object-storage/buckets/create'); + cy.wait([ + '@getFeatureFlags', + '@getBuckets', + '@getAccount', + '@getObjectStorageEndpoints', + ]); + + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E1', + s3_endpoint: 'us-sea-1.linodeobjects.com', + }); + + ui.drawer + .findByTitle('Create Bucket') + .should('be.visible') + .within(() => { + cy.findByText('Label').click().type(bucketLabel); + ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); + cy.findByLabelText('Object Storage Endpoint Type') + .should('be.visible') + .click(); + + // Select E1 endpoint + ui.autocompletePopper + .findByTitle('Standard (E1) us-sea-1.linodeobjects.com') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm bucket rate limits text for E1 endpoint + cy.findByText('Bucket Rate Limits').should('be.visible'); + cy.contains( + 'This endpoint type supports up to 750 Requests Per Second (RPS). Understand bucket rate limits' + ).should('be.visible'); + + // Confirm bucket rate limit table should not exist when E1 endpoint is selected + cy.get('[data-testid="bucket-rate-limit-table"]').should('not.exist'); + + mockGetBuckets([mockBucket]).as('getBuckets'); + + ui.buttonGroup + .findButtonByTitle('Create Bucket') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for the newly 'created' mocked bucket to appear + cy.wait(['@getBuckets']); + + // Confirm request body has expected data + cy.wait('@createBucket').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['endpoint_type']).to.equal('E1'); + expect(requestPayload['cors_enabled']).to.equal(true); + expect(requestPayload['s3_endpoint']).to.equal( + 'us-sea-1.linodeobjects.com' + ); + }); + + ui.drawer.find().should('not.exist'); + + // Confirm that bucket is created, initiate deletion for cleanup + cy.findByText(endpointTypeE1).should('be.visible'); + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockRegion.label).should('be.visible'); + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm bucket gets deleted + mockGetBuckets([]).as('getBuckets'); + cy.wait(['@deleteBucket', '@getBuckets']); + cy.findByText(bucketLabel).should('not.exist'); + }); + + /** + * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E2 + */ + it('can create a bucket with E2 endpoint type', () => { + const endpointTypeE2 = 'Standard (E2)'; + const bucketLabel = randomLabel(); + + mockGetBuckets([]).as('getBuckets'); + mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); + mockCreateBucket({ + label: bucketLabel, + endpoint_type: 'E2', + cors_enabled: true, + region: mockRegion.id, + }).as('createBucket'); + + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + + mockGetRegions(mockRegions); + + cy.visitWithLogin('/object-storage/buckets/create'); + cy.wait([ + '@getFeatureFlags', + '@getBuckets', + '@getAccount', + '@getObjectStorageEndpoints', + ]); + + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E2', + s3_endpoint: undefined, + }); + + ui.drawer + .findByTitle('Create Bucket') + .should('be.visible') + .within(() => { + cy.findByText('Label').click().type(bucketLabel); + ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); + cy.findByLabelText('Object Storage Endpoint Type') + .should('be.visible') + .click(); + + // Select E2 endpoint + ui.autocompletePopper + .findByTitle('Standard (E2)') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm bucket rate limits text for E2 endpoint + cy.findByText('Bucket Rate Limits').should('be.visible'); + cy.contains( + 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. Understand bucket rate limits.' + ).should('be.visible'); + + // Confirm bucket rate limit table should exist when E2 endpoint is selected + cy.get('[data-testid="bucket-rate-limit-table"]').should('exist'); + + // Confirm that basic rate limits table is displayed + checkRateLimitsTable(mockBucket.endpoint_type!); + + mockGetBuckets([mockBucket]).as('getBuckets'); + + ui.buttonGroup + .findButtonByTitle('Create Bucket') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for the newly 'created' mocked bucket to appear + cy.wait(['@getBuckets']); + + // Confirm request body has expected data + cy.wait('@createBucket').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['endpoint_type']).to.equal('E2'); + expect(requestPayload['cors_enabled']).to.equal(false); + }); + + ui.drawer.find().should('not.exist'); + + // Confirm that bucket is created, initiate deletion for cleanup + cy.findByText(endpointTypeE2).should('be.visible'); + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockRegion.label).should('be.visible'); + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm bucket gets deleted + mockGetBuckets([]).as('getBuckets'); + cy.wait(['@deleteBucket', '@getBuckets']); + cy.findByText(bucketLabel).should('not.exist'); + }); + + /** + * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E3 + */ + it('can create a bucket with E3 endpoint type', () => { + const endpointTypeE3 = 'Standard (E3)'; + const bucketLabel = randomLabel(); + + mockGetBuckets([]).as('getBuckets'); + mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); + mockCreateBucket({ + label: bucketLabel, + endpoint_type: 'E3', + cors_enabled: false, + region: mockRegion.id, + }).as('createBucket'); + + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + + mockGetRegions(mockRegions); + + cy.visitWithLogin('/object-storage/buckets/create'); + cy.wait([ + '@getFeatureFlags', + '@getBuckets', + '@getAccount', + '@getObjectStorageEndpoints', + ]); + + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E3', + s3_endpoint: undefined, + }); + + ui.drawer + .findByTitle('Create Bucket') + .should('be.visible') + .within(() => { + cy.findByText('Label').click().type(bucketLabel); + ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); + cy.findByLabelText('Object Storage Endpoint Type') + .should('be.visible') + .click(); + + // Select E3 endpoint + ui.autocompletePopper + .findByTitle('Standard (E3)') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm bucket rate limits text for E3 endpoint + cy.findByText('Bucket Rate Limits').should('be.visible'); + cy.contains( + 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. Understand bucket rate limits.' + ).should('be.visible'); + + // Confirm bucket rate limit table should exist when E3 endpoint is selected + cy.get('[data-testid="bucket-rate-limit-table"]').should('exist'); + + // Confirm that basic rate limits table is displayed + checkRateLimitsTable(mockBucket.endpoint_type!); + + mockGetBuckets([mockBucket]).as('getBuckets'); + + ui.buttonGroup + .findButtonByTitle('Create Bucket') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for the newly 'created' mocked bucket to appear + cy.wait(['@getBuckets']); + + // Confirm request body has expected data + cy.wait('@createBucket').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['endpoint_type']).to.equal('E3'); + expect(requestPayload['cors_enabled']).to.equal(false); + }); + + ui.drawer.find().should('not.exist'); + + // Confirm that bucket is created, initiate deletion for cleanup + cy.findByText(endpointTypeE3).should('be.visible'); + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockRegion.label).should('be.visible'); + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm bucket gets deleted + mockGetBuckets([]).as('getBuckets'); + cy.wait(['@deleteBucket', '@getBuckets']); + cy.findByText(bucketLabel).should('not.exist'); + }); }); 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 f7aa759ec34..f453f6b7153 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -1,37 +1,27 @@ -import { containsClick, containsVisible } from 'support/helpers'; +import { containsVisible } from 'support/helpers'; import { ui } from 'support/ui'; -import { authenticate } from 'support/api/authentication'; -import { cleanUp } from 'support/util/cleanup'; import { interceptGetStackScripts, + mockGetStackScript, mockGetStackScripts, } from 'support/intercepts/stackscripts'; -import { interceptCreateLinode } from 'support/intercepts/linodes'; +import { mockCreateLinode } from 'support/intercepts/linodes'; import { filterOneClickApps, handleAppLabel, } from 'src/features/Linodes/LinodesCreate/utilities'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mapStackScriptLabelToOCA } from 'src/features/OneClickApps/utils'; import { stackScriptFactory } from 'src/factories/stackscripts'; import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import type { StackScript } from '@linode/api-v4'; import type { OCA } from '@src/features/OneClickApps/types'; - -authenticate(); +import { imageFactory, linodeFactory } from 'src/factories'; +import { mockGetAllImages } from 'support/intercepts/images'; describe('OneClick Apps (OCA)', () => { - before(() => { - cleanUp(['linodes']); - }); - it('Lists all the OneClick Apps', () => { interceptGetStackScripts().as('getStackScripts'); @@ -123,9 +113,19 @@ describe('OneClick Apps (OCA)', () => { }); it('Deploys a Linode from a One Click App', () => { - const stackscriptId = 401709; - const stackScripts = stackScriptFactory.build({ - id: stackscriptId, + const images = [ + imageFactory.build({ + id: 'linode/ubuntu22.04', + label: 'Ubuntu 20.04', + }), + imageFactory.build({ + id: 'linode/debian11', + label: 'Debian 11', + }), + ]; + + const stackscript = stackScriptFactory.build({ + id: 0, username: 'linode', user_gravatar_id: '9d4d301385af69ceb7ad658aad09c142', label: 'E2E Test App', @@ -160,26 +160,25 @@ describe('OneClick Apps (OCA)', () => { ], }); - const firstName = randomLabel(); - const password = randomString(16); - const image = 'linode/ubuntu22.04'; const rootPassword = randomString(16); - const region = chooseRegion({ capabilities: ['Vlans'] }); + const region = chooseRegion(); const linodeLabel = randomLabel(); + + // UDF values + const firstName = randomLabel(); + const password = randomString(16); const levelName = 'Get the enderman!'; - mockGetStackScripts([stackScripts]).as('getStackScripts'); - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(false), - oneClickApps: makeFeatureFlagData({ - 401709: 'E2E Test App', - }), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); + const linode = linodeFactory.build({ + label: linodeLabel, + }); + + mockGetAllImages(images); + mockGetStackScripts([stackscript]).as('getStackScripts'); + mockGetStackScript(stackscript.id, stackscript); cy.visitWithLogin(`/linodes/create?type=One-Click`); - cy.wait('@getFeatureFlags'); cy.wait('@getStackScripts'); cy.findByTestId('one-click-apps-container').within(() => { @@ -188,40 +187,43 @@ describe('OneClick Apps (OCA)', () => { // Check that the app is listed and select it cy.get('[data-qa-selection-card="true"]').should('have.length', 3); - cy.get(`[id=app-${stackscriptId}]`).first().should('be.visible').click(); + cy.findAllByText(stackscript.label).first().should('be.visible').click(); }); - // Input the user defined fields - const userFieldId = - "the-username-for-the-linode's-non-root-admin/ssh-user(must-be-lowercase)"; - const passwordFieldId = - "the-password-for-the-linode's-non-root-admin/ssh-user"; - const levelNameFieldId = 'world-name'; + cy.findByLabelText( + "The username for the Linode's non-root admin/SSH user(must be lowercase) (required)" + ) + .should('be.visible') + .click() + .type(firstName); + + cy.findByLabelText( + "The password for the Linode's non-root admin/SSH user (required)" + ) + .should('be.visible') + .click() + .type(password); - cy.findByTestId('user-defined-fields-panel').within(() => { - cy.get(`[id="${userFieldId}"]`) - .should('be.visible') - .click() - .type(`${firstName}{enter}`); - cy.get(`[id="${passwordFieldId}"]`) - .should('be.visible') - .click() - .type(`${password}{enter}`); - cy.get(`[id="${levelNameFieldId}"]`) - .should('be.visible') - .click() - .type(`${levelName}{enter}`); + cy.findByLabelText('World Name (required)') + .should('be.visible') + .click() + .type(levelName); - // Check each field should persist when moving onto another field - cy.get(`[id="${userFieldId}"]`).should('have.value', firstName); - cy.get(`[id="${passwordFieldId}"]`).should('have.value', password); - cy.get(`[id="${levelNameFieldId}"]`).should('have.value', levelName); - }); + // Check each field should persist when moving onto another field + cy.findByLabelText( + "The username for the Linode's non-root admin/SSH user(must be lowercase) (required)" + ).should('have.value', firstName); + + cy.findByLabelText( + "The password for the Linode's non-root admin/SSH user (required)" + ).should('have.value', password); + + cy.findByLabelText('World Name (required)').should('have.value', levelName); // Choose an image - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image').type(`${image}{enter}`); - }); + cy.findByPlaceholderText('Choose an image') + .click() + .type('{downArrow}{enter}'); // Choose a region ui.regionSelect.find().click().type(`${region.id}{enter}`); @@ -237,14 +239,14 @@ describe('OneClick Apps (OCA)', () => { cy.findByText('Linode Label') .should('be.visible') .click() - .type('{selectAll}{backspace}') .type(linodeLabel); // Choose a Root Password cy.get('[id="root-password"]').type(rootPassword); // Create the Linode - interceptCreateLinode().as('createLinode'); + mockCreateLinode(linode).as('createLinode'); + ui.button .findByTitle('Create Linode') .should('be.visible') @@ -252,6 +254,7 @@ describe('OneClick Apps (OCA)', () => { .click(); cy.wait('@createLinode'); - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); + + ui.toast.assertMessage(`Your Linode ${linode.label} is being created.`); }); }); diff --git a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts new file mode 100644 index 00000000000..ddfbdab9566 --- /dev/null +++ b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts @@ -0,0 +1,211 @@ +import { + accountFactory, + appTokenFactory, + profileFactory, +} from '@src/factories'; +import { accountUserFactory } from '@src/factories/accountUsers'; +import { DateTime } from 'luxon'; +import { + mockGetAccount, + mockGetChildAccounts, + mockGetUser, +} from 'support/intercepts/account'; +import { + mockCreatePersonalAccessToken, + mockGetAppTokens, + mockGetPersonalAccessTokens, + mockGetProfile, +} from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; + +const mockParentAccount = accountFactory.build({ + company: 'Parent Company', +}); + +const mockParentProfile = profileFactory.build({ + username: randomLabel(), + user_type: 'parent', +}); + +const mockParentUser = accountUserFactory.build({ + username: mockParentProfile.username, + user_type: 'parent', +}); + +const mockChildAccount = accountFactory.build({ + company: 'Partner Company', +}); + +const mockParentAccountToken = appTokenFactory.build({ + id: randomNumber(), + created: DateTime.now().toISO(), + expiry: DateTime.now().plus({ minutes: 15 }).toISO(), + label: `${mockParentAccount.company}_proxy`, + scopes: '*', + token: randomString(32), + website: undefined, + thumbnail_url: undefined, +}); + +describe('Token scopes', () => { + /* + * Confirm that the “Child account access” grant is not visible in the list of permissions. + * Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + */ + it('Token scopes for parent user with restricted access', () => { + mockGetProfile(mockParentProfile); + mockGetAccount(mockParentAccount); + mockGetChildAccounts([mockChildAccount]); + mockGetProfile({ ...mockParentProfile, restricted: true }); + mockGetUser(mockParentUser); + + mockGetPersonalAccessTokens([]).as('getTokens'); + mockGetAppTokens([]).as('getAppTokens'); + mockCreatePersonalAccessToken(mockParentAccountToken).as('createToken'); + + cy.visitWithLogin('/profile/tokens'); + cy.wait(['@getTokens', '@getAppTokens']); + + // Click create button, fill out and submit PAT create form. + ui.button + .findByTitle('Create a Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetPersonalAccessTokens([mockParentAccountToken]).as('getTokens'); + ui.drawer + .findByTitle('Add Personal Access Token') + .should('be.visible') + .within(() => { + // Confirm that the “Child account access” grant is not visible in the list of permissions. + cy.findAllByText('Child Account Access').should('not.exist'); + + // Specify ALL scopes by selecting the "No Access" Select All radio button. + cy.get('[data-qa-perm-rw-radio]').click(); + cy.get('[data-qa-perm-rw-radio]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + + // Specify a label and re-submit. + cy.findByLabelText('Label') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click() + .type(mockParentAccountToken.label); + + ui.buttonGroup + .findButtonByTitle('Create Token') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that PAT secret dialog is shown and close it. + cy.wait('@createToken'); + ui.dialog + .findByTitle('Personal Access Token') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('I Have Saved My Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that new PAT is shown in list and "View Scopes" drawer works. + // Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + cy.wait('@getTokens').then((xhr) => { + const actualTokenData = xhr.response?.body.data; + const actualTokenScopes = actualTokenData[0].scopes; + expect(actualTokenScopes).to.equal(mockParentAccountToken.scopes); + }); + }); + + /* + * Confirm that the “Child account access” grant is visible in the list of permissions. + * Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + */ + it('Token scopes for parent user with unrestricted access', () => { + mockGetProfile(mockParentProfile); + mockGetAccount(mockParentAccount); + mockGetChildAccounts([mockChildAccount]); + mockGetProfile({ ...mockParentProfile, restricted: false }); + mockGetUser(mockParentUser); + + mockGetPersonalAccessTokens([]).as('getTokens'); + mockGetAppTokens([]).as('getAppTokens'); + mockCreatePersonalAccessToken(mockParentAccountToken).as('createToken'); + + cy.visitWithLogin('/profile/tokens'); + cy.wait(['@getTokens', '@getAppTokens']); + + // Click create button, fill out and submit PAT create form. + ui.button + .findByTitle('Create a Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetPersonalAccessTokens([mockParentAccountToken]).as('getTokens'); + ui.drawer + .findByTitle('Add Personal Access Token') + .should('be.visible') + .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'); + + // Specify ALL scopes by selecting the "No Access" Select All radio button. + cy.get('[data-qa-perm-rw-radio]').click(); + cy.get('[data-qa-perm-rw-radio]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + + // Specify a label and re-submit. + cy.findByLabelText('Label') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click() + .type(mockParentAccountToken.label); + + ui.buttonGroup + .findButtonByTitle('Create Token') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that PAT secret dialog is shown and close it. + cy.wait('@createToken'); + ui.dialog + .findByTitle('Personal Access Token') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('I Have Saved My Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that new PAT is shown in list and "View Scopes" drawer works. + // Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + cy.wait('@getTokens').then((xhr) => { + const actualTokenData = xhr.response?.body.data; + const actualTokenScopes = actualTokenData[0].scopes; + expect(actualTokenScopes).to.equal(mockParentAccountToken.scopes); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts index d57fec9b84f..2484db5c322 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts @@ -1,8 +1,3 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory, @@ -25,8 +20,6 @@ import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/Place import { linodeCreatePage } from 'support/ui/pages'; import { extendRegion } from 'support/util/regions'; -import type { Flags } from 'src/featureFlags'; - const mockAccount = accountFactory.build(); const mockNewarkRegion = extendRegion( @@ -53,17 +46,6 @@ describe('Linode create flow with Placement Group', () => { beforeEach(() => { mockGetAccount(mockAccount); mockGetRegions(mockRegions).as('getRegions'); - // TODO Remove feature flag mocks when `placementGroups` flag is retired. - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - linodeCreateRefactor: makeFeatureFlagData( - false - ), - }); - mockGetFeatureFlagClientstream(); }); /* @@ -198,16 +180,16 @@ describe('Linode create flow with Placement Group', () => { }); // Confirm the Placement group assignment is accounted for in the summary. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText('Assigned to Placement Group').should('be.visible'); - }); + cy.findByText('Assigned to Placement Group') + .scrollIntoView() + .should('be.visible'); // Type in a label, password and submit the form. mockCreateLinode(mockLinode).as('createLinode'); cy.get('#linode-label').clear().type('linode-with-placement-group'); cy.get('#root-password').type(randomString(32)); - cy.get('[data-qa-deploy-linode]').click(); + cy.findByText('Create Linode').should('be.enabled').click(); // Wait for outgoing API request and confirm that payload contains expected data. cy.wait('@createLinode').then((xhr) => { @@ -269,9 +251,9 @@ describe('Linode create flow with Placement Group', () => { .click(); // Confirm the Placement group assignment is accounted for in the summary. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText('Assigned to Placement Group').should('be.visible'); - }); + cy.findByText('Assigned to Placement Group') + .scrollIntoView() + .should('be.visible'); // Create Linode and confirm contents of outgoing API request payload. ui.button diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts index 5a833e72296..e114bf576de 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts @@ -1,14 +1,7 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory, placementGroupFactory } from 'src/factories'; import { regionFactory } from 'src/factories'; import { ui } from 'support/ui/'; - -import type { Flags } from 'src/featureFlags'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreatePlacementGroup, @@ -23,14 +16,6 @@ const mockAccount = accountFactory.build(); describe('Placement Group create flow', () => { beforeEach(() => { - // TODO Remove feature flag mocks when `placementGroups` flag is retired. - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts index cae1fb1f4ef..b08c39cb67b 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -2,12 +2,6 @@ * @file Cypress integration tests for VM Placement Groups deletion flows. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; - import { mockGetAccount } from 'support/intercepts/account'; import { mockDeletePlacementGroup, @@ -22,7 +16,6 @@ import { placementGroupFactory, } from 'src/factories'; import { headers as emptyStatePageHeaders } from 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyStateData'; -import type { Flags } from 'src/featureFlags'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { ui } from 'support/ui'; @@ -48,14 +41,6 @@ const PlacementGroupErrorMessage = 'An unknown error has occurred.'; describe('Placement Group deletion', () => { beforeEach(() => { - // TODO Remove feature flag mocks when `placementGroups` flag is retired. - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts index 1b2fb9f1e7c..244f629f834 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts @@ -1,8 +1,3 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetPlacementGroups } from 'support/intercepts/placement-groups'; import { ui } from 'support/ui'; import { @@ -15,20 +10,10 @@ import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { mockGetLinodes } from 'support/intercepts/linodes'; -import type { Flags } from 'src/featureFlags'; - const mockAccount = accountFactory.build(); describe('VM Placement landing page', () => { - // Mock the VM Placement Groups feature flag to be enabled for each test in this block. beforeEach(() => { - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount).as('getAccount'); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts index 882febd4cc1..0c52a148848 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts @@ -5,10 +5,6 @@ import { regionFactory, } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetLinodeDetails, mockGetLinodes, @@ -24,12 +20,9 @@ import { import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; - import type { Linode } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; const mockAccount = accountFactory.build(); @@ -52,16 +45,7 @@ const mockRegions = regionFactory.buildList(10, { }); describe('Placement Groups Linode assignment', () => { - // Mock the VM Placement Groups feature flag to be enabled for each test in this block. - // TODO Remove these mocks when `placementGroups` feature flag is retired. beforeEach(() => { - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount).as('getAccount'); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts index e405cf03829..35c5473b9a5 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts @@ -2,7 +2,6 @@ * @file Integration tests for Placement Groups navigation. */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories'; import { ui } from 'support/ui'; @@ -16,57 +15,37 @@ describe('Placement Groups navigation', () => { }); /* - * - Confirms that Placement Groups navigation item is shown when feature flag is enabled. * - Confirms that clicking Placement Groups navigation item directs user to Placement Groups landing page. */ it('can navigate to Placement Groups landing page', () => { - mockAppendFeatureFlags({ - placementGroups: { - beta: true, - enabled: true, - }, - }).as('getFeatureFlags'); - cy.visitWithLogin('/linodes'); - cy.wait('@getFeatureFlags'); ui.nav.findItemByTitle('Placement Groups').should('be.visible').click(); cy.url().should('endWith', '/placement-groups'); }); /* - * - Confirms that Placement Groups navigation item is not shown when feature flag is disabled. + * - Confirm navigation patterns to the create drawer */ - it('does not show Placement Groups navigation item when feature is disabled', () => { - mockAppendFeatureFlags({ - placementGroups: { - beta: true, - enabled: false, - }, - }).as('getFeatureFlags'); - - cy.visitWithLogin('/linodes'); - cy.wait('@getFeatureFlags'); - - ui.nav.find().within(() => { - cy.findByText('Placement Groups').should('not.exist'); - }); - }); - - /* - * - Confirms that manual navigation to Placement Groups landing page with feature is disabled displays Not Found to user. - */ - it('displays Not Found when manually navigating to /placement-groups with feature flag disabled', () => { - mockAppendFeatureFlags({ - placementGroups: { - beta: true, - enabled: false, - }, - }).as('getFeatureFlags'); - + it.only('can navigate to a placement group details page', () => { cy.visitWithLogin('/placement-groups'); - cy.wait('@getFeatureFlags'); - cy.findByText('Not Found').should('be.visible'); + ui.button + .findByTitle('Create Placement Group') + .should('be.visible') + .click(); + cy.url().should('endWith', '/placement-groups/create'); + + ui.drawer + .findByTitle('Create Placement Group') + .should('be.visible') + .within(() => { + ui.button + .findByAttribute('aria-label', 'Close drawer') + .should('be.visible') + .click(); + }); + + cy.url().should('endWith', '/placement-groups'); }); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts index 6b9f172f397..b857c69e1ca 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts @@ -2,11 +2,6 @@ * @file Integration tests for Placement Group update label flows. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber } from 'support/util/random'; import { mockGetPlacementGroups, @@ -15,7 +10,6 @@ import { } from 'support/intercepts/placement-groups'; import { accountFactory, placementGroupFactory } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import type { Flags } from 'src/featureFlags'; import { chooseRegion } from 'support/util/regions'; import { ui } from 'support/ui'; @@ -24,13 +18,6 @@ const mockAccount = accountFactory.build(); describe('Placement Group update label flow', () => { // Mock the VM Placement Groups feature flag to be enabled for each test in this block. beforeEach(() => { - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount); }); diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index cb14c8e9265..7619780800d 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -374,7 +374,7 @@ describe('Create stackscripts', () => { */ filteredImageData?.forEach((imageSample: Image) => { const imageLabel = imageSample.label; - cy.findAllByText(imageLabel) + cy.findAllByText(imageLabel, { exact: false }) .last() .scrollIntoView() .should('exist') diff --git a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts index 6e2835aa20b..76c2ec63a0f 100644 --- a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts @@ -114,7 +114,9 @@ describe('volume attach and detach flows', () => { // Confirm that volume has been attached to Linode. cy.wait('@attachVolume').its('response.statusCode').should('eq', 200); - ui.toast.assertMessage(`Volume ${volume.label} successfully attached.`); + ui.toast.assertMessage( + `Volume ${volume.label} has been attached to Linode ${linode.label}.` + ); cy.findByText(volume.label) .should('be.visible') .closest('tr') diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index 9a0230826f2..ff4d3deb0dd 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -1,12 +1,27 @@ -import type { Linode } from '@linode/api-v4'; +import type { Linode, Region } from '@linode/api-v4'; import { createTestLinode } from 'support/util/linodes'; -import { createLinodeRequestFactory } from 'src/factories/linodes'; +import { + createLinodeRequestFactory, + linodeFactory, +} from 'src/factories/linodes'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; -import { interceptCreateVolume } from 'support/intercepts/volumes'; +import { + interceptCreateVolume, + mockGetVolume, + mockGetVolumes, +} from 'support/intercepts/volumes'; import { randomNumber, randomString, randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { ui } from 'support/ui'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { accountFactory, regionFactory, volumeFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockGetLinodeDetails, + mockGetLinodes, +} from 'support/intercepts/linodes'; // 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. @@ -15,6 +30,18 @@ const pageSizeOverride = { PAGE_SIZE: 100, }; +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes', 'Block Storage', 'Block Storage Encryption'], + id: 'us-east', + label: 'Newark, NJ', + site_type: 'core', + }), +]; + +const CLIENT_LIBRARY_UPDATE_COPY = + 'This Linode requires a client library update and will need to be rebooted prior to attaching an encrypted volume.'; + authenticate(); describe('volume create flow', () => { before(() => { @@ -112,6 +139,10 @@ describe('volume create flow', () => { .should('be.visible') .click(); + // @TODO BSE: once BSE is fully rolled out, check for the notice (selected linode doesn't have + // "Block Storage Encryption" capability + user checked "Encrypt Volume" checkbox) instead of the absence of it + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + cy.findByText('Create Volume').click(); cy.wait('@createVolume'); @@ -141,6 +172,180 @@ describe('volume create flow', () => { ); }); + /* + * - Checks for Block Storage Encryption client library update notice on the Volume Create page. + */ + it('displays a warning notice on Volume Create page re: rebooting for client library updates under the appropriate conditions', () => { + // Conditions: Block Storage encryption feature flag is on; user has Block Storage Encryption capability; volume being created is encrypted and the + // selected Linode does not support Block Storage Encryption + + // Mock feature flag -- @TODO BSE: Remove feature flag once BSE is fully rolled out + mockAppendFeatureFlags({ + blockStorageEncryption: true, + }).as('getFeatureFlags'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Block Storage Encryption'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions).as('getRegions'); + + const linodeRequest = createLinodeRequestFactory.build({ + label: randomLabel(), + root_pass: randomString(16), + region: mockRegions[0].id, + booted: false, + }); + + cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( + (linode: Linode) => { + cy.visitWithLogin('/volumes/create'); + cy.wait(['@getFeatureFlags', '@getAccount']); + + // Select a linode without the BSE capability + cy.findByLabelText('Linode') + .should('be.visible') + .click() + .type(linode.label); + + ui.autocompletePopper + .findByTitle(linode.label) + .should('be.visible') + .click(); + + // Check the "Encrypt Volume" checkbox + cy.get('[data-qa-checked]').should('be.visible').click(); + // }); + + // Ensure warning notice is displayed + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); + } + ); + }); + + /* + * - Checks for absence of Block Storage Encryption client library update notice on the Volume Create page + * when selected linode supports BSE + */ + it('does not display a warning notice on Volume Create page re: rebooting for client library updates when selected linode supports BSE', () => { + // Conditions: Block Storage encryption feature flag is on; user has Block Storage Encryption capability; volume being created is encrypted and the + // selected Linode supports Block Storage Encryption + + // Mock feature flag -- @TODO BSE: Remove feature flag once BSE is fully rolled out + mockAppendFeatureFlags({ + blockStorageEncryption: true, + }).as('getFeatureFlags'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Block Storage Encryption'], + }); + + // Mock linode + const mockLinode = linodeFactory.build({ + region: mockRegions[0].id, + id: 123456, + capabilities: ['Block Storage Encryption'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodes([mockLinode]).as('getLinodes'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin(`/volumes/create`); + cy.wait(['@getAccount', '@getRegions', '@getLinodes']); + + // Select a linode without the BSE capability + cy.findByLabelText('Linode') + .should('be.visible') + .click() + .type(mockLinode.label); + + ui.autocompletePopper + .findByTitle(mockLinode.label) + .should('be.visible') + .click(); + + // Check the "Encrypt Volume" checkbox + cy.get('[data-qa-checked]').should('be.visible').click(); + // }); + + // Ensure warning notice is not displayed + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + }); + + /* + * - Checks for Block Storage Encryption client library update notice in the Create/Attach Volume drawer from the + 'Storage' details page of an existing Linode. + */ + it('displays a warning notice re: rebooting for client library updates under the appropriate conditions', () => { + // Conditions: Block Storage encryption feature flag is on; user has Block Storage Encryption capability; Linode does not support Block Storage Encryption and the user is trying to attach an encrypted volume + + // Mock feature flag -- @TODO BSE: Remove feature flag once BSE is fully rolled out + mockAppendFeatureFlags({ + blockStorageEncryption: true, + }).as('getFeatureFlags'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Block Storage Encryption'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions).as('getRegions'); + + const volume = volumeFactory.build({ + region: mockRegions[0].id, + encryption: 'enabled', + }); + + const linodeRequest = createLinodeRequestFactory.build({ + label: randomLabel(), + root_pass: randomString(16), + region: mockRegions[0].id, + booted: false, + }); + + cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( + (linode: Linode) => { + mockGetVolumes([volume]).as('getVolumes'); + mockGetVolume(volume); + + cy.visitWithLogin(`/linodes/${linode.id}/storage`); + cy.wait(['@getFeatureFlags', '@getAccount']); + + // Click "Add Volume" button + cy.findByText('Add Volume').click(); + + cy.get('[data-qa-drawer="true"]').within(() => { + cy.get('[data-qa-checked]').should('be.visible').click(); + }); + + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); + + // Ensure notice is cleared when switching views in drawer + cy.get('[data-qa-radio="Attach Existing Volume"]').click(); + cy.wait(['@getVolumes']); + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + + // Ensure notice is displayed in "Attach Existing Volume" view when an encrypted volume is selected + cy.findByPlaceholderText('Select a Volume') + .should('be.visible') + .click() + .type(`${volume.label}{downarrow}{enter}`); + ui.autocompletePopper + .findByTitle(volume.label) + .should('be.visible') + .click(); + + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); + } + ); + }); + /* * - Creates a volume from the 'Storage' details page of an existing Linode. * - Confirms that volume is listed correctly on Linode 'Storage' details page. diff --git a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts index fe441dcf865..7897d0c7f2c 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -87,7 +87,7 @@ describe('volume delete flow', () => { // Confirm that volume is deleted. cy.wait('@deleteVolume').its('response.statusCode').should('eq', 200); cy.findByText(volume.label).should('not.exist'); - ui.toast.assertMessage('Volume successfully deleted.'); + ui.toast.assertMessage(`Volume ${volume.label} has been deleted.`); } ); }); diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index 59436b4e983..f6d807e4c15 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -87,7 +87,7 @@ describe('volume upgrade/migration', () => { cy.findByText('active').should('be.visible'); - ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); + ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); it('can upgrade an attached volume from the volumes landing page', () => { @@ -178,7 +178,7 @@ describe('volume upgrade/migration', () => { cy.findByText('active').should('be.visible'); - ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); + ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); it('can upgrade an attached volume from the linode details page', () => { @@ -265,6 +265,6 @@ describe('volume upgrade/migration', () => { cy.findByText('active').should('be.visible'); - ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); + ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); }); diff --git a/packages/manager/cypress/support/component/index.html b/packages/manager/cypress/support/component/index.html new file mode 100644 index 00000000000..c3ef4d3fb82 --- /dev/null +++ b/packages/manager/cypress/support/component/index.html @@ -0,0 +1,15 @@ + + + + + + + + Cloud Manager Components + + +
+
+
+ + diff --git a/packages/manager/cypress/support/component/setup.tsx b/packages/manager/cypress/support/component/setup.tsx new file mode 100644 index 00000000000..cb1e039d375 --- /dev/null +++ b/packages/manager/cypress/support/component/setup.tsx @@ -0,0 +1,81 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +import * as React from 'react'; +import { mount } from 'cypress/react18'; + +import { Provider } from 'react-redux'; +import { LDProvider } from 'launchdarkly-react-client-sdk'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClientFactory } from '@src/queries/base'; +import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; +import { SnackbarProvider } from 'notistack'; +import { MemoryRouter } from 'react-router-dom'; +import { storeFactory } from 'src/store'; + +import '@testing-library/cypress/add-commands'; +import { ThemeName } from 'src/foundations/themes'; + +import 'cypress-axe'; + +/** + * Mounts a component with a Cloud Manager theme applied. + * + * @param jsx - React Component to mount. + * @param theme - Cloud Manager theme to apply. Defaults to `light`. + */ +export const mountWithTheme = ( + jsx: React.ReactNode, + theme: ThemeName = 'light', + flags: any = {} +) => { + const queryClient = queryClientFactory(); + const store = storeFactory(); + + return mount( + + + + + + {jsx} + + + + + + ); +}; + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount; + mountWithTheme: typeof mountWithTheme; + } + } +} + +Cypress.Commands.add('mount', mount); +Cypress.Commands.add('mountWithTheme', mountWithTheme); diff --git a/packages/manager/cypress/support/index.d.ts b/packages/manager/cypress/support/index.d.ts index b6c7a2e19e1..eb5eb156ae5 100644 --- a/packages/manager/cypress/support/index.d.ts +++ b/packages/manager/cypress/support/index.d.ts @@ -1,5 +1,5 @@ +import type { mount } from 'cypress/react18'; import { Labelable } from './commands'; - import type { LinodeVisitOptions } from './login.ts'; import type { TestTag } from 'support/util/tag'; @@ -95,6 +95,11 @@ declare global { * @param state - Cypress internal state to retrieve. */ state(state?: string): any; + + /** + * Mount a React component via `cypress/react`. + */ + mount: typeof mount; } } } diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 6e81f7b0455..a3ae28ffbbf 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -10,6 +10,7 @@ import { makeResponse } from 'support/util/response'; import type { Account, + AccountAvailability, AccountLogin, AccountMaintenance, AccountSettings, @@ -62,6 +63,23 @@ export const mockUpdateAccount = ( ); }; +/** + * Intercepts GET request to fetch account availability data and mocks response. + * + * @param accountAvailability - Account availability objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetAccountAvailability = ( + accountAvailability: AccountAvailability[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('account/availability*'), + paginateResponse(accountAvailability) + ); +}; + /** * Intercepts GET request to fetch account users and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/events.ts b/packages/manager/cypress/support/intercepts/events.ts index eea7b5fac12..0e12a2f8fb9 100644 --- a/packages/manager/cypress/support/intercepts/events.ts +++ b/packages/manager/cypress/support/intercepts/events.ts @@ -2,9 +2,11 @@ * @file Mocks and intercepts related to notification and event handling. */ -import type { Event, Notification } from '@linode/api-v4'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; + +import type { Event, Notification } from '@linode/api-v4'; /** * Intercepts GET request to fetch events and mocks response. @@ -21,6 +23,50 @@ export const mockGetEvents = (events: Event[]): Cypress.Chainable => { ); }; +/** + * Intercepts polling GET request to fetch events and mocks response. + * + * Unlike `mockGetEvents`, this utility only intercepts outgoing requests that + * occur while Cloud Manager is polling for events. + * + * @param events - Array of Events with which to mock response. + * @param pollingTimestamp - Timestamp to find when identifying polling requests. + * + * @returns Cypress chainable. + */ +export const mockGetEventsPolling = ( + events: Event[], + pollingTimestamp: string +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('account/events*'), (req) => { + console.log({ headers: req.headers }); + if ( + req.headers['x-filter'].includes( + `{"created":{"+gte":"${pollingTimestamp}"}}` + ) + ) { + req.reply(paginateResponse(events)); + } else { + req.continue(); + } + }); +}; + +/** + * Intercepts POST request to mark an event as seen and mocks response. + * + * @param eventId - ID of the event for which to intercept request. + * + * @returns Cypress chainable. + */ +export const mockMarkEventSeen = (eventId: number): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`account/events/${eventId}/seen`), + makeResponse({}) + ); +}; + /** * Intercepts GET request to fetch notifications and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/firewalls.ts b/packages/manager/cypress/support/intercepts/firewalls.ts index 07e2fc7a097..7ece27835b7 100644 --- a/packages/manager/cypress/support/intercepts/firewalls.ts +++ b/packages/manager/cypress/support/intercepts/firewalls.ts @@ -1,10 +1,12 @@ /** * @file Cypress intercepts and mocks for Firewall API requests. */ -import type { Firewall } from '@linode/api-v4'; +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import type { Firewall, FirewallTemplate } from '@linode/api-v4'; + /** * Intercepts GET request to fetch Firewalls. * @@ -44,6 +46,25 @@ export const mockCreateFirewall = ( return cy.intercept('POST', apiMatcher('networking/firewalls*'), firewall); }; +/** + * Intercepts POST request to create a Firewall and mocks an error response. + * + * @param errorMessage - Error message to be included in the mocked HTTP response. + * @param statusCode - HTTP status code for mocked error response. Default is `400`. + * + * @returns Cypress chainable. + */ +export const mockCreateFirewallError = ( + errorMessage: string, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`networking/firewalls*`), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts POST request to create a Firewall. * @@ -80,3 +101,20 @@ export const interceptUpdateFirewallLinodes = ( apiMatcher(`networking/firewalls/${firewallId}/devices`) ); }; + +/** + * Intercepts GET request to fetch a Firewall template and mocks response. + * + * @param template - Template with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetTemplate = ( + template: FirewallTemplate +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('networking/firewalls/templates/*'), + template + ); +}; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index e3dd41d140a..00b2e4c3f1b 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -4,11 +4,19 @@ import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; +import { linodeVlanNoInternetConfig } from 'support/util/linodes'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import { linodeVlanNoInternetConfig } from 'support/util/linodes'; -import type { Disk, Kernel, Linode, LinodeType, Volume } from '@linode/api-v4'; +import type { + Disk, + Firewall, + Kernel, + Linode, + LinodeIPsResponse, + LinodeType, + Volume, +} from '@linode/api-v4'; /** * Intercepts POST request to create a Linode. @@ -482,7 +490,8 @@ export const mockGetLinodeKernel = ( ); }; -/* Intercepts POST request to get a Linode Resize. +/** + * Intercepts POST request to get a Linode Resize. * * @param linodeId - ID of Linode to fetch. * @@ -496,3 +505,41 @@ export const interceptLinodeResize = ( apiMatcher(`linode/instances/${linodeId}/resize`) ); }; + +/** + * Mocks GET request to get a Linode's firewalls. + * + * @param linodeId - ID of Linode to get firewalls associated with it. + * @param firewalls - the firewalls with which to mock the response. + * + * @returns Cypress Chainable. + */ +export const mockGetLinodeFirewalls = ( + linodeId: number, + firewalls: Firewall[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/firewalls`), + paginateResponse(firewalls) + ); +}; + +/** + * Mocks GET request to get a Linode's IP addresses. + * + * @param linodeId - ID of Linode to get IP addresses for. + * @param ipAddresses: the IP Addresses with which to mock the response. + * + * @returns Cypress Chainable. + */ +export const mockGetLinodeIPAddresses = ( + linodeId: number, + ipAddresses: LinodeIPsResponse +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/ips`), + makeResponse(ipAddresses) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/networking.ts b/packages/manager/cypress/support/intercepts/networking.ts new file mode 100644 index 00000000000..375183442c1 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/networking.ts @@ -0,0 +1,16 @@ +import { apiMatcher } from 'support/util/intercepts'; + +/** + * Mocks PUT request to update an IP address. + * + * @param address - the IP address to update + * @param rdns - the updated RDNS of the IP address + * + * @returns Cypress chainable. + */ +export const mockUpdateIPAddress = ( + address: string, + rdns: string +): Cypress.Chainable => { + return cy.intercept('PUT', apiMatcher(`/networking/ips/${address}`), rdns); +}; diff --git a/packages/manager/cypress/support/intercepts/volumes.ts b/packages/manager/cypress/support/intercepts/volumes.ts index e0eb89ca056..90bc5f97669 100644 --- a/packages/manager/cypress/support/intercepts/volumes.ts +++ b/packages/manager/cypress/support/intercepts/volumes.ts @@ -20,6 +20,21 @@ export const mockGetVolumes = (volumes: Volume[]): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('volumes*'), paginateResponse(volumes)); }; +/** + * Intercepts GET request to fetch a Volume and mocks response. + * + * @param volume - Volume with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetVolume = (volume: Volume): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`volumes/${volume.id}`), + makeResponse(volume) + ); +}; + /** * Intercepts POST request to create a Volume. * diff --git a/packages/manager/cypress/support/plugins/fetch-account.ts b/packages/manager/cypress/support/plugins/fetch-account.ts index 3de8553f7b8..0262c5d3f16 100644 --- a/packages/manager/cypress/support/plugins/fetch-account.ts +++ b/packages/manager/cypress/support/plugins/fetch-account.ts @@ -1,5 +1,14 @@ import type { CypressPlugin } from './plugin'; -import { getAccountInfo, getAccountSettings } from '@linode/api-v4'; +import { resolve, join } from 'path'; +import { getAccountInfo, getAccountSettings, getProfile } from '@linode/api-v4'; +import { readFileSync } from 'fs'; + +import type { Account } from '@linode/api-v4'; + +/** + * The name of the environment variable that controls account cache reading. + */ +const envVarName = 'CY_TEST_ACCOUNT_CACHE_DIR'; /** * Fetches and caches Linode account info and settings. @@ -8,16 +17,78 @@ import { getAccountInfo, getAccountSettings } from '@linode/api-v4'; * `cloudManagerAccountSettings` env, respectively. */ export const fetchAccount: CypressPlugin = async (_on, config) => { - const [account, accountSettings] = await Promise.all([ - getAccountInfo(), + // Fetch profile and account settings first, since there is a comparatively + // low likelihood that these requests will fail. + const [profile, accountSettings] = await Promise.all([ + getProfile(), getAccountSettings(), ]); + /** + * Cached account data for the desired test account if it is available, or `undefined`. + */ + const accountCacheData = (() => { + if (!config.env[envVarName]) { + return undefined; + } + + const accountCacheDir = config.env[envVarName]; + const accountCachePath = resolve( + join(accountCacheDir, `${profile.uid}.json`) + ); + + try { + const cacheJson = readFileSync(accountCachePath, 'utf8'); + const cacheData = JSON.parse(cacheJson); + + if ('account' in cacheData) { + const accountCache = cacheData['account'] as Account; + return accountCache; + } + } catch (e) { + // TODO Error message. + console.error(`Failed to read account cache file at ${accountCachePath}`); + if ('message' in e) { + console.error(e.message); + } + return undefined; + } + + return undefined; + })(); + + // Fetch account info, falling back to offline cached data if it is + // enabled and available. + let account: undefined | Account = undefined; + try { + account = await getAccountInfo(); + } catch (e) { + console.error( + 'An error occurred while retrieving test account information.' + ); + + // Re-throw the error if no cached account data is available, because the + // test run cannot continue. + if (!accountCacheData) { + throw e; + } + // Otherwise, note that the original account fetch failed and that the tests + // will be proceeding using cached data. + else { + if (e.message) { + console.error(e.message); + } + console.info( + 'Cached account data is available and will be used instead.' + ); + } + } + return { ...config, env: { ...config.env, - cloudManagerAccount: account, + cloudManagerAccount: account || accountCacheData, cloudManagerAccountSettings: accountSettings, }, }; diff --git a/packages/manager/cypress/support/plugins/junit-report.ts b/packages/manager/cypress/support/plugins/junit-report.ts index 986eb6c6b24..a60ded4521b 100644 --- a/packages/manager/cypress/support/plugins/junit-report.ts +++ b/packages/manager/cypress/support/plugins/junit-report.ts @@ -8,27 +8,42 @@ const capitalize = (str: string): string => { }; /** - * Enables and configures JUnit reporting when `CY_TEST_JUNIT_REPORT` is defined. + * Returns a plugin to enable JUnit reporting when `CY_TEST_JUNIT_REPORT` is defined. + * + * If no suite name is specified, this function will attempt to determine the + * suite name using the Cypress configuration object. + * + * @param suiteName - Optional suite name in the JUnit output. * * @returns Cypress configuration object. */ -export const enableJunitReport: CypressPlugin = (_on, config) => { - if (!!config.env[envVarName]) { - const testSuite = config.env['cypress_test_suite'] || 'core'; - const testSuiteName = `${capitalize(testSuite)} Test Suite`; +export const enableJunitReport = ( + suiteName?: string, + jenkinsMode: boolean = false +): CypressPlugin => { + return (_on, config) => { + if (!!config.env[envVarName]) { + // Use `suiteName` if it is specified. + // Otherwise, attempt to determine the test suite name using + // our Cypress configuration. + const testSuite = suiteName || config.env['cypress_test_suite'] || 'core'; + + const testSuiteName = `${capitalize(testSuite)} Test Suite`; - // 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'; + // 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'; - // See also: https://www.npmjs.com/package/mocha-junit-reporter#full-configuration-options - config.reporterOptions = { - mochaFile: 'cypress/results/test-results-[hash].xml', - rootSuiteTitle: 'Cloud Manager Cypress Tests', - testsuitesTitle: testSuiteName, - jenkinsMode: false, - }; - } - return config; + // See also: https://www.npmjs.com/package/mocha-junit-reporter#full-configuration-options + config.reporterOptions = { + mochaFile: 'cypress/results/test-results-[hash].xml', + rootSuiteTitle: 'Cloud Manager Cypress Tests', + testsuitesTitle: testSuiteName, + jenkinsMode, + suiteTitleSeparatedBy: jenkinsMode ? '→' : ' ', + }; + } + return config; + }; }; diff --git a/packages/manager/cypress/support/plugins/node-version-check.ts b/packages/manager/cypress/support/plugins/node-version-check.ts index 20da1291b2c..80e399cc553 100644 --- a/packages/manager/cypress/support/plugins/node-version-check.ts +++ b/packages/manager/cypress/support/plugins/node-version-check.ts @@ -2,7 +2,7 @@ import { CypressPlugin } from './plugin'; // Supported major versions of Node.js. // Running Cypress using other versions will cause a warning to be displayed. -const supportedVersions = [16, 18]; +const supportedVersions = [18, 20]; /** * Returns a string describing the version of Node.js that is running the tests. diff --git a/packages/manager/cypress/support/ui/app-bar.ts b/packages/manager/cypress/support/ui/app-bar.ts new file mode 100644 index 00000000000..163e395760b --- /dev/null +++ b/packages/manager/cypress/support/ui/app-bar.ts @@ -0,0 +1,13 @@ +/** + * UI helpers for Cloud Manager top app bar. + */ +export const appBar = { + /** + * Finds the app bar. + * + * @returns Cypress chainable. + */ + find: () => { + return cy.get('[data-qa-appbar]'); + }, +}; diff --git a/packages/manager/cypress/support/ui/autocomplete.ts b/packages/manager/cypress/support/ui/autocomplete.ts index 86356ab3d88..2be652878ce 100644 --- a/packages/manager/cypress/support/ui/autocomplete.ts +++ b/packages/manager/cypress/support/ui/autocomplete.ts @@ -33,7 +33,7 @@ export const autocompletePopper = { * Finds an item within an autocomplete popper that has the given title. */ findByTitle: ( - title: string, + title: string | RegExp, options?: SelectorMatcherOptions ): Cypress.Chainable => { return ( @@ -90,7 +90,9 @@ export const regionSelect = { */ findItemByRegionId: (regionId: string, searchRegions?: Region[]) => { const region = getRegionById(regionId, searchRegions); - return autocompletePopper.findByTitle(`${region.label} (${region.id})`); + return autocompletePopper.findByTitle( + new RegExp(`${region.label}\\s?(\(${region.id}\))?`) + ); }, /** @@ -105,6 +107,8 @@ export const regionSelect = { */ findItemByRegionLabel: (regionLabel: string, searchRegions?: Region[]) => { const region = getRegionByLabel(regionLabel, searchRegions); - return autocompletePopper.findByTitle(`${region.label} (${region.id})`); + return autocompletePopper.findByTitle( + new RegExp(`${region.label}\\s?(\(${region.id}\))?`) + ); }, }; diff --git a/packages/manager/cypress/support/ui/index.ts b/packages/manager/cypress/support/ui/index.ts index 26d8b89ac17..1cba6746bcb 100644 --- a/packages/manager/cypress/support/ui/index.ts +++ b/packages/manager/cypress/support/ui/index.ts @@ -1,5 +1,6 @@ import * as accordion from './accordion'; import * as actionMenu from './action-menu'; +import * as appBar from './app-bar'; import * as autocomplete from './autocomplete'; import * as breadcrumb from './breadcrumb'; import * as buttons from './buttons'; @@ -21,6 +22,7 @@ import * as userMenu from './user-menu'; export const ui = { ...accordion, ...actionMenu, + ...appBar, ...autocomplete, ...breadcrumb, ...buttons, diff --git a/packages/manager/cypress/support/util/accessibility.ts b/packages/manager/cypress/support/util/accessibility.ts new file mode 100644 index 00000000000..1dc2e8193a6 --- /dev/null +++ b/packages/manager/cypress/support/util/accessibility.ts @@ -0,0 +1,29 @@ +/** + * @file Utilities related to accessibility testing. + */ + +/** + * Performs automated Axe accessibility checks against a component. + * + * Only applicable to component tests; does not achieve anything when used + * by an integration test. + * + * @param rulesetTag - Axe ruleset tag. Defaults to WCAG 2.2 Level AA rules. + * + * @link [axe-core rule tags](https://www.deque.com/axe/core-documentation/api-documentation/#axecore-tags) + */ +export const checkComponentA11y = (rulesetTag: string = 'wcag22aa') => { + // Specify a custom aXe core path to account for monorepo package layout. + const axeCorePath = '../../node_modules/axe-core/axe.min.js'; + + // Perform checks against component only and not the surrounding HTML. + const componentContext = '[data-cy-root]'; + + cy.injectAxe({ axeCorePath }); + cy.checkA11y(componentContext, { + runOnly: { + type: 'tag', + values: [rulesetTag], + }, + }); +}; diff --git a/packages/manager/cypress/support/util/components.ts b/packages/manager/cypress/support/util/components.ts new file mode 100644 index 00000000000..0fe9bd25692 --- /dev/null +++ b/packages/manager/cypress/support/util/components.ts @@ -0,0 +1,104 @@ +/** + * @file Utilities for component testing. + */ + +import { MountReturn } from 'cypress/react18'; +import type { ThemeName } from 'src/foundations/themes'; + +/** + * Array of themes for which to test components. + */ +export const componentThemes: ThemeName[] = ['light', 'dark']; + +/** + * Default theme to use for non-visual component tests. + * + * Sorry dark theme users. + */ +// TODO Look into allowing this to be overridden via `.env`. +export const defaultTheme = 'light'; + +const capitalize = (uncapitalizedString: string): string => { + return `${uncapitalizedString[0].toUpperCase()}${uncapitalizedString.slice( + 1 + )}`; +}; + +/** + * Describes a Cypress command that can be used to mount a component. + * + * @param jsx - React node to mount. + * @param flags - Optional feature flags to apply. + */ +export type MountCommand = ( + jsx: React.ReactNode, + flags?: any +) => Cypress.Chainable; + +/** + * Describes a group of tests for a component. + * + * Passes a `mount` command to the given `callback` that can be used to + * mount any component using the default theme. + * + * @param componentName - Name of component being tested. + * @param callback - Test scope callback. + */ +export const componentTests = ( + componentName: string, + callback: (mountCommand: MountCommand) => void +) => { + const mountCommand = (jsx: React.ReactNode, flags?: any) => + cy.mountWithTheme(jsx, defaultTheme, flags); + describe(`${componentName} component tests`, () => { + callback(mountCommand); + }); +}; + +/** + * Describes a group of visual tests for a component. + * + * Tests defined inside the given `callback` will be parameterized against + * every theme. This makes `visualTests` useful for tests focused on accessibility + * and visual regression. + * + * Passes a `mount` command to the given `callback` that can be used to mount + * any component with the parameterized theme. + * + * @param callback - Test scope callback. + */ +export const visualTests = (callback: (mountCommand: MountCommand) => void) => { + describe('Visual tests', () => { + componentThemes.forEach((themeName: ThemeName) => { + const mountCommand = (jsx: React.ReactNode, flags?: any) => + cy.mountWithTheme(jsx, themeName, flags); + describe(`${capitalize(themeName)} theme`, () => { + callback(mountCommand); + }); + }); + }); +}; + +/** + * Creates a spy for the given function and assigns it an alias. + * + * @example + * const spyFn = createSpy(() => {}, 'mySpyFunction'); + * mount(); + * // ...Later, after interacting with ``. + * cy.get('@mySpyFunction').should('have.been.calledOnce'); + * + * @param fn - Function for which to create spy. + * @param alias - Alias to assign for later examination into spy. + * + * @returns The given function `fn`. + */ +// TODO Find a better place for this util. +export const createSpy = (fn: () => T, alias: string) => { + const callback = { + fn, + }; + + cy.spy(callback, 'fn').as(alias); + return callback.fn; +}; diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index ad6e6b538d4..9bbe9ad4b9f 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -1,11 +1,13 @@ import { createLinode, getLinodeConfigs } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@src/factories'; +import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; +import { pageSize } from 'support/constants/api'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { pollLinodeDiskStatuses, pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; + import { depaginate } from './paginate'; -import { pageSize } from 'support/constants/api'; import type { Config, @@ -13,17 +15,16 @@ import type { InterfacePayload, Linode, } from '@linode/api-v4'; -import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; /** * Linode create interface to configure a Linode with no public internet access. */ export const linodeVlanNoInternetConfig: InterfacePayload[] = [ { - purpose: 'vlan', - primary: false, - label: randomLabel(), ipam_address: null, + label: randomLabel(), + primary: false, + purpose: 'vlan', }, ]; @@ -40,30 +41,30 @@ export const linodeVlanNoInternetConfig: InterfacePayload[] = [ */ export type CreateTestLinodeSecurityMethod = | 'firewall' - | 'vlan_no_internet' - | 'powered_off'; + | 'powered_off' + | 'vlan_no_internet'; /** * Options to control the behavior of test Linode creation. */ export interface CreateTestLinodeOptions { - /** Whether to wait for created Linode disks to be available before resolving. */ - waitForDisks: boolean; + /** Method to use to secure the test Linode. */ + securityMethod: CreateTestLinodeSecurityMethod; /** Whether to wait for created Linode to boot before resolving. */ waitForBoot: boolean; - /** Method to use to secure the test Linode. */ - securityMethod: CreateTestLinodeSecurityMethod; + /** Whether to wait for created Linode disks to be available before resolving. */ + waitForDisks: boolean; } /** * Default test Linode creation options. */ export const defaultCreateTestLinodeOptions = { - waitForDisks: false, - waitForBoot: false, securityMethod: 'firewall', + waitForBoot: false, + waitForDisks: false, }; /** @@ -106,20 +107,20 @@ export const createTestLinode = async ( const resolvedCreatePayload = { ...createLinodeRequestFactory.build({ - label: randomLabel(), + booted: false, image: 'linode/debian11', + label: randomLabel(), region: chooseRegion().id, - booted: false, }), ...(createRequestPayload || {}), ...securityMethodPayload, // Override given root password; mitigate against using default factory password, inadvertent logging, etc. root_pass: randomString(64, { + lowercase: true, + numbers: true, spaces: true, symbols: true, - numbers: true, - lowercase: true, uppercase: true, }), }; @@ -169,21 +170,24 @@ export const createTestLinode = async ( } Cypress.log({ - name: 'createTestLinode', - message: `Create Linode '${linode.label}' (ID ${linode.id})`, consoleProps: () => { return { + linode, options: resolvedOptions, payload: { ...resolvedCreatePayload, root_pass: '(redacted)', }, - linode, }; }, + message: `Create Linode '${linode.label}' (ID ${linode.id})`, + name: 'createTestLinode', }); - return linode; + return { + ...linode, + capabilities: [], + }; }; /** diff --git a/packages/manager/package.json b/packages/manager/package.json index c7de8fa0381..dc6a85808be 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.127.0", + "version": "1.128.0", "private": true, "type": "module", "bugs": { @@ -27,8 +27,8 @@ "@paypal/react-paypal-js": "^7.8.3", "@reach/tabs": "^0.10.5", "@sentry/react": "^7.57.0", - "@tanstack/react-query": "4.36.1", - "@tanstack/react-query-devtools": "4.36.1", + "@tanstack/react-query": "5.51.24", + "@tanstack/react-query-devtools": "5.51.24", "algoliasearch": "^4.14.3", "axios": "~1.7.4", "braintree-web": "^3.92.2", @@ -49,7 +49,6 @@ "libphonenumber-js": "^1.10.6", "lodash.clonedeep": "^4.5.0", "lodash.curry": "^4.1.1", - "lodash.set": "^4.3.2", "logic-query-parser": "^0.0.5", "luxon": "3.4.4", "markdown-it": "^12.3.2", @@ -100,6 +99,8 @@ "cy:run": "cypress run -b chrome", "cy:e2e": "cypress run --headless -b chrome", "cy:debug": "cypress open --e2e", + "cy:component": "cypress open --component", + "cy:component:run": "cypress run --component --headless -b chrome", "cy:rec-snap": "cypress run --headless -b chrome --env visualRegMode=record --spec ./cypress/integration/**/*visual*.spec.ts", "typecheck": "tsc --noEmit && tsc -p cypress --noEmit", "coverage": "vitest run --coverage && open coverage/index.html", @@ -143,7 +144,6 @@ "@types/jspdf": "^1.3.3", "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.curry": "^4.1.9", - "@types/lodash.set": "^4.3.9", "@types/luxon": "3.4.2", "@types/markdown-it": "^10.0.2", "@types/md5": "^2.1.32", @@ -168,8 +168,8 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react-swc": "^3.5.0", - "@vitest/coverage-v8": "^1.6.0", - "@vitest/ui": "^1.6.0", + "@vitest/coverage-v8": "^2.0.5", + "@vitest/ui": "^2.0.5", "chai-string": "^1.5.0", "chalk": "^5.2.0", "commander": "^6.2.1", @@ -213,7 +213,7 @@ "ts-node": "^10.9.2", "vite": "^5.1.7", "vite-plugin-svgr": "^3.2.0", - "vitest": "^1.6.0" + "vitest": "^2.0.5" }, "browserslist": [ ">1%", diff --git a/packages/manager/public/.well-known/security.txt b/packages/manager/public/.well-known/security.txt index 2e874fff756..228df60423e 100644 --- a/packages/manager/public/.well-known/security.txt +++ b/packages/manager/public/.well-known/security.txt @@ -1,4 +1,16 @@ -Contact: disclosure@linode.com -Encryption: https://keybase.io/linodesecurity/pgp_keys.asc -Policy: https://hackerone.com/linode -Hiring: https://linode.com/careers \ No newline at end of file +# Akamai uses HackerOne for responsible disclosure using +# separate, invite-only programs for specific scopes: +# Akamai CDN: https://hackerone.com/akamai?type=team +# Akamai Connected Cloud / Linode: https://hackerone.com/linode?type=team +Policy: https://www.akamai.com/site/en/documents/akamai/2024/security-research-agreement.pdf + +# In addition, we welcome _all_ types of security reports via email: +Contact: mailto:security@akamai.com +Encryption: https://www.akamai.com/us/en/multimedia/documents/infosec/akamai-security-general.pub + +# Please send abuse reports to: +Contact: mailto:abuse@akamai.com + +Hiring: https://www.akamai.com/careers + +Preferred-Languages: en \ No newline at end of file diff --git a/packages/manager/public/assets/apachespark.svg b/packages/manager/public/assets/apachespark.svg new file mode 100644 index 00000000000..aa4cec7d24e --- /dev/null +++ b/packages/manager/public/assets/apachespark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/apachespark.svg b/packages/manager/public/assets/white/apachespark.svg new file mode 100644 index 00000000000..5c0c084fd22 --- /dev/null +++ b/packages/manager/public/assets/white/apachespark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 3fed2fc35bd..9172bf218c4 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -15,9 +15,9 @@ import { useDialogContext } from 'src/context/useDialogContext'; import { Footer } from 'src/features/Footer'; import { GlobalNotifications } from 'src/features/GlobalNotifications/GlobalNotifications'; import { - notificationContext, + notificationCenterContext, useNotificationContext, -} from 'src/features/NotificationCenter/NotificationContext'; +} from 'src/features/NotificationCenter/NotificationCenterContext'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; import { useFlags } from 'src/hooks/useFlags'; import { @@ -200,7 +200,7 @@ export const MainContent = () => { const globalErrors = useGlobalErrors(); - const NotificationProvider = notificationContext.Provider; + const NotificationProvider = notificationCenterContext.Provider; const contextValue = useNotificationContext(); const ComplianceUpdateProvider = complianceUpdateContext.Provider; diff --git a/packages/manager/src/__data__/distributedRegionsData.ts b/packages/manager/src/__data__/distributedRegionsData.ts new file mode 100644 index 00000000000..051230aae49 --- /dev/null +++ b/packages/manager/src/__data__/distributedRegionsData.ts @@ -0,0 +1,193 @@ +import type { Region } from '@linode/api-v4'; + +export const distributedRegions: Region[] = [ + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-den-edge-1', + label: 'Edge - Denver, CO', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'de', + id: 'de-ham-edge-1', + label: 'Edge - Hamburg, DE', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'fr', + id: 'fr-mrs-edge-1', + label: 'Edge - Marseille, FR', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'za', + id: 'za-jnb-edge-1', + label: 'Edge - Johannesburg, ZA\t', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'my', + id: 'my-kul-edge-1', + label: 'Edge - Kuala Lumpur, MY', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'co', + id: 'co-bog-edge-1', + label: 'Edge - Bogotá, CO', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'mx', + id: 'mx-qro-edge-1', + label: 'Edge - Querétaro, MX', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-hou-edge-1', + label: 'Edge - Houston, TX', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'cl', + id: 'cl-scl-edge-1', + label: 'Edge - Santiago, CL', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, +]; diff --git a/packages/manager/src/__data__/ldClient.ts b/packages/manager/src/__data__/ldClient.ts index ba2e9dfb02d..ab550b3aea1 100644 --- a/packages/manager/src/__data__/ldClient.ts +++ b/packages/manager/src/__data__/ldClient.ts @@ -1,4 +1,4 @@ -import { LDClient } from 'launchdarkly-js-client-sdk'; +import type { LDClient } from 'launchdarkly-js-client-sdk'; const client: LDClient = { allFlags: vi.fn(), diff --git a/packages/manager/src/__data__/productionRegionsData.ts b/packages/manager/src/__data__/productionRegionsData.ts new file mode 100644 index 00000000000..ecef9b995b1 --- /dev/null +++ b/packages/manager/src/__data__/productionRegionsData.ts @@ -0,0 +1,788 @@ +import type { Region } from '@linode/api-v4'; + +/** + * Array of Regions that simulate APIv4 Production region data. + */ +export const productionRegions: Region[] = [ + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'in', + id: 'ap-west', + label: 'Mumbai, IN', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.105.34.5, 172.105.35.5, 172.105.36.5, 172.105.37.5, 172.105.38.5, 172.105.39.5, 172.105.40.5, 172.105.41.5, 172.105.42.5, 172.105.43.5', + ipv6: + '2400:8904::f03c:91ff:fea5:659, 2400:8904::f03c:91ff:fea5:9282, 2400:8904::f03c:91ff:fea5:b9b3, 2400:8904::f03c:91ff:fea5:925a, 2400:8904::f03c:91ff:fea5:22cb, 2400:8904::f03c:91ff:fea5:227a, 2400:8904::f03c:91ff:fea5:924c, 2400:8904::f03c:91ff:fea5:f7e2, 2400:8904::f03c:91ff:fea5:2205, 2400:8904::f03c:91ff:fea5:9207', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'ca', + id: 'ca-central', + label: 'Toronto, CA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.105.0.5, 172.105.3.5, 172.105.4.5, 172.105.5.5, 172.105.6.5, 172.105.7.5, 172.105.8.5, 172.105.9.5, 172.105.10.5, 172.105.11.5', + ipv6: + '2600:3c04::f03c:91ff:fea9:f63, 2600:3c04::f03c:91ff:fea9:f6d, 2600:3c04::f03c:91ff:fea9:f80, 2600:3c04::f03c:91ff:fea9:f0f, 2600:3c04::f03c:91ff:fea9:f99, 2600:3c04::f03c:91ff:fea9:fbd, 2600:3c04::f03c:91ff:fea9:fdd, 2600:3c04::f03c:91ff:fea9:fe2, 2600:3c04::f03c:91ff:fea9:f68, 2600:3c04::f03c:91ff:fea9:f4a', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'au', + id: 'ap-southeast', + label: 'Sydney, AU', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.105.166.5, 172.105.169.5, 172.105.168.5, 172.105.172.5, 172.105.162.5, 172.105.170.5, 172.105.167.5, 172.105.171.5, 172.105.181.5, 172.105.161.5', + ipv6: + '2400:8907::f03c:92ff:fe6e:ec8, 2400:8907::f03c:92ff:fe6e:98e4, 2400:8907::f03c:92ff:fe6e:1c58, 2400:8907::f03c:92ff:fe6e:c299, 2400:8907::f03c:92ff:fe6e:c210, 2400:8907::f03c:92ff:fe6e:c219, 2400:8907::f03c:92ff:fe6e:1c5c, 2400:8907::f03c:92ff:fe6e:c24e, 2400:8907::f03c:92ff:fe6e:e6b, 2400:8907::f03c:92ff:fe6e:e3d', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Managed Databases', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-iad', + label: 'Washington, DC', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '139.144.192.62, 139.144.192.60, 139.144.192.61, 139.144.192.53, 139.144.192.54, 139.144.192.67, 139.144.192.69, 139.144.192.66, 139.144.192.52, 139.144.192.68', + ipv6: + '2600:3c05::f03c:93ff:feb6:43b6, 2600:3c05::f03c:93ff:feb6:4365, 2600:3c05::f03c:93ff:feb6:43c2, 2600:3c05::f03c:93ff:feb6:e441, 2600:3c05::f03c:93ff:feb6:94ef, 2600:3c05::f03c:93ff:feb6:94ba, 2600:3c05::f03c:93ff:feb6:94a8, 2600:3c05::f03c:93ff:feb6:9413, 2600:3c05::f03c:93ff:feb6:9443, 2600:3c05::f03c:93ff:feb6:94e0', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Managed Databases', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-ord', + label: 'Chicago, IL', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.0.17, 172.232.0.16, 172.232.0.21, 172.232.0.13, 172.232.0.22, 172.232.0.9, 172.232.0.19, 172.232.0.20, 172.232.0.15, 172.232.0.18', + ipv6: + '2600:3c06::f03c:93ff:fed0:e5fc, 2600:3c06::f03c:93ff:fed0:e54b, 2600:3c06::f03c:93ff:fed0:e572, 2600:3c06::f03c:93ff:fed0:e530, 2600:3c06::f03c:93ff:fed0:e597, 2600:3c06::f03c:93ff:fed0:e511, 2600:3c06::f03c:93ff:fed0:e5f2, 2600:3c06::f03c:93ff:fed0:e5bf, 2600:3c06::f03c:93ff:fed0:e529, 2600:3c06::f03c:93ff:fed0:e5a3', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Disk Encryption', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Managed Databases', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'fr', + id: 'fr-par', + label: 'Paris, FR', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.32.21, 172.232.32.23, 172.232.32.17, 172.232.32.18, 172.232.32.16, 172.232.32.22, 172.232.32.20, 172.232.32.14, 172.232.32.11, 172.232.32.12', + ipv6: + '2600:3c07::f03c:93ff:fef2:2e63, 2600:3c07::f03c:93ff:fef2:2ec7, 2600:3c07::f03c:93ff:fef2:0dee, 2600:3c07::f03c:93ff:fef2:0d25, 2600:3c07::f03c:93ff:fef2:0de0, 2600:3c07::f03c:93ff:fef2:2e29, 2600:3c07::f03c:93ff:fef2:0dda, 2600:3c07::f03c:93ff:fef2:0d82, 2600:3c07::f03c:93ff:fef2:b3ac, 2600:3c07::f03c:93ff:fef2:b3a8', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-sea', + label: 'Seattle, WA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.160.19, 172.232.160.21, 172.232.160.17, 172.232.160.15, 172.232.160.18, 172.232.160.8, 172.232.160.12, 172.232.160.11, 172.232.160.14, 172.232.160.16', + ipv6: + '2600:3c0a::f03c:93ff:fe54:c6da, 2600:3c0a::f03c:93ff:fe54:c691, 2600:3c0a::f03c:93ff:fe54:c68d, 2600:3c0a::f03c:93ff:fe54:c61e, 2600:3c0a::f03c:93ff:fe54:c653, 2600:3c0a::f03c:93ff:fe54:c64c, 2600:3c0a::f03c:93ff:fe54:c68a, 2600:3c0a::f03c:93ff:fe54:c697, 2600:3c0a::f03c:93ff:fe54:c60f, 2600:3c0a::f03c:93ff:fe54:c6a0', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'br', + id: 'br-gru', + label: 'Sao Paulo, BR', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.0.4, 172.233.0.9, 172.233.0.7, 172.233.0.12, 172.233.0.5, 172.233.0.13, 172.233.0.10, 172.233.0.6, 172.233.0.8, 172.233.0.11', + ipv6: + '2600:3c0d::f03c:93ff:fe3d:51cb, 2600:3c0d::f03c:93ff:fe3d:51a7, 2600:3c0d::f03c:93ff:fe3d:51a9, 2600:3c0d::f03c:93ff:fe3d:5119, 2600:3c0d::f03c:93ff:fe3d:51fe, 2600:3c0d::f03c:93ff:fe3d:517c, 2600:3c0d::f03c:93ff:fe3d:5144, 2600:3c0d::f03c:93ff:fe3d:5170, 2600:3c0d::f03c:93ff:fe3d:51cc, 2600:3c0d::f03c:93ff:fe3d:516c', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'nl', + id: 'nl-ams', + label: 'Amsterdam, NL', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.33.36, 172.233.33.38, 172.233.33.35, 172.233.33.39, 172.233.33.34, 172.233.33.33, 172.233.33.31, 172.233.33.30, 172.233.33.37, 172.233.33.32', + ipv6: + '2600:3c0e::f03c:93ff:fe9d:2d10, 2600:3c0e::f03c:93ff:fe9d:2d89, 2600:3c0e::f03c:93ff:fe9d:2d79, 2600:3c0e::f03c:93ff:fe9d:2d96, 2600:3c0e::f03c:93ff:fe9d:2da5, 2600:3c0e::f03c:93ff:fe9d:2d34, 2600:3c0e::f03c:93ff:fe9d:2d68, 2600:3c0e::f03c:93ff:fe9d:2d17, 2600:3c0e::f03c:93ff:fe9d:2d45, 2600:3c0e::f03c:93ff:fe9d:2d5c', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'se', + id: 'se-sto', + label: 'Stockholm, SE', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.128.24, 172.232.128.26, 172.232.128.20, 172.232.128.22, 172.232.128.25, 172.232.128.19, 172.232.128.23, 172.232.128.18, 172.232.128.21, 172.232.128.27', + ipv6: + '2600:3c09::f03c:93ff:fea9:4dbe, 2600:3c09::f03c:93ff:fea9:4d63, 2600:3c09::f03c:93ff:fea9:4dce, 2600:3c09::f03c:93ff:fea9:4dbb, 2600:3c09::f03c:93ff:fea9:4d99, 2600:3c09::f03c:93ff:fea9:4d26, 2600:3c09::f03c:93ff:fea9:4de0, 2600:3c09::f03c:93ff:fea9:4d69, 2600:3c09::f03c:93ff:fea9:4dbf, 2600:3c09::f03c:93ff:fea9:4da6', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'es', + id: 'es-mad', + label: 'Madrid, ES', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.111.6, 172.233.111.17, 172.233.111.21, 172.233.111.25, 172.233.111.19, 172.233.111.12, 172.233.111.26, 172.233.111.16, 172.233.111.18, 172.233.111.9', + ipv6: + '2a01:7e02::f03c:93ff:feea:b585, 2a01:7e02::f03c:93ff:feea:b5ab, 2a01:7e02::f03c:93ff:feea:b5c6, 2a01:7e02::f03c:93ff:feea:b592, 2a01:7e02::f03c:93ff:feea:b5aa, 2a01:7e02::f03c:93ff:feea:b5d3, 2a01:7e02::f03c:93ff:feea:b5d7, 2a01:7e02::f03c:93ff:feea:b528, 2a01:7e02::f03c:93ff:feea:b522, 2a01:7e02::f03c:93ff:feea:b51a', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'in', + id: 'in-maa', + label: 'Chennai, IN', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.96.17, 172.232.96.26, 172.232.96.19, 172.232.96.20, 172.232.96.25, 172.232.96.21, 172.232.96.18, 172.232.96.22, 172.232.96.23, 172.232.96.24', + ipv6: + '2600:3c08::f03c:93ff:fe7c:1135, 2600:3c08::f03c:93ff:fe7c:11f8, 2600:3c08::f03c:93ff:fe7c:11d2, 2600:3c08::f03c:93ff:fe7c:11a7, 2600:3c08::f03c:93ff:fe7c:11ad, 2600:3c08::f03c:93ff:fe7c:110a, 2600:3c08::f03c:93ff:fe7c:11f9, 2600:3c08::f03c:93ff:fe7c:1137, 2600:3c08::f03c:93ff:fe7c:11db, 2600:3c08::f03c:93ff:fe7c:1164', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'jp', + id: 'jp-osa', + label: 'Osaka, JP', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.64.44, 172.233.64.43, 172.233.64.37, 172.233.64.40, 172.233.64.46, 172.233.64.41, 172.233.64.39, 172.233.64.42, 172.233.64.45, 172.233.64.38', + ipv6: + '2400:8905::f03c:93ff:fe9d:b085, 2400:8905::f03c:93ff:fe9d:b012, 2400:8905::f03c:93ff:fe9d:b09b, 2400:8905::f03c:93ff:fe9d:b0d8, 2400:8905::f03c:93ff:fe9d:259f, 2400:8905::f03c:93ff:fe9d:b006, 2400:8905::f03c:93ff:fe9d:b084, 2400:8905::f03c:93ff:fe9d:b0ce, 2400:8905::f03c:93ff:fe9d:25ea, 2400:8905::f03c:93ff:fe9d:b086', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'it', + id: 'it-mil', + label: 'Milan, IT', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.192.19, 172.232.192.18, 172.232.192.16, 172.232.192.20, 172.232.192.24, 172.232.192.21, 172.232.192.22, 172.232.192.17, 172.232.192.15, 172.232.192.23', + ipv6: + '2600:3c0b::f03c:93ff:feba:d513, 2600:3c0b::f03c:93ff:feba:d5c3, 2600:3c0b::f03c:93ff:feba:d597, 2600:3c0b::f03c:93ff:feba:d5fb, 2600:3c0b::f03c:93ff:feba:d51f, 2600:3c0b::f03c:93ff:feba:d58e, 2600:3c0b::f03c:93ff:feba:d5d5, 2600:3c0b::f03c:93ff:feba:d534, 2600:3c0b::f03c:93ff:feba:d57c, 2600:3c0b::f03c:93ff:feba:d529', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-mia', + label: 'Miami, FL', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.160.34, 172.233.160.27, 172.233.160.30, 172.233.160.29, 172.233.160.32, 172.233.160.28, 172.233.160.33, 172.233.160.26, 172.233.160.25, 172.233.160.31', + ipv6: + '2a01:7e04::f03c:93ff:fead:d31f, 2a01:7e04::f03c:93ff:fead:d37f, 2a01:7e04::f03c:93ff:fead:d30c, 2a01:7e04::f03c:93ff:fead:d318, 2a01:7e04::f03c:93ff:fead:d316, 2a01:7e04::f03c:93ff:fead:d339, 2a01:7e04::f03c:93ff:fead:d367, 2a01:7e04::f03c:93ff:fead:d395, 2a01:7e04::f03c:93ff:fead:d3d0, 2a01:7e04::f03c:93ff:fead:d38e', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'id', + id: 'id-cgk', + label: 'Jakarta, ID', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.224.23, 172.232.224.32, 172.232.224.26, 172.232.224.27, 172.232.224.21, 172.232.224.24, 172.232.224.22, 172.232.224.20, 172.232.224.31, 172.232.224.28', + ipv6: + '2600:3c0c::f03c:93ff:feed:a90b, 2600:3c0c::f03c:93ff:feed:a9a5, 2600:3c0c::f03c:93ff:feed:a935, 2600:3c0c::f03c:93ff:feed:a930, 2600:3c0c::f03c:93ff:feed:a95c, 2600:3c0c::f03c:93ff:feed:a9ad, 2600:3c0c::f03c:93ff:feed:a9f2, 2600:3c0c::f03c:93ff:feed:a9ff, 2600:3c0c::f03c:93ff:feed:a9c8, 2600:3c0c::f03c:93ff:feed:a96b', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-lax', + label: 'Los Angeles, CA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.128.45, 172.233.128.38, 172.233.128.53, 172.233.128.37, 172.233.128.34, 172.233.128.36, 172.233.128.33, 172.233.128.39, 172.233.128.43, 172.233.128.44', + ipv6: + '2a01:7e03::f03c:93ff:feb1:b789, 2a01:7e03::f03c:93ff:feb1:b717, 2a01:7e03::f03c:93ff:feb1:b707, 2a01:7e03::f03c:93ff:feb1:b7ab, 2a01:7e03::f03c:93ff:feb1:b7e2, 2a01:7e03::f03c:93ff:feb1:b709, 2a01:7e03::f03c:93ff:feb1:b7a6, 2a01:7e03::f03c:93ff:feb1:b750, 2a01:7e03::f03c:93ff:feb1:b76e, 2a01:7e03::f03c:93ff:feb1:b7a2', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'us', + id: 'us-central', + label: 'Dallas, TX', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '72.14.179.5, 72.14.188.5, 173.255.199.5, 66.228.53.5, 96.126.122.5, 96.126.124.5, 96.126.127.5, 198.58.107.5, 198.58.111.5, 23.239.24.5', + ipv6: + '2600:3c00::2, 2600:3c00::9, 2600:3c00::7, 2600:3c00::5, 2600:3c00::3, 2600:3c00::8, 2600:3c00::6, 2600:3c00::4, 2600:3c00::c, 2600:3c00::b', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'us', + id: 'us-west', + label: 'Fremont, CA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '173.230.145.5, 173.230.147.5, 173.230.155.5, 173.255.212.5, 173.255.219.5, 173.255.241.5, 173.255.243.5, 173.255.244.5, 74.207.241.5, 74.207.242.5', + ipv6: + '2600:3c01::2, 2600:3c01::9, 2600:3c01::5, 2600:3c01::7, 2600:3c01::3, 2600:3c01::8, 2600:3c01::4, 2600:3c01::b, 2600:3c01::c, 2600:3c01::6', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'us', + id: 'us-southeast', + label: 'Atlanta, GA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '74.207.231.5, 173.230.128.5, 173.230.129.5, 173.230.136.5, 173.230.140.5, 66.228.59.5, 66.228.62.5, 50.116.35.5, 50.116.41.5, 23.239.18.5', + ipv6: + '2600:3c02::3, 2600:3c02::5, 2600:3c02::4, 2600:3c02::6, 2600:3c02::c, 2600:3c02::7, 2600:3c02::2, 2600:3c02::9, 2600:3c02::8, 2600:3c02::b', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'us', + id: 'us-east', + label: 'Newark, NJ', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '66.228.42.5, 96.126.106.5, 50.116.53.5, 50.116.58.5, 50.116.61.5, 50.116.62.5, 66.175.211.5, 97.107.133.4, 207.192.69.4, 207.192.69.5', + ipv6: + '2600:3c03::7, 2600:3c03::4, 2600:3c03::9, 2600:3c03::6, 2600:3c03::3, 2600:3c03::c, 2600:3c03::5, 2600:3c03::b, 2600:3c03::2, 2600:3c03::8', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'gb', + id: 'eu-west', + label: 'London, UK', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '178.79.182.5, 176.58.107.5, 176.58.116.5, 176.58.121.5, 151.236.220.5, 212.71.252.5, 212.71.253.5, 109.74.192.20, 109.74.193.20, 109.74.194.20', + ipv6: + '2a01:7e00::9, 2a01:7e00::3, 2a01:7e00::c, 2a01:7e00::5, 2a01:7e00::6, 2a01:7e00::8, 2a01:7e00::b, 2a01:7e00::4, 2a01:7e00::7, 2a01:7e00::2', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '139.162.11.5, 139.162.13.5, 139.162.14.5, 139.162.15.5, 139.162.16.5, 139.162.21.5, 139.162.27.5, 103.3.60.18, 103.3.60.19, 103.3.60.20', + ipv6: + '2400:8901::5, 2400:8901::4, 2400:8901::b, 2400:8901::3, 2400:8901::9, 2400:8901::2, 2400:8901::8, 2400:8901::7, 2400:8901::c, 2400:8901::6', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'de', + id: 'eu-central', + label: 'Frankfurt, DE', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '139.162.130.5, 139.162.131.5, 139.162.132.5, 139.162.133.5, 139.162.134.5, 139.162.135.5, 139.162.136.5, 139.162.137.5, 139.162.138.5, 139.162.139.5', + ipv6: + '2a01:7e01::5, 2a01:7e01::9, 2a01:7e01::7, 2a01:7e01::c, 2a01:7e01::2, 2a01:7e01::4, 2a01:7e01::3, 2a01:7e01::6, 2a01:7e01::b, 2a01:7e01::8', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'jp', + id: 'ap-northeast', + label: 'Tokyo, JP', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '139.162.66.5, 139.162.67.5, 139.162.68.5, 139.162.69.5, 139.162.70.5, 139.162.71.5, 139.162.72.5, 139.162.73.5, 139.162.74.5, 139.162.75.5', + ipv6: + '2400:8902::3, 2400:8902::6, 2400:8902::c, 2400:8902::4, 2400:8902::2, 2400:8902::8, 2400:8902::7, 2400:8902::5, 2400:8902::b, 2400:8902::9', + }, + site_type: 'core', + status: 'ok', + }, +]; diff --git a/packages/manager/src/assets/icons/ResizeWindow.svg b/packages/manager/src/assets/icons/ResizeWindow.svg new file mode 100644 index 00000000000..c9ea7c2201a --- /dev/null +++ b/packages/manager/src/assets/icons/ResizeWindow.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/logo/akamai-wave.svg b/packages/manager/src/assets/logo/akamai-wave.svg new file mode 100644 index 00000000000..423f77081c8 --- /dev/null +++ b/packages/manager/src/assets/logo/akamai-wave.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx index c3c8d41ebf4..fa9ed19f713 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx @@ -1,4 +1,4 @@ -import { Theme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -18,10 +18,14 @@ import { useAccountUsers } from 'src/queries/account/users'; import { useProfile, useSSHKeysQuery } from 'src/queries/profile/profile'; import { truncateAndJoinList } from 'src/utilities/stringUtils'; +import { Avatar } from '../Avatar/Avatar'; import { GravatarByEmail } from '../GravatarByEmail'; +import { GravatarOrAvatar } from '../GravatarOrAvatar'; import { PaginationFooter } from '../PaginationFooter/PaginationFooter'; import { TableRowLoading } from '../TableRowLoading/TableRowLoading'; +import type { Theme } from '@mui/material/styles'; + export const MAX_SSH_KEYS_DISPLAY = 25; const useStyles = makeStyles()((theme: Theme) => ({ @@ -57,6 +61,7 @@ interface Props { const UserSSHKeyPanel = (props: Props) => { const { classes } = useStyles(); + const theme = useTheme(); const { authorizedUsers, disabled, setAuthorizedUsers } = props; const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState( @@ -145,9 +150,14 @@ const UserSSHKeyPanel = (props: Props) => {
- + } + avatar={} /> {profile.username}
@@ -177,7 +187,25 @@ const UserSSHKeyPanel = (props: Props) => {
- + + } + gravatar={ + + } + /> {user.username}
diff --git a/packages/manager/src/components/AreaChart/utils.test.ts b/packages/manager/src/components/AreaChart/utils.test.ts index f52f176ead7..2c513baef42 100644 --- a/packages/manager/src/components/AreaChart/utils.test.ts +++ b/packages/manager/src/components/AreaChart/utils.test.ts @@ -14,7 +14,7 @@ const timestamp = 1704204000000; describe('getAccessibleTimestamp', () => { it('should return the time in a format like 10/14/2023, 9:30 AM', () => { expect(getAccessibleTimestamp(timestamp, 'America/New_York')).toBe( - '1/2/2024, 9:00\u202fAM' + '1/2/2024, 9:00 AM' ); }); }); @@ -22,7 +22,7 @@ describe('getAccessibleTimestamp', () => { describe('tooltipLabelFormatter', () => { it('should return the time in a format like October 14, 2023, 9:30 AM', () => { expect(tooltipLabelFormatter(timestamp, 'America/New_York')).toBe( - 'Jan 2, 2024, 9:00\u202fAM' + 'Jan 2, 2024, 9:00 AM' ); }); }); diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index e8644fbce4d..de989e277c0 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -4,7 +4,7 @@ import MuiAutocomplete from '@mui/material/Autocomplete'; import React from 'react'; import { Box } from 'src/components/Box'; -import { TextField, TextFieldProps } from 'src/components/TextField'; +import { TextField } from 'src/components/TextField'; import { CircleProgress } from '../CircleProgress'; import { InputAdornment } from '../InputAdornment'; @@ -15,6 +15,7 @@ import { } from './Autocomplete.styles'; import type { AutocompleteProps } from '@mui/material/Autocomplete'; +import type { TextFieldProps } from 'src/components/TextField'; export interface EnhancedAutocompleteProps< T extends { label: string }, @@ -25,6 +26,8 @@ export interface EnhancedAutocompleteProps< AutocompleteProps, 'renderInput' > { + /** Removes "select all" option for multiselect */ + disableSelectAll?: boolean; /** Provides a hint with error styling to assist users. */ errorText?: string; /** Provides a hint with normal styling to assist users. */ @@ -38,8 +41,6 @@ export interface EnhancedAutocompleteProps< placeholder?: string; /** Label for the "select all" option. */ selectAllLabel?: string; - /** Removes "select all" option for mutliselect */ - disableSelectAll?: boolean; textFieldProps?: Partial; } @@ -71,6 +72,7 @@ export const Autocomplete = < clearOnBlur, defaultValue, disablePortal = true, + disableSelectAll = false, errorText = '', helperText, label, @@ -88,7 +90,6 @@ export const Autocomplete = < selectAllLabel = '', textFieldProps, value, - disableSelectAll = false, ...rest } = props; @@ -103,6 +104,11 @@ export const Autocomplete = < return ( 0 + ? optionsWithSelectAll + : options + } renderInput={(params) => ( You have no options to choose from} onBlur={onBlur} - options={ - multiple && !disableSelectAll && options.length > 0 - ? optionsWithSelectAll - : options - } popupIcon={} value={value} {...rest} diff --git a/packages/manager/src/components/Avatar/Avatar.stories.tsx b/packages/manager/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 00000000000..9817951902b --- /dev/null +++ b/packages/manager/src/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; + +import { Avatar } from 'src/components/Avatar/Avatar'; + +import type { Meta, StoryObj } from '@storybook/react'; +import type { AvatarProps } from 'src/components/Avatar/Avatar'; + +export const Default: StoryObj = { + render: (args) => , +}; + +export const System: StoryObj = { + render: (args) => , +}; + +const meta: Meta = { + args: { + color: '#0174bc', + height: 88, + sx: {}, + username: 'MyUsername', + width: 88, + }, + component: Avatar, + title: 'Components/Avatar', +}; +export default meta; diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx new file mode 100644 index 00000000000..e1c553d2c15 --- /dev/null +++ b/packages/manager/src/components/Avatar/Avatar.test.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; + +import { profileFactory } from 'src/factories/profile'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Avatar } from './Avatar'; + +import type { AvatarProps } from './Avatar'; + +const mockProps: AvatarProps = {}; + +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); + return { + ...actual, + useProfile: queryMocks.useProfile, + }; +}); + +describe('Avatar', () => { + it('should render the first letter of a username from /profile with default background color', () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ username: 'my-user' }), + }); + const { getByTestId } = renderWithTheme(); + const avatar = getByTestId('avatar'); + const avatarStyles = getComputedStyle(avatar); + + expect(getByTestId('avatar-letter')).toHaveTextContent('M'); + expect(avatarStyles.backgroundColor).toBe('rgb(1, 116, 188)'); // theme.color.primary.dark (#0174bc) + }); + + it('should render a background color from props', () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ username: 'my-user' }), + }); + + const { getByTestId } = renderWithTheme( + + ); + const avatar = getByTestId('avatar'); + const avatarText = getByTestId('avatar-letter'); + const avatarStyles = getComputedStyle(avatar); + const avatarTextStyles = getComputedStyle(avatarText); + + // Confirm background color contrasts with text color. + expect(avatarStyles.backgroundColor).toBe('rgb(0, 0, 0)'); // black + expect(avatarTextStyles.color).toBe('rgb(255, 255, 255)'); // white + }); + + it('should render the first letter of username from props', async () => { + const { getByTestId } = renderWithTheme( + + ); + + expect(getByTestId('avatar-letter')).toHaveTextContent('T'); + }); + + it('should render an svg instead of first letter for system users', async () => { + const systemUsernames = ['Linode', 'lke-service-account-123']; + + systemUsernames.forEach((username, i) => { + const { getAllByRole, queryByTestId } = renderWithTheme( + + ); + expect(getAllByRole('img')[i]).toBeVisible(); + expect(queryByTestId('avatar-letter')).toBe(null); + }); + }); +}); diff --git a/packages/manager/src/components/Avatar/Avatar.tsx b/packages/manager/src/components/Avatar/Avatar.tsx new file mode 100644 index 00000000000..968ec8b5834 --- /dev/null +++ b/packages/manager/src/components/Avatar/Avatar.tsx @@ -0,0 +1,96 @@ +import { Typography, useTheme } from '@mui/material'; +import { default as _Avatar } from '@mui/material/Avatar'; +import * as React from 'react'; + +import AkamaiWave from 'src/assets/logo/akamai-wave.svg'; +import { usePreferences } from 'src/queries/profile/preferences'; +import { useProfile } from 'src/queries/profile/profile'; + +import type { SxProps } from '@mui/material'; + +export const DEFAULT_AVATAR_SIZE = 28; + +export interface AvatarProps { + /** + * Optional background color to override the color set in user preferences + * */ + color?: string; + /** + * Optional height + * @default 28px + * */ + height?: number; + /** + * Optional styles + * */ + sx?: SxProps; + /** + * Optional username to override the profile username; will display the first letter + * */ + username?: string; + /** + * Optional width + * @default 28px + * */ + width?: number; +} + +/** + * The Avatar component displays the first letter of a username on a solid background color. + * For system avatars associated with Akamai-generated events, an Akamai logo is displayed in place of a letter. + */ +export const Avatar = (props: AvatarProps) => { + const { + color, + height = DEFAULT_AVATAR_SIZE, + sx, + username, + width = DEFAULT_AVATAR_SIZE, + } = props; + + const theme = useTheme(); + + const { data: preferences } = usePreferences(); + const { data: profile } = useProfile(); + + const _username = username ?? profile?.username ?? ''; + const isAkamai = + _username === 'Linode' || _username.startsWith('lke-service-account'); + + const savedAvatarColor = + isAkamai || !preferences?.avatarColor + ? theme.palette.primary.dark + : preferences.avatarColor; + const avatarLetter = _username[0]?.toUpperCase() ?? ''; + + return ( + <_Avatar + sx={{ + '& svg': { + height: width / 2, + width: width / 2, + }, + bgcolor: color ?? savedAvatarColor, + height, + width, + ...sx, + }} + alt={`Avatar for user ${username ?? profile?.email ?? ''}`} + data-testid="avatar" + > + {isAkamai ? ( + + ) : ( + + {avatarLetter} + + )} + + ); +}; diff --git a/packages/manager/src/components/GravatarForProxy.tsx b/packages/manager/src/components/AvatarForProxy.tsx similarity index 93% rename from packages/manager/src/components/GravatarForProxy.tsx rename to packages/manager/src/components/AvatarForProxy.tsx index 5bbef1d15b1..16840219df4 100644 --- a/packages/manager/src/components/GravatarForProxy.tsx +++ b/packages/manager/src/components/AvatarForProxy.tsx @@ -9,7 +9,7 @@ interface Props { width?: number; } -export const GravatarForProxy = ({ height = 34, width = 34 }: Props) => { +export const AvatarForProxy = ({ height = 34, width = 34 }: Props) => { return ( ({ diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.stories.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.stories.tsx new file mode 100644 index 00000000000..f0593bcad69 --- /dev/null +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.stories.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { Typography } from 'src/components/Typography'; + +import { CheckoutSummary } from './CheckoutSummary'; + +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +const Item = ({ children }: { children?: React.ReactNode }) => ( + + {children} + +); + +const defaultArgs = { + displaySections: [ + { title: 'Debian 11' }, + { details: '$36/month', title: 'Dedicated 4GB' }, + ], + heading: 'Checkout Summary', +}; + +const meta: Meta = { + component: CheckoutSummary, + decorators: [ + (Story: StoryFn) => ( +
+ +
+ ), + ], + title: 'Components/CheckoutSummary', +}; + +export default meta; + +export const Default: Story = { + args: defaultArgs, +}; + +export const WithAgreement: Story = { + args: { + ...defaultArgs, + agreement: Agreement item can go here!, + }, +}; + +export const WithChildren: Story = { + args: { + ...defaultArgs, + children: Child items can go here!, + }, +}; diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx index ada8a14e6bb..a272a3536c5 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx @@ -1,6 +1,6 @@ import { useTheme } from '@mui/material'; +import { styled } from '@mui/material/styles'; import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; -import { Theme, styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -8,10 +8,24 @@ import { Paper } from '../Paper'; import { Typography } from '../Typography'; import { SummaryItem } from './SummaryItem'; -interface Props { +import type { Theme } from '@mui/material/styles'; + +interface CheckoutSummaryProps { + /** + * JSX element to be displayed as an agreement section. + */ agreement?: JSX.Element; + /** + * JSX element for additional content to be rendered within the component. + */ children?: JSX.Element | null; + /** + * The sections to be displayed in the `CheckoutSumamry` + */ displaySections: SummaryItem[]; + /** + * The heading text to be displayed in the `CheckoutSummary`. + */ heading: string; } @@ -22,7 +36,7 @@ export interface SummaryItem { title?: string; } -export const CheckoutSummary = (props: Props) => { +export const CheckoutSummary = (props: CheckoutSummaryProps) => { const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); diff --git a/packages/manager/src/components/Code/Code.tsx b/packages/manager/src/components/Code/Code.tsx index 0eed0031c7e..185fd296175 100644 --- a/packages/manager/src/components/Code/Code.tsx +++ b/packages/manager/src/components/Code/Code.tsx @@ -13,6 +13,7 @@ export const Code = (props: Props) => { const StyledSpan = styled('span')(({ theme }) => ({ backgroundColor: theme.color.grey5, + borderRadius: theme.spacing(0.3), color: theme.color.black, fontFamily: '"Ubuntu Mono", monospace, sans-serif', margin: '0 2px', diff --git a/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx b/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx new file mode 100644 index 00000000000..aba8758b071 --- /dev/null +++ b/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { ColorPicker } from 'src/components/ColorPicker/ColorPicker'; + +import type { ColorPickerProps } from './ColorPicker'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + args: { + defaultColor: '#0174bc', + label: 'Label for color picker', + onChange: () => undefined, + }, + component: ColorPicker, + title: 'Components/ColorPicker', +}; + +export const Default: StoryObj = { + render: (args) => { + return ; + }, +}; + +export default meta; diff --git a/packages/manager/src/components/ColorPicker/ColorPicker.tsx b/packages/manager/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 00000000000..6b611c91ee7 --- /dev/null +++ b/packages/manager/src/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,54 @@ +import { useTheme } from '@mui/material'; +import React, { useState } from 'react'; + +import type { CSSProperties } from 'react'; + +export interface ColorPickerProps { + /** + * Optional color to specify as a default + * */ + defaultColor?: string; + /** + * Optional styles for the input element + * */ + inputStyles?: CSSProperties; + /** + * Visually hidden label to semantically describe the color picker for accessibility + * */ + label: string; + /** + * Function to update the color based on user selection + * */ + onChange: (color: string) => void; +} + +/** + * The ColorPicker component serves as a wrapper for the native HTML input color picker. + */ +export const ColorPicker = (props: ColorPickerProps) => { + const { defaultColor, inputStyles, label, onChange } = props; + + const theme = useTheme(); + const [color, setColor] = useState( + defaultColor ?? theme.palette.primary.dark + ); + + return ( + <> + + { + setColor(e.target.value); + onChange(e.target.value); + }} + color={color} + id="color-picker" + style={inputStyles} + type="color" + value={color} + /> + + ); +}; diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx index ed83f09279e..1a244e89044 100644 --- a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx +++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx @@ -3,9 +3,11 @@ import copy from 'copy-to-clipboard'; import * as React from 'react'; import FileCopy from 'src/assets/icons/copy.svg'; -import { Tooltip, TooltipProps } from 'src/components/Tooltip'; +import { Tooltip } from 'src/components/Tooltip'; import { omittedProps } from 'src/utilities/omittedProps'; +import type { TooltipProps } from 'src/components/Tooltip'; + export interface CopyTooltipProps { /** * Additional classes to be applied to the root element. @@ -62,7 +64,7 @@ export const CopyTooltip = (props: CopyTooltipProps) => { }; const CopyButton = ( - { {...props} > {copyableText ? text : } - + ); if (disabled) { @@ -91,8 +93,8 @@ export const CopyTooltip = (props: CopyTooltipProps) => { ); }; -const StyledCopyButton = styled('button', { - label: 'StyledCopyButton', +export const StyledIconButton = styled('button', { + label: 'StyledIconButton', shouldForwardProp: omittedProps(['copyableText', 'text', 'onClickCallback']), })>(({ theme, ...props }) => ({ '& svg': { diff --git a/packages/manager/src/components/CopyableAndDownloadableTextField.tsx b/packages/manager/src/components/CopyableAndDownloadableTextField.tsx deleted file mode 100644 index 17f89731429..00000000000 --- a/packages/manager/src/components/CopyableAndDownloadableTextField.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Theme } from '@mui/material/styles'; -import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; - -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { DownloadTooltip } from 'src/components/DownloadTooltip'; -import { TextField, TextFieldProps } from 'src/components/TextField'; - -const useStyles = makeStyles()((theme: Theme) => ({ - copyIcon: { - '& svg': { - height: 14, - top: 1, - }, - marginRight: theme.spacing(0.5), - }, - removeDisabledStyles: { - '&.Mui-disabled': { - background: theme.bg.main, - borderColor: theme.name === 'light' ? '#ccc' : '#222', - color: theme.name === 'light' ? 'inherit' : '#fff !important', - opacity: 1, - }, - }, -})); - -interface Props extends TextFieldProps { - className?: string; - fileName?: string; - hideIcon?: boolean; -} - -export const CopyableAndDownloadableTextField = (props: Props) => { - const { classes } = useStyles(); - const { className, hideIcon, value, ...restProps } = props; - - const fileName = props.fileName ?? snakeCase(props.label); - - return ( - - - - - ), - }} - className={`${className} ${classes.removeDisabledStyles}`} - data-qa-copy-tooltip - disabled - /> - ); -}; - -const snakeCase = (str: string | undefined): string => { - if (!str) { - return ''; - } - - return str - .replace(/\W+/g, ' ') - .split(/ |\B(?=[A-Z])/) - .map((word) => word.toLowerCase()) - .join('_'); -}; diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.stories.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.stories.tsx new file mode 100644 index 00000000000..2e12633f855 --- /dev/null +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.stories.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { CopyableTextField } from './CopyableTextField'; + +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; + +const meta: Meta = { + args: { + label: 'Label', + value: 'Text to copy', + }, + component: CopyableTextField, + decorators: [ + (Story: StoryFn) => ( +
+ +
+ ), + ], + title: 'Components/Input/CopyableTextField', +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx index cb86b4b93b0..1f2da7ffcd7 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx @@ -1,11 +1,14 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { - CopyTooltip, - CopyTooltipProps, -} from 'src/components/CopyTooltip/CopyTooltip'; -import { TextField, TextFieldProps } from 'src/components/TextField'; +import { Box } from 'src/components/Box'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { TextField } from 'src/components/TextField'; + +import { DownloadTooltip } from '../DownloadTooltip'; + +import type { CopyTooltipProps } from 'src/components/CopyTooltip/CopyTooltip'; +import type { TextFieldProps } from 'src/components/TextField'; interface CopyableTextFieldProps extends TextFieldProps { /** @@ -13,23 +16,34 @@ interface CopyableTextFieldProps extends TextFieldProps { */ CopyTooltipProps?: Partial; className?: string; - hideIcon?: boolean; + hideIcons?: boolean; + showDownloadIcon?: boolean; } export const CopyableTextField = (props: CopyableTextFieldProps) => { - const { CopyTooltipProps, className, hideIcon, value, ...restProps } = props; + const { + CopyTooltipProps, + className, + hideIcons, + showDownloadIcon, + value, + ...restProps + } = props; + + const fileName = showDownloadIcon ? snakeCase(props.label) : ''; return ( + endAdornment: hideIcons ? undefined : ( + + {showDownloadIcon && ( + + )} + + ), }} className={`${className} copy removeDisabledStyles`} @@ -44,13 +58,6 @@ const StyledTextField = styled(TextField)(({ theme }) => ({ backgroundColor: theme.name === 'dark' ? '#2f3236' : '#f4f4f4', opacity: 1, }, - '.copyIcon': { - '& svg': { - height: 14, - top: 1, - }, - marginRight: theme.spacing(0.5), - }, '.removeDisabledStyles': { '& .MuiInput-input': { WebkitTextFillColor: 'unset !important', @@ -67,3 +74,27 @@ const StyledTextField = styled(TextField)(({ theme }) => ({ }, }, })); + +const StyledIconBox = styled(Box)(({ theme }) => ({ + '& button svg': { + color: theme.color.grey1, + height: 14, + top: 1, + transition: theme.transitions.create(['color']), + }, + '& button svg:hover': { + color: theme.palette.primary.main, + }, + '&:last-child': { + marginRight: theme.spacing(0.5), + }, + display: 'flex', +})); + +const snakeCase = (str: string): string => { + return str + .replace(/\W+/g, ' ') + .split(/ |\B(?=[A-Z])/) + .map((word) => word.toLowerCase()) + .join('_'); +}; diff --git a/packages/manager/src/components/Currency/Currency.test.tsx b/packages/manager/src/components/Currency/Currency.test.tsx index 0b46d8a8410..b5cfbd02fe0 100644 --- a/packages/manager/src/components/Currency/Currency.test.tsx +++ b/packages/manager/src/components/Currency/Currency.test.tsx @@ -8,69 +8,79 @@ import { Currency } from './Currency'; describe('Currency Component', () => { it('displays a given quantity in USD', () => { const { getByText, rerender } = renderWithTheme(); - getByText('$5.00'); + expect(getByText('$5.00')).toBeVisible(); rerender(); - getByText('$99.99'); + expect(getByText('$99.99')).toBeVisible(); rerender(); - getByText('$0.00'); + expect(getByText('$0.00')).toBeVisible(); }); it('handles negative quantities', () => { const { getByText, rerender } = renderWithTheme(); - getByText('-$5.00'); + expect(getByText('-$5.00')).toBeVisible(); rerender(); - getByText('-$99.99'); + expect(getByText('-$99.99')).toBeVisible(); rerender(); - getByText('-$0.01'); + expect(getByText('-$0.01')).toBeVisible(); }); it('wraps in parentheses', () => { const { getByText, rerender } = renderWithTheme( ); - getByText('($5.00)'); + expect(getByText('($5.00)')).toBeVisible(); rerender(); - getByText('-($5.00)'); + expect(getByText('-($5.00)')).toBeVisible(); rerender(); - getByText('($0.00)'); + expect(getByText('($0.00)')).toBeVisible(); }); it('handles custom number of decimal places', () => { const { getByText, rerender } = renderWithTheme( ); - getByText('$5.000'); + expect(getByText('$5.000')).toBeVisible(); rerender(); - getByText('$99.999'); + expect(getByText('$99.999')).toBeVisible(); rerender(); - getByText('-$5.000'); + expect(getByText('-$5.000')).toBeVisible(); }); it('handles custom default values', () => { const { getByText } = renderWithTheme( ); - getByText(`$${UNKNOWN_PRICE}`); + expect(getByText(`$${UNKNOWN_PRICE}`)).toBeVisible(); }); it('groups by comma', () => { const { getByText, rerender } = renderWithTheme( ); - getByText('$1,000.00'); + expect(getByText('$1,000.00')).toBeVisible(); rerender(); - getByText('$100,000.00'); + expect(getByText('$100,000.00')).toBeVisible(); }); it('displays --.-- when passed in as a quantity', () => { const { getByText } = renderWithTheme(); - getByText('$--.--'); + expect(getByText('$--.--')).toBeVisible(); }); it('applies the passed in data attributes', () => { const { getByTestId } = renderWithTheme( ); - getByTestId('currency-test'); + expect(getByTestId('currency-test')).toBeInTheDocument(); + }); + + it('should display price with default 2 decimal places if decimalPlaces is negative or undefined', () => { + const { getByText, rerender } = renderWithTheme( + + ); + expect(getByText('$99.00')).toBeVisible(); + + rerender(); + expect(getByText('$99.00')).toBeVisible(); }); }); diff --git a/packages/manager/src/components/Currency/Currency.tsx b/packages/manager/src/components/Currency/Currency.tsx index f1ad0259632..5bf4cf71460 100644 --- a/packages/manager/src/components/Currency/Currency.tsx +++ b/packages/manager/src/components/Currency/Currency.tsx @@ -22,11 +22,15 @@ interface CurrencyFormatterProps { } export const Currency = (props: CurrencyFormatterProps) => { - const { dataAttrs, quantity, wrapInParentheses } = props; + const { dataAttrs, decimalPlaces, quantity, wrapInParentheses } = props; + + // Use the default value (2) when decimalPlaces is negative or undefined. + const minimumFractionDigits = + decimalPlaces !== undefined && decimalPlaces >= 0 ? decimalPlaces : 2; const formatter = new Intl.NumberFormat('en-US', { currency: 'USD', - minimumFractionDigits: props.decimalPlaces ?? 2, + minimumFractionDigits, style: 'currency', }); diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index cb5c23eafc7..1db938d8756 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -2,28 +2,22 @@ import Clear from '@mui/icons-material/Clear'; import Search from '@mui/icons-material/Search'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { debounce } from 'throttle-debounce'; import { CircleProgress } from 'src/components/CircleProgress'; import { InputAdornment } from 'src/components/InputAdornment'; -import { TextField, TextFieldProps } from 'src/components/TextField'; +import { TextField } from 'src/components/TextField'; import { IconButton } from '../IconButton'; +import type { TextFieldProps } from 'src/components/TextField'; + export interface DebouncedSearchProps extends TextFieldProps { className?: string; /** * Whether to show a clear button at the end of the input. */ clearable?: boolean; - /** - * Including this prop will disable this field from being self-managed. - * The user must then manage the state of the text field and provide a - * value and change handler. - */ - customValue?: { - onChange: (newValue: string | undefined) => void; - value: string | undefined; - }; /** * Interval in milliseconds of time that passes before search queries are accepted. * @default 400 @@ -39,8 +33,9 @@ export interface DebouncedSearchProps extends TextFieldProps { /** * Function to perform when searching for query */ - onSearch?: (query: string) => void; + onSearch: (query: string) => void; placeholder?: string; + value: string; } export const DebouncedSearchTextField = React.memo( @@ -49,7 +44,6 @@ export const DebouncedSearchTextField = React.memo( InputProps, className, clearable, - customValue, debounceTime, defaultValue, hideLabel, @@ -57,25 +51,28 @@ export const DebouncedSearchTextField = React.memo( label, onSearch, placeholder, + value, ...restOfTextFieldProps } = props; - // Manage the textfield state if customValue is not provided - const managedValue = React.useState(); - const [textFieldValue, setTextFieldValue] = customValue - ? [customValue.value, customValue.onChange] - : managedValue; + const [textFieldValue, setTextFieldValue] = React.useState(''); + + // Memoize the debounced onChange handler to prevent unnecessary re-creations. + const debouncedOnChange = React.useMemo( + () => + debounce(debounceTime ?? 400, (e) => { + onSearch(e.target.value); + setTextFieldValue(e.target.value); + }), + [debounceTime, onSearch] + ); + // Synchronize the internal state with the prop value when the value prop changes. React.useEffect(() => { - if (textFieldValue != undefined) { - const timeout = setTimeout( - () => onSearch && onSearch(textFieldValue), - debounceTime !== undefined ? debounceTime : 400 - ); - return () => clearTimeout(timeout); + if (value && value !== textFieldValue) { + setTextFieldValue(value); } - return undefined; - }, [debounceTime, onSearch, textFieldValue]); + }, [value]); return ( { + setTextFieldValue(''); + onSearch(''); + }} aria-label="Clear" - onClick={() => setTextFieldValue('')} size="small" > setTextFieldValue(e.target.value)} + onChange={debouncedOnChange} placeholder={placeholder || 'Filter by query'} value={textFieldValue} {...restOfTextFieldProps} diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx index fc430e82dd7..82e9a136b18 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx @@ -12,6 +12,7 @@ const props = { isSearching: false, label: labelVal, onSearch: vi.fn(), + value: '', }; describe('Debounced Search Text Field', () => { diff --git a/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx b/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx new file mode 100644 index 00000000000..1c3aebab2b3 --- /dev/null +++ b/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import { DisplayPrice } from './DisplayPrice'; + +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +const meta: Meta = { + component: DisplayPrice, + decorators: [ + (Story: StoryFn) => ( +
+ +
+ ), + ], + title: 'Components/DisplayPrice', +}; + +export default meta; + +export const Default: Story = { + args: { + price: 99, + }, + render: (args) => , +}; + +export const WithFontSize: Story = { + args: { + ...Default.args, + fontSize: '2rem', + }, +}; + +export const WithDecimalPlaces: Story = { + args: { + ...Default.args, + decimalPlaces: 1, + }, +}; + +export const WithInterval: Story = { + args: { + ...Default.args, + interval: 'mo', + }, +}; diff --git a/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx b/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx index 07bacae6467..134d880c231 100644 --- a/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx +++ b/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx @@ -5,9 +5,24 @@ import { Currency } from 'src/components/Currency'; import { Typography } from 'src/components/Typography'; export interface DisplayPriceProps { + /** + * The number of decimal places to display in the price. + */ decimalPlaces?: number; + /** + * The font size of the displayed price. + */ fontSize?: string; + /** + * The format interval to use for price formatting. + * @example 'mo' + * @example 'month' + * @example 'year' + */ interval?: string; + /** + * The price to display. + */ price: '--.--' | number; } diff --git a/packages/manager/src/components/DownloadTooltip.tsx b/packages/manager/src/components/DownloadTooltip.tsx index 1b8a543f3ed..ec1a8abddf1 100644 --- a/packages/manager/src/components/DownloadTooltip.tsx +++ b/packages/manager/src/components/DownloadTooltip.tsx @@ -1,8 +1,7 @@ -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; import FileDownload from 'src/assets/icons/download.svg'; +import { StyledIconButton } from 'src/components/CopyTooltip/CopyTooltip'; import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; import { downloadFile } from 'src/utilities/downloadFile'; @@ -31,41 +30,7 @@ interface Props { text: string; } -const useStyles = makeStyles()((theme: Theme) => ({ - displayText: { - color: theme.textColors.linkActiveLight, - marginLeft: 6, - }, - flex: { - display: 'flex', - width: 'auto !important', - }, - root: { - '& svg': { - color: theme.color.grey1, - height: 20, - margin: 0, - position: 'relative', - transition: theme.transitions.create(['color']), - width: 20, - }, - '&:hover': { - backgroundColor: theme.color.white, - }, - backgroundColor: 'transparent', - border: 'none', - borderRadius: 4, - color: theme.color.grey1, - cursor: 'pointer', - padding: 4, - position: 'relative', - transition: theme.transitions.create(['background-color']), - }, -})); - export const DownloadTooltip = (props: Props) => { - const { classes, cx } = useStyles(); - const { className, displayText, fileName, onClickCallback, text } = props; const handleIconClick = () => { @@ -77,24 +42,16 @@ export const DownloadTooltip = (props: Props) => { return ( - + {displayText && {displayText}} + ); }; diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinks.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinks.tsx index 6d3a479f0d2..8e1bdd87c3d 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinks.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinks.tsx @@ -8,15 +8,18 @@ import { getLinkOnClick } from 'src/utilities/emptyStateLandingUtils'; import type { ResourcesLinks } from './ResourcesLinksTypes'; export const ResourceLinks = (props: ResourcesLinks) => { - const { linkAnalyticsEvent, links } = props; + const { linkAnalyticsEvent, links, onClick } = props; return ( {links.map((linkData) => ( { + getLinkOnClick(linkAnalyticsEvent, linkData.text); + onClick?.(); + }} external={linkData.external} - onClick={getLinkOnClick(linkAnalyticsEvent, linkData.text)} to={linkData.to} > {linkData.text} diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts index 487a34e6ea8..942ff3d8a31 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts @@ -18,6 +18,7 @@ export interface ResourcesHeaders { export interface ResourcesLinks { linkAnalyticsEvent: linkAnalyticsEvent; links: ResourcesLink[]; + onClick?: () => void; } export interface ResourcesLinkSection { diff --git a/packages/manager/src/components/Encryption/constants.tsx b/packages/manager/src/components/Encryption/constants.tsx index 4d93f9f3a8d..4f44f4fd100 100644 --- a/packages/manager/src/components/Encryption/constants.tsx +++ b/packages/manager/src/components/Encryption/constants.tsx @@ -87,8 +87,14 @@ export const BLOCK_STORAGE_ENCRYPTION_GENERAL_DESCRIPTION = ( export const BLOCK_STORAGE_CHOOSE_REGION_COPY = 'Select a region to use Volume encryption.'; +export const BLOCK_STORAGE_ENCRYPTION_UNAVAILABLE_IN_LINODE_REGION_COPY = + "Volume encryption is not available in this Linode's region."; + export const BLOCK_STORAGE_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY = `Volume encryption is not available in the selected region. ${BLOCK_STORAGE_CHOOSE_REGION_COPY}`; +export const BLOCK_STORAGE_CLIENT_LIBRARY_UPDATE_REQUIRED_COPY = + 'This Linode requires a client library update and will need to be rebooted prior to attaching an encrypted volume.'; + // Caveats export const BLOCK_STORAGE_ENCRYPTION_OVERHEAD_CAVEAT = 'Please note encryption overhead may impact your volume IOPS performance negatively. This may compound when multiple encryption-enabled volumes are attached to the same Linode.'; @@ -97,7 +103,7 @@ export const BLOCK_STORAGE_USER_SIDE_ENCRYPTION_CAVEAT = 'User-side encryption on top of encryption-enabled volumes is discouraged at this time, as it could severely impact your volume performance.'; export const BLOCK_STORAGE_ENCRYPTION_SETTING_IMMUTABLE_COPY = - 'The encryption setting cannot be changed after creation.'; + 'The encryption setting cannot be modified after a volume has been created.'; export const BLOCK_STORAGE_CLONING_INHERITANCE_CAVEAT = 'Encryption is inherited from the source volume and cannot be changed when cloning volumes.'; diff --git a/packages/manager/src/components/ErrorMessage.tsx b/packages/manager/src/components/ErrorMessage.tsx index 68917fb0fd3..8bb3871e750 100644 --- a/packages/manager/src/components/ErrorMessage.tsx +++ b/packages/manager/src/components/ErrorMessage.tsx @@ -1,29 +1,32 @@ import React from 'react'; +import { MigrateError } from './MigrateError'; import { SupportTicketGeneralError } from './SupportTicketGeneralError'; import { Typography } from './Typography'; -import type { - EntityType, - FormPayloadValues, -} from 'src/features/Support/SupportTickets/SupportTicketDialog'; +import type { EntityForTicketDetails } from './SupportLink/SupportLink'; +import type { FormPayloadValues } from 'src/features/Support/SupportTickets/SupportTicketDialog'; interface Props { - entityType: EntityType; + entity?: EntityForTicketDetails; formPayloadValues?: FormPayloadValues; message: string; } +export const migrationsDisabledRegex = /migrations are currently disabled/i; export const supportTextRegex = /(open a support ticket|contact Support)/i; export const ErrorMessage = (props: Props) => { - const { entityType, formPayloadValues, message } = props; - const isSupportTicketError = supportTextRegex.test(message); + const { entity, formPayloadValues, message } = props; - if (isSupportTicketError) { + if (migrationsDisabledRegex.test(message)) { + return ; + } + + if (supportTextRegex.test(message)) { return ( diff --git a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx index add07267945..e102a493d0c 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx +++ b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx @@ -5,8 +5,10 @@ import { replaceNewlinesWithLineBreaks } from 'src/utilities/replaceNewlinesWith import { Button } from '../Button/Button'; import { Dialog } from '../Dialog/Dialog'; +import { ErrorMessage } from '../ErrorMessage'; import { LinearProgress } from '../LinearProgress'; import { Link } from '../Link'; +import { Notice } from '../Notice/Notice'; import { Stack } from '../Stack'; import { Typography } from '../Typography'; import { useCreateFirewallFromTemplate } from './useCreateFirewallFromTemplate'; @@ -209,7 +211,9 @@ const ErrorDialogContent = ( return ( An error occurred - {error} + + + diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 67de298235c..5ee12154822 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -1,4 +1,3 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { useFlags as ldUseFlags } from 'launchdarkly-react-client-sdk'; import * as React from 'react'; import { useDispatch } from 'react-redux'; @@ -10,6 +9,7 @@ import { getStorage, setStorage } from 'src/utilities/storage'; import type { FlagSet, Flags } from 'src/featureFlags'; import type { Dispatch } from 'src/hooks/types'; + const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; /** @@ -22,20 +22,46 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclp', label: 'CloudPulse' }, { flag: 'blockStorageEncryption', label: 'Block Storage Encryption (BSE)' }, { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, - { flag: 'eventMessagesV2', label: 'Event Messages V2' }, { flag: 'gecko2', label: 'Gecko' }, { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, - { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, - { flag: 'placementGroups', label: 'Placement Groups' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, { flag: 'dbaasV2', label: 'Databases V2 Beta' }, { flag: 'databaseResize', label: 'Database Resize' }, + { flag: 'apicliDxToolsAdditions', label: 'APICLI DX Tools Additions' }, + { flag: 'apicliButtonCopy', label: 'APICLI Button Copy' }, ]; +const renderFlagItems = ( + flags: Partial, + onCheck: (e: React.ChangeEvent, flag: string) => void +) => { + return options.map((option) => { + const flagValue = flags[option.flag]; + const isChecked = + typeof flagValue === 'object' && 'enabled' in flagValue + ? Boolean(flagValue.enabled) + : Boolean(flagValue); + + return ( +
  • + onCheck(e, option.flag)} + type="checkbox" + /> + {option.label} +
  • + ); + }); +}; + export const FeatureFlagTool = withFeatureFlagProvider(() => { const dispatch: Dispatch = useDispatch(); const flags = useFlags(); @@ -74,42 +100,22 @@ export const FeatureFlagTool = withFeatureFlagProvider(() => { }; return ( - - -

    Feature Flags

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

    Mock Data

    -
    - - - -
    - ); -}; diff --git a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx index 6d7c6d70986..2094e0f7a54 100644 --- a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx +++ b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx @@ -1,30 +1,380 @@ import * as React from 'react'; -const LOCAL_STORAGE_KEY = 'msw'; +import { Tooltip } from 'src/components/Tooltip'; +import { mswDB } from 'src/mocks/indexedDB'; +import { extraMockPresets } from 'src/mocks/presets'; +import { dbSeeders } from 'src/mocks/presets/crud/seeds'; +import { removeSeeds } from 'src/mocks/presets/crud/seeds/utils'; -export const isMSWEnabled = - localStorage.getItem(LOCAL_STORAGE_KEY) === 'enabled'; +import { BaselinePresetOptions } from './components/BaselinePresetOptions'; +import { DevToolSelect } from './components/DevToolSelect'; +import { ExtraPresetOptions } from './components/ExtraPresetOptions'; +import { SeedOptions } from './components/SeedOptions'; +import { + getBaselinePreset, + getExtraPresets, + getExtraPresetsMap, + getSeeders, + getSeedsCountMap, + isMSWEnabled, + saveBaselinePreset, + saveExtraPresets, + saveExtraPresetsMap, + saveMSWEnabled, + saveSeeders, + saveSeedsCountMap, +} from './utils'; -export const setMSWEnabled = (enabled: boolean) => { - localStorage.setItem(LOCAL_STORAGE_KEY, enabled ? 'enabled' : 'disabled'); - window.location.reload(); -}; +import type { + MockPresetBaselineId, + MockPresetCrudId, + MockPresetExtraGroup, + MockPresetExtraId, + MockState, +} from 'src/mocks/types'; + +interface ServiceWorkerSaveState { + hasSaved: boolean; + hasUnsavedChanges: boolean; + mocksCleared?: boolean; +} +/** + * Renders the service worker tool. + */ export const ServiceWorkerTool = () => { + const loadedBaselinePreset = getBaselinePreset(); + const loadedExtraPresets = getExtraPresets(); + const loadedSeeders = getSeeders(dbSeeders); + const loadedSeedsCountMap = getSeedsCountMap(); + const loadedPresetsMap = getExtraPresetsMap(); + const [ + baselinePreset, + setBaselinePreset, + ] = React.useState(loadedBaselinePreset); + const [extraPresets, setExtraPresets] = React.useState( + loadedExtraPresets + ); + const [presetsCountMap, setPresetsCountMap] = React.useState<{ + [key: string]: number; + }>(loadedPresetsMap); + const [seeders, setSeeders] = React.useState(loadedSeeders); + const [seedsCountMap, setSeedsCountMap] = React.useState<{ + [key: string]: number; + }>(loadedSeedsCountMap); + const isCrudPreset = + loadedBaselinePreset === 'baseline:crud' || + baselinePreset === 'baseline:crud'; + + const [saveState, setSaveState] = React.useState({ + hasSaved: false, + hasUnsavedChanges: false, + mocksCleared: false, + }); + + const globalHandlers = { + applyChanges: () => { + // Save base preset, extra presets, and content seeders to local storage. + saveBaselinePreset(baselinePreset); + saveExtraPresets(extraPresets); + saveSeeders(seeders); + saveSeedsCountMap(seedsCountMap); + saveExtraPresetsMap(presetsCountMap); + + const promises = seeders.map((seederId) => { + const seeder = dbSeeders.find((dbSeeder) => dbSeeder.id === seederId); + + return seeder?.seeder({} as MockState); + }); + + Promise.all(promises).then(() => { + setSaveState((prevSaveState) => ({ + ...prevSaveState, + hasSaved: true, + hasUnsavedChanges: false, + })); + }); + + // We only have to reload the window if MSW is already enabled. Otherwise, + // the changes will automatically be picked up next time MSW is enabled. + if (isMSWEnabled) { + window.location.reload(); + } + }, + + discardChanges: () => { + setBaselinePreset(getBaselinePreset()); + setExtraPresets(getExtraPresets()); + setSeeders(getSeeders(dbSeeders)); + setSeedsCountMap(getSeedsCountMap()); + setPresetsCountMap(getExtraPresetsMap()); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: false, + }); + }, + + resetAll: () => { + mswDB.clear('mockState'); + mswDB.clear('seedState'); + seederHandlers.removeAll(); + setBaselinePreset('baseline:preset-mocking'); + setExtraPresets([]); + setPresetsCountMap({}); + saveBaselinePreset('baseline:preset-mocking'); + saveExtraPresets([]); + saveSeeders([]); + saveSeedsCountMap({}); + saveExtraPresetsMap({}); + + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + mocksCleared: true, + }); + }, + + toggleMSW: (e: React.ChangeEvent) => { + saveMSWEnabled(e.target.checked); + window.location.reload(); + }, + }; + + const seederHandlers = { + changeCount: ( + e: React.ChangeEvent, + seederId: MockPresetCrudId + ) => { + setSeedsCountMap({ + ...seedsCountMap, + [seederId]: parseInt(e.target.value, 10), + }); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + removeAll: () => { + // remove all seeds & reset all seed fields to 0 + setSeeders([]); + setSeedsCountMap( + Object.fromEntries(Object.keys(seedsCountMap).map((key) => [key, 0])) + ); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + toggle: async ( + e: React.ChangeEvent, + seederId: MockPresetCrudId + ) => { + const willEnable = e.target.checked; + if (willEnable && !seeders.includes(seederId)) { + setSeeders([...seeders, seederId]); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + } else if (!willEnable && seeders.includes(seederId)) { + setSeeders( + seeders.filter((seeder) => { + return seeder !== seederId; + }) + ); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + await removeSeeds(seederId); + } + }, + }; + + const presetHandlers = { + changeBase: (e: React.ChangeEvent) => { + setBaselinePreset(e.target.value as MockPresetBaselineId); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + changeCount: ( + e: React.ChangeEvent, + presetId: MockPresetBaselineId + ) => { + setPresetsCountMap({ + ...presetsCountMap, + [presetId]: parseInt(e.target.value, 10), + }); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + changeSelect: ( + e: React.ChangeEvent, + groupId: MockPresetExtraGroup['id'] + ) => { + const newPresetId = e.target.value; + + const updatedExtraPresets = extraPresets.filter((presetId) => { + const preset = extraMockPresets.find((p) => p.id === presetId); + + return preset?.group.id !== groupId; + }); + + // Add the new preset if one was selected + if (newPresetId) { + updatedExtraPresets.push(newPresetId); + } + + setExtraPresets(updatedExtraPresets); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + toggle: ( + e: React.ChangeEvent, + presetId: MockPresetExtraId + ) => { + const willEnable = e.target.checked; + if (willEnable && !extraPresets.includes(presetId)) { + setExtraPresets([...extraPresets, presetId]); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + } else if (!willEnable && extraPresets.includes(presetId)) { + setExtraPresets( + extraPresets.filter((handler) => { + return handler !== presetId; + }) + ); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + } + }, + }; + return ( - <> - - Mock Service Worker: - - {isMSWEnabled ? 'Enabled' : 'Disabled'} - - - setMSWEnabled(e.target.checked)} - style={{ margin: 0 }} - type="checkbox" - /> - +
    +
    + API Mocks +
    + +
    +
    +
    + globalHandlers.toggleMSW(e)} + style={{ margin: 0 }} + type="checkbox" + /> + + Enable MSW + +
    +
    + + Base Preset + + presetHandlers.changeBase(e)} + value={baselinePreset} + > + + +
    +
    +
    +
    +
    + Seeds (CRUD preset only) + +
    +
    +
    + +
    +
    +
    +
    +
    Presets
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    ); }; diff --git a/packages/manager/src/dev-tools/components/BaselinePresetOptions.tsx b/packages/manager/src/dev-tools/components/BaselinePresetOptions.tsx new file mode 100644 index 00000000000..f8cca0420fd --- /dev/null +++ b/packages/manager/src/dev-tools/components/BaselinePresetOptions.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { getMockPresetGroups } from 'src/mocks/mockPreset'; +import { baselineMockPresets } from 'src/mocks/presets'; + +/** + * Renders a select with options for baseline presets + */ +export const BaselinePresetOptions = () => { + return getMockPresetGroups(baselineMockPresets).map((group) => ( + + {baselineMockPresets + .filter((mockPreset) => mockPreset.group.id === group) + .map((mockPreset) => { + return ( + + ); + })} + + )); +}; diff --git a/packages/manager/src/dev-tools/components/DevToolSelect.tsx b/packages/manager/src/dev-tools/components/DevToolSelect.tsx new file mode 100644 index 00000000000..ae63f870a31 --- /dev/null +++ b/packages/manager/src/dev-tools/components/DevToolSelect.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +export type DevToolsSelectProps = React.DetailedHTMLProps< + React.SelectHTMLAttributes, + HTMLSelectElement +>; + +/** + * Thin wrapper around native `{props.children} +
    + ); +}; diff --git a/packages/manager/src/dev-tools/components/Draggable.tsx b/packages/manager/src/dev-tools/components/Draggable.tsx new file mode 100644 index 00000000000..3eb31c5cfbe --- /dev/null +++ b/packages/manager/src/dev-tools/components/Draggable.tsx @@ -0,0 +1,133 @@ +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import React, { useEffect, useRef, useState } from 'react'; + +import ResizeWindow from 'src/assets/icons/ResizeWindow.svg'; + +import type { ReactNode } from 'react'; + +interface DraggableProps { + children?: ReactNode; + draggable: boolean; +} + +export const Draggable = ({ children, draggable }: DraggableProps) => { + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [position, setPosition] = useState({ + x: 40, + y: window.innerHeight - 400, + }); + const [size, setSize] = useState({ height: 400, width: 600 }); + const [rel, setRel] = useState<{ x: number; y: number } | null>(null); + const nodeRef = useRef(null); + const minWidth = 400; + const minHeight = 300; + + const onMouseDown = (e: React.MouseEvent) => { + if (!draggable || e.button !== 0) { + return; + } + + const node = nodeRef.current; + if (!node) { + return; + } + + const rect = node.getBoundingClientRect(); + setIsDragging(true); + setRel({ + x: e.pageX - rect.left, + y: e.pageY - rect.top, + }); + e.stopPropagation(); + e.preventDefault(); + }; + + const onMouseMove = React.useCallback( + (e: MouseEvent) => { + if (isDragging) { + setPosition({ + x: e.pageX - (rel?.x || 0), + y: e.pageY - (rel?.y || 0), + }); + } else if (isResizing) { + const newSize = { + height: Math.max(e.pageY - position.y, minHeight) + 30, + width: Math.max(e.pageX - position.x, minWidth) + 10, + }; + setSize(newSize); + } + e.stopPropagation(); + e.preventDefault(); + }, + [isDragging, isResizing, position, rel] + ); + + const onMouseUp = React.useCallback((e: MouseEvent) => { + setIsDragging(false); + setIsResizing(false); + e.stopPropagation(); + e.preventDefault(); + }, []); + + const onResizeStart = (e: React.MouseEvent) => { + setIsResizing(true); + e.stopPropagation(); + e.preventDefault(); + }; + + useEffect(() => { + if (isDragging || isResizing) { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } else { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, [isDragging, isResizing, onMouseMove, onMouseUp]); + + return ( +
    { + if (e.key === 'Enter' || e.key === ' ') { + onMouseDown((e as unknown) as React.MouseEvent); + } + }} + style={{ + height: `${size.height}px`, + left: `${position.x}px`, + position: 'absolute', + top: `${position.y}px`, + width: `${size.width}px`, + }} + ref={nodeRef} + role="button" + tabIndex={0} + > + {children} + {draggable && ( + <> + + + + )} +
    + ); +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx new file mode 100644 index 00000000000..9eedf8d709b --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; + +import { extraMockPresets } from 'src/mocks/presets'; + +import type { ExtraPresetOptionsProps } from './ExtraPresetOptions'; + +interface ExtraPresetOptionCheckboxProps + extends Omit { + group: string; +} + +export const ExtraPresetOptionCheckbox = ( + props: ExtraPresetOptionCheckboxProps +) => { + const { + disabled, + group, + handlers, + onPresetCountChange, + onTogglePreset, + presetsCountMap, + } = props; + + return extraMockPresets + .filter((extraMockPreset) => extraMockPreset.group.id === group) + .map( + (extraMockPreset) => + extraMockPreset.group.type === 'checkbox' && ( +
  • +
    + onTogglePreset(e, extraMockPreset.id)} + type="checkbox" + /> + + {extraMockPreset.label} + +
    + {extraMockPreset.canUpdateCount && ( +
    + + + + + { + if (e.target.value === '') { + e.target.value = '0'; + } + }} + onChange={(e) => { + const value = e.target.value; + onPresetCountChange( + { + target: { value }, + } as React.ChangeEvent, + extraMockPreset.id + ); + }} + onFocus={(e) => { + if (e.target.value === '0') { + e.target.value = ''; + } + }} + value={ + presetsCountMap[extraMockPreset.id] ?? + (presetsCountMap[extraMockPreset.id] || '0') + } + aria-label={`Value for ${extraMockPreset.label}`} + disabled={disabled || !handlers.includes(extraMockPreset.id)} + min={0} + style={{ paddingLeft: '20px', width: '100%' }} + type="number" + /> +
    + )} +
  • + ) + ); +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx new file mode 100644 index 00000000000..3b07094474c --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import { extraMockPresets } from 'src/mocks/presets'; + +import type { ExtraPresetOptionsProps } from './ExtraPresetOptions'; + +interface ExtraPresetOptionCheckboxProps + extends Omit< + ExtraPresetOptionsProps, + 'onPresetCountChange' | 'onTogglePreset' + > { + group: string; +} + +export const ExtraPresetOptionSelect = ( + props: ExtraPresetOptionCheckboxProps +) => { + const { disabled, group, handlers, onSelectChange } = props; + + return ( +
    + +
    + ); +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx new file mode 100644 index 00000000000..6a56f0f650c --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; + +import { getMockPresetGroups } from 'src/mocks/mockPreset'; +import { extraMockPresets } from 'src/mocks/presets'; + +import { ExtraPresetOptionCheckbox } from './ExtraPresetOptionCheckbox'; +import { ExtraPresetOptionSelect } from './ExtraPresetOptionSelect'; + +export interface ExtraPresetOptionsProps { + disabled: boolean; + handlers: string[]; + onPresetCountChange: (e: React.ChangeEvent, presetId: string) => void; + onSelectChange: (e: React.ChangeEvent, presetId: string) => void; + onTogglePreset: (e: React.ChangeEvent, presetId: string) => void; + presetsCountMap: { [key: string]: number }; +} + +/** + * Renders a list of extra presets with an optional count. + */ +export const ExtraPresetOptions = ({ + disabled, + handlers, + onPresetCountChange, + onSelectChange, + onTogglePreset, + presetsCountMap, +}: ExtraPresetOptionsProps) => { + const extraPresetGroups = getMockPresetGroups(extraMockPresets); + + return ( +
      + {extraPresetGroups.map((group) => { + const currentGroupType = extraMockPresets.find( + (p) => p.group.id === group + )?.group.type; + + const selectGroup = currentGroupType === 'select'; + + return ( +
      +
    • + {group}{' '} + {currentGroupType === 'select' && ( + + )} +
    • + {currentGroupType === 'checkbox' && ( + + )} +
      + ); + })} +
    + ); +}; diff --git a/packages/manager/src/dev-tools/components/SeedOptions.tsx b/packages/manager/src/dev-tools/components/SeedOptions.tsx new file mode 100644 index 00000000000..667a46cbd13 --- /dev/null +++ b/packages/manager/src/dev-tools/components/SeedOptions.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { getStateSeederGroups } from 'src/mocks/mockState'; +import { dbSeeders } from 'src/mocks/presets/crud/seeds'; + +interface SeedOptionsProps { + disabled: boolean; + onCountChange: (e: React.ChangeEvent, populatorId: string) => void; + onToggleSeeder: (e: React.ChangeEvent, populatorId: string) => void; + seeders: string[]; + seedsCountMap: { [key: string]: number }; +} + +/** + * Renders a list of seeders and their counts. + */ +export const SeedOptions = ({ + disabled, + onCountChange, + onToggleSeeder, + seeders, + seedsCountMap, +}: SeedOptionsProps) => { + return ( +
      + {getStateSeederGroups(dbSeeders).map((group) => ( +
      + {dbSeeders + .filter((dbSeeder) => dbSeeder.group.id === group) + .map((dbSeeder) => ( +
    • + onToggleSeeder(e, dbSeeder.id)} + style={{ marginRight: 12 }} + type="checkbox" + /> + + {dbSeeder.label} + + {dbSeeder.canUpdateCount && ( + { + const value = e.target.value; + if (value === '') { + e.target.value = '0'; + } + }} + onChange={(e) => { + const value = e.target.value; + onCountChange( + { + target: { value }, + } as React.ChangeEvent, + dbSeeder.id + ); + }} + onFocus={(e) => { + if (e.target.value === '0') { + e.target.value = ''; + } + }} + aria-label={`Value for ${dbSeeder.label}`} + disabled={disabled || !seeders.includes(dbSeeder.id)} + min={0} + style={{ marginLeft: 8, width: 60 }} + type="number" + value={seedsCountMap[dbSeeder.id] || '0'} + /> + )} +
    • + ))} +
      + ))} +
    + ); +}; diff --git a/packages/manager/src/dev-tools/constants.ts b/packages/manager/src/dev-tools/constants.ts new file mode 100644 index 00000000000..2370a42a329 --- /dev/null +++ b/packages/manager/src/dev-tools/constants.ts @@ -0,0 +1,11 @@ +export const LOCAL_STORAGE_KEY = 'msw'; + +export const LOCAL_STORAGE_SEEDERS_KEY = 'msw-seeders'; + +export const LOCAL_STORAGE_PRESET_KEY = 'msw-preset'; + +export const LOCAL_STORAGE_PRESET_EXTRAS_KEY = 'msw-preset-extras'; + +export const LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY = 'msw-seeds-count-map'; + +export const LOCAL_STORAGE_PRESETS_MAP_KEY = 'msw-preset-count-map'; diff --git a/packages/manager/src/dev-tools/dev-tools.css b/packages/manager/src/dev-tools/dev-tools.css index 23f3b0a5a28..1793a41a38b 100644 --- a/packages/manager/src/dev-tools/dev-tools.css +++ b/packages/manager/src/dev-tools/dev-tools.css @@ -1,42 +1,536 @@ -#dev-tools { +.dev-tools { + --open-height-desktop: 375px; + --open-height-mobile: 400px; + + --background-color-closed: rgba(0, 0, 0, 0.55); + --background-color-opened: rgba(0, 0, 0, 0.85); + --background-blur: blur(7px); + + --open-close-transition: background-color 0.2s, height 0.075s; + position: fixed; - bottom: 0px; - background: black; - opacity: 0.4; + width: 100%; + bottom: 0; + left: 0; + z-index: 1; + color: white; + font-size: 11pt; + scrollbar-color: rgba(255, 255, 255, .75) transparent; + + &.isDraggable { + position: relative; + height: calc(100% - 50px); + + .dev-tools__main { + flex-direction: column; + } + + .dev-tools__tool { + min-height: 300px; + } + + .dev-tools__tool__body, + .dev-tools__msw__column__body { + flex-basis: auto; + } + + .dev-tools__body { + border-radius: 0 0 4px 4px; + height: 100% !important; + } + } +} + +.dev-tools__draggable-handle { + position: absolute; + z-index: 2; + left: -30px; + top: 35px; + cursor: grab; + background: #222222; + border: none; + border-radius: 4px 0 0 4px; + padding: 4px 4px 2px 4px; + + svg { + fill: white; + } +} + +.dev-tools__resize-handle { + position: absolute; + z-index: 2; + right: 0px; + bottom: 16px; + color: white; + cursor: ew-resize; + background: transparent; + border: none; + + svg { + fill: white; + } +} + +.dev-tools hr { + border: none; + height: 1px; + background-color: rgba(255, 255, 255, 0.25); + margin-top: 12px; + margin-bottom: 16px; +} + +.dev-tools__select { + position: relative; + display: inline-block; + background: transparent; + border-radius: 4px; + border: 2px solid rgba(255, 255, 255, 0.5); + color: #fff; + + &.thin { + border-width: 1px + } +} + +.dev-tools__select:has(>select:focus), +.dev-tools__select:has(>select:active) { + background: rgb(50, 50, 50); + border-color: rgba(255, 255, 255, 0.65); +} + +.dev-tools__select::after { + position: absolute; + right: 8px; + top: 10px; + content: ''; + display: block; + width: 6px; + height: 6px; + border-right: 2px solid white; + border-top: 2px solid white; + transform: rotate(135deg); +} + +.dev-tools__select select { + background: transparent; + border: none; color: white; + padding: 4px 4px 5px 6px; + font-family: inherit; + font-size: 10.5pt; + appearance: none; + outline: none; +} + + /* avoid overriding TanStack React Query Devtools styles */ +.dev-tools__body button:not(.tsqd-parent-container button) { + background: transparent; + border: 2px solid rgba(255, 255, 255, 0.5); + border-radius: 1000px; + color: white; + cursor: pointer; + padding: 5px 18px 7px 18px; + font-weight: bold; + font-size: 10.5pt; + font-family: inherit; + transition: background-color 0.2s; + white-space: nowrap; + + &.small { + padding: 2px 8px 4px 8px; + border-width: 1px; + font-size: 12px; + } + + &.right-align { + float: right; + } + + + &.green { + background: #17cf73; + color: #080808; + } +} + +.dev-tools__body .dev-tools__content button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.dev-tools__body .dev-tools__content button:not(:disabled).green:hover { + background-color: #60e9a4; + color: #080808; + +} + +.dev-tools__body .dev-tools__content button:disabled, +.dev-tools__body .dev-tools__content button:disabled:active { + background: transparent; + cursor: not-allowed; + border-color: rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.25); +} + +.dev-tools__segmented-button .dev-tools__content button:not(:only-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-left: 1px solid rgba(255, 255, 255, 0.5); + border-right: 1px solid rgba(255, 255, 255, 0.5); +} + +.dev-tools__segmented-button { + display: flex; + flex-wrap: nowrap; + + button { + white-space: nowrap; + } +} + +.dev-tools__segmented-button .dev-tools__content button:first-child:not(:only-child) { + border-top-left-radius: 1000px; + border-bottom-left-radius: 1000px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-left: 2px solid rgba(255, 255, 255, 0.5); + border-right: 1px solid rgba(255, 255, 255, 0.5); + padding-left: 24px; +} + +.dev-tools__segmented-button .dev-tools__content button:last-child:not(:only-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 1000px; + border-bottom-right-radius: 1000px; + border-left: 1px solid rgba(255, 255, 255, 0.5); + border-right: 2px solid rgba(255, 255, 255, 0.5); + padding-right: 24px; +} + +.dev-tools__button-list { width: 100%; - padding: 20px; - height: 60px; - width: 60px; - transition: all 0.3s; + display: flex; + gap: 8px; + justify-content: end; +} + +.dev-tools__body .dev-tools__content button.toggle-button.toggle-button--on { + background: rgba(255, 255, 255, 0.2); + border: 2px solid white; + border-right: 2px solid white; + border-left: 2px solid white; + border-color: white; + border-left-color: white; + border-right-color: white; + border-top-color: white; + border-bottom-color: white; + color: white; + text-shadow: 0px -1px 0px black; +} + +.dev-tools__body .dev-tools__content button:active { + background: rgb(50, 50, 50); + border-color: rgba(255, 255, 255, 0.65); +} + +.dev-tools__toggle { + width: 70px; + height: 54px; + border-top-right-radius: 12px; + position: absolute; + left: 0; + bottom: 0; z-index: 1; } -#dev-tools.mswEnabled svg { - color: #aaff00; +.dev-tools__toggle button { + width: 100%; + height: 100%; + background-color: transparent; + border: 0; + color: white; + cursor: pointer; +} + +.dev-tools__toggle button svg { + position: relative; + top: 3px; + left: -5px; } -#dev-tools:hover { - height: 375px; +.dev-tools.dev-tools--msw .dev-tools__toggle button svg { + color: #17cf73; + filter: drop-shadow(0 0 3px limegreen); +} + +.dev-tools__toggle button::after { + content: ''; + width: 8px; + height: 8px; + border-right: 2px solid white; + border-top: 2px solid white; + display: inline-block; + position: relative; + left: 3px; + top: -3px; + transform: rotate(-45deg); + transition: 0.3s transform; +} + +.dev-tools.dev-tools--open .dev-tools__toggle button::after { + transform: translateY(-4px) rotate(135deg); +} + +.dev-tools .dev-tools__toggle, +.dev-tools .dev-tools__body { + background-color: var(--background-color-closed); + backdrop-filter: var(--background-blur); + transition: var(--open-close-transition); +} + +.dev-tools.dev-tools--open .dev-tools__toggle { + background-color: #080808; + backdrop-filter: none; +} + +.dev-tools__draggable-toggle { + display: flex; + justify-content: end; + + button { + background: #1d1d1e; + background-blend-mode: overlay; + border: 0; + + cursor: pointer; + padding: 4px; + position: relative; + bottom: -4px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + svg { + color: white; + + } +} + +.dev-tools.dev-tools--open .dev-tools__body { + background-color: var(--background-color-opened); +} + +.dev-tools__body input[type="number"] { + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.5); + font-family: inherit; + font-size: 10.5pt; + float: right; +} + +/* + * Dev tools body has height of `0` when `.dev-tools--open` class is not present. + */ +.dev-tools .dev-tools__body { + height: 0; + padding: 0; +} +.dev-tools.dev-tools--open .dev-tools__body { + height: var(--open-height-mobile); +} + +.dev-tools__content { + padding: 12px 12px; + display: flex; + flex-direction: column; + gap: 12px; width: 100%; - opacity: 0.9; + height: 100%; +} + +.dev-tools__status-bar { + flex-grow: 0; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + overflow-x: auto; + gap: 12px; +} + +.dev-tools__main { + display: flex; + flex-direction: column; + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; overflow: auto; + gap: 24px; } -#dev-tools:hover svg { - color: white; +.dev-tools__main__column { + flex-basis: 0; + flex-shrink: 0; + flex-grow: 1; } -#dev-tools .tools { - display: none; +.dev-tools__tool { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + gap: 12px; } -#dev-tools:hover .tools { +.dev-tools__tool__header { + flex-grow: 0; + flex-shrink: 0; + flex-basis: 0; +} + +.dev-tools__tool__header span { + font-size: 13pt; + font-weight: bold; +} + +.dev-tools__tool__footer { + flex-grow: 0; + flex-shrink: 0; + flex-basis: 0; + align-items: center; +} + +.dev-tools__list-box, +.dev-tools__scroll-box { + /*padding: 4px 8px;*/ + border: 1px solid rgba(255, 255, 255, 0.25); + box-shadow: inset 0px 5px 9px 0px rgba(0,0,0,0.35); + height: 100%; + overflow: auto; +} + +.dev-tools__list-box ul { + padding-left: none; + padding: 4px 8px; + /*padding-left: 0;*/ + margin: 0; + height: 100%; +} + +.dev-tools__list-box li { + padding-top: 3px; + padding-bottom: 4px; + margin-top: 2px; + margin-bottom: 2px; + list-style: none; +} + +.dev-tools__list-box li:nth-child(even) { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 4px; + margin-left: -4px; + margin-right: -4px; + padding-left: 4px; + padding-right: 4px; +} + +.dev-tools .dev-tools__list-box .dev-tools__list-box__separator { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); + margin-left: -8px; + margin-right: -8px; + padding-left: 8px; + padding-right: 8px; + padding-bottom: 9px; + margin-bottom: 6px; + font-weight: bold; + background-color: transparent; + border-radius: 0; +} + +.dev-tools__msw { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dev-tools__msw__presets { display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.25); } -@media only screen and (max-width: 960px) { - #dev-tools:hover .tools { - justify-content: space-between; +.dev-tools__msw__extras { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 12px; + + &.disabled { + opacity: 0.5; } } + +.dev-tools__msw__column__heading { + flex-basis: 0; + flex-grow: 0; + flex-shrink: 0; + font-weight: bold; + + &.disabled { + opacity: 0.5; + } +} + + + +@media only screen and (min-width: 1024px) { + .dev-tools.dev-tools--open .dev-tools__body { + height: var(--open-height-desktop); + } + + .dev-tools__main { + flex-direction: row; + } + + .dev-tools__msw__extras { + flex-direction: row; + } + + .dev-tools__tool__body { + flex-grow: 1; + flex-shrink: 1; + flex-basis: 0; + overflow: auto; + } + + .dev-tools__msw__column { + flex-grow: 1; + flex-shrink: 1; + flex-basis: 0; + height: 100%; + min-height: 120px; + display: flex; + flex-direction: column; + gap: 4px; + } + + .dev-tools__msw__column__body { + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; + overflow: auto; + } +} + +/* TanStack React Query Devtools */ +.tsqd-main-panel { + height: 100% !important; + width: 100% !important; + position: relative !important; +} \ No newline at end of file diff --git a/packages/manager/src/dev-tools/dev-tools.tsx b/packages/manager/src/dev-tools/dev-tools.tsx index 531961e1fe2..c91715ac618 100644 --- a/packages/manager/src/dev-tools/dev-tools.tsx +++ b/packages/manager/src/dev-tools/dev-tools.tsx @@ -1,56 +1,186 @@ +import CloseIcon from '@mui/icons-material/Close'; import Handyman from '@mui/icons-material/Handyman'; -import Grid from '@mui/material/Unstable_Grid2'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import { styled } from '@mui/material'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; -import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; -import { ENABLE_DEV_TOOLS, isProductionBuild } from 'src/constants'; -import { ApplicationStore } from 'src/store'; +import { getRoot } from 'src/utilities/rootManager'; +import { Draggable } from './components/Draggable'; import './dev-tools.css'; import { EnvironmentToggleTool } from './EnvironmentToggleTool'; import { FeatureFlagTool } from './FeatureFlagTool'; -import { MockDataTool } from './MockDataTool'; -import { Preferences } from './Preferences'; -import { isMSWEnabled } from './ServiceWorkerTool'; -import { ThemeSelector } from './ThemeSelector'; +import { ServiceWorkerTool } from './ServiceWorkerTool'; +import { isMSWEnabled } from './utils'; + +import type { QueryClient } from '@tanstack/react-query'; +import type { ApplicationStore } from 'src/store'; + +export type DevToolsView = 'mocks' | 'react-query'; + +const reactQueryDevtoolsStyle = { + border: '1px solid rgba(255, 255, 255, 0.25)', + height: '100%', + width: '100%', +}; + +export const install = (store: ApplicationStore, queryClient: QueryClient) => { + const DevTools = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [isDraggable, setIsDraggable] = React.useState(false); + const [view, setView] = React.useState('mocks'); + const devToolsMainRef = React.useRef(null); + + const handleOpenReactQuery = () => { + setView('react-query'); + }; + + const handleOpenMocks = () => { + setView('mocks'); + }; + + const handleDraggableToggle = () => { + setIsDraggable(!isDraggable); + if (isDraggable) { + setIsOpen(false); + } + }; + + const handleGoToPreferences = () => { + window.location.assign('/profile/settings?preferenceEditor=true'); + }; + + React.useEffect(() => { + // Prevent scrolling of the window when scrolling start/end of the dev tools + // Particularly useful when in draggable mode + if (!isDraggable) { + return; + } + + const handleWheel = (e: WheelEvent) => { + if (!devToolsMainRef.current?.contains(e.target as Node)) { + return; + } + + const target = devToolsMainRef.current; + const isAtTop = target.scrollTop === 0; + const isAtBottom = + target.scrollHeight - target.clientHeight <= target.scrollTop + 1; + + if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) { + e.preventDefault(); + } + }; + + window.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + window.removeEventListener('wheel', handleWheel); + }; + }, []); -function install(store: ApplicationStore) { - function DevTools() { return ( -
    -
    - -
    - - - - - {import.meta.env.DEV && ( - - - + +
    + {!isDraggable && ( +
    + +
    + )} + {isOpen && ( +
    + +
    )} - {!isProductionBuild || ENABLE_DEV_TOOLS ? ( - - - - - - ) : null} - -
    +
    +
    +
    +
    + +
    +
    + + +
    +
    + +
    +
    +
    + {view === 'mocks' && ( + <> +
    + +
    +
    + +
    + + )} + {view === 'react-query' && ( + + + + + + )} +
    +
    +
    +
    + ); - } + }; + + const devToolsRoot = + document.getElementById('dev-tools-root') || + (() => { + const newRoot = document.createElement('div'); + newRoot.id = 'dev-tools-root'; + document.body.appendChild(newRoot); + return newRoot; + })(); - const devToolsRoot = document.createElement('div'); - document.body.appendChild(devToolsRoot); - const root = createRoot(devToolsRoot); + const root = getRoot(devToolsRoot); root.render( ); -} +}; -export { install }; +const StyledReactQueryDevtoolsContainer = styled('div')({ + border: '1px solid rgba(255, 255, 255, 0.25)', + height: '100%', + width: '100%', +}); diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts index 9b0703ff810..6d34f875c2d 100644 --- a/packages/manager/src/dev-tools/load.ts +++ b/packages/manager/src/dev-tools/load.ts @@ -1,23 +1,135 @@ import { ENABLE_DEV_TOOLS } from 'src/constants'; -import { ApplicationStore } from 'src/store'; +import { mswDB } from 'src/mocks/indexedDB'; +import { resolveMockPreset } from 'src/mocks/mockPreset'; +import { createInitialMockStore, emptyStore } from 'src/mocks/mockState'; +import { allMockPresets, defaultBaselineMockPreset } from 'src/mocks/presets'; +import { dbSeeders } from 'src/mocks/presets/crud/seeds'; -import { isMSWEnabled } from './ServiceWorkerTool'; +import { + getBaselinePreset, + getExtraPresets, + getSeeders, + isMSWEnabled, +} from './utils'; + +import type { QueryClient } from '@tanstack/react-query'; +import type { MockPresetExtra, MockSeeder, MockState } from 'src/mocks/types'; +import type { ApplicationStore } from 'src/store'; + +export let mockState: MockState; /** - * Use this to dynamicly import our custom dev-tools ONLY when they + * Use this to dynamically import our custom dev-tools ONLY when they * are needed. + * * @param store Redux store to control */ -export async function loadDevTools(store: ApplicationStore) { +export async function loadDevTools( + store: ApplicationStore, + client: QueryClient +) { const devTools = await import('./dev-tools'); if (isMSWEnabled) { - const { worker } = await import('../mocks/testBrowser'); + const { worker: mswWorker } = await import('../mocks/mswWorkers'); + const mswPresetId = getBaselinePreset() ?? defaultBaselineMockPreset.id; + const mswPreset = + allMockPresets.find((preset) => preset.id === mswPresetId) ?? + defaultBaselineMockPreset; + + const extraMswPresetIds = getExtraPresets(); + const extraMswPresets = extraMswPresetIds + .map((presetId) => + allMockPresets.find((extraPreset) => extraPreset.id === presetId) + ) + .filter((preset) => !!preset); + + const mswContentSeederIds = getSeeders(dbSeeders); + const mswContentSeeders = mswContentSeederIds + .map((seederId) => dbSeeders.find((dbSeeder) => dbSeeder.id === seederId)) + .filter((seeder) => !!seeder); + + // Apply MSW context populators. + const initialContext = await createInitialMockStore(); + await mswDB.saveStore(initialContext, 'mockState'); + + // Seeding + const seedContext = (await mswDB.getStore('seedState')) || emptyStore; + + const populateSeeds = async (store: MockState): Promise => { + return await mswContentSeeders.reduce( + async (accPromise, cur: MockSeeder) => { + const acc = await accPromise; + + return await cur.seeder(acc); + }, + Promise.resolve(store) + ); + }; + + const updateSeedContext = async ( + key: T, + seeds: MockState + ): Promise => { + seedContext[key] = seeds[key]; + }; + + const seeds = await populateSeeds(emptyStore); + + const seedPromises = (Object.keys( + seedContext + ) as (keyof MockState)[]).map((key) => updateSeedContext(key, seeds)); + + await Promise.all(seedPromises); + + await mswDB.saveStore(seedContext ?? emptyStore, 'seedState'); + + // Merge the contexts + const mergedContext: MockState = { + ...initialContext, + eventQueue: [ + ...initialContext.eventQueue, + ...(seedContext?.eventQueue || []), + ], + firewalls: [ + ...initialContext.firewalls, + ...(seedContext?.firewalls || []), + ], + linodeConfigs: [ + ...initialContext.linodeConfigs, + ...(seedContext?.linodeConfigs || []), + ], + linodes: [...initialContext.linodes, ...(seedContext?.linodes || [])], + notificationQueue: [ + ...initialContext.notificationQueue, + ...(seedContext?.notificationQueue || []), + ], + placementGroups: [ + ...initialContext.placementGroups, + ...(seedContext?.placementGroups || []), + ], + regionAvailability: [ + ...initialContext.regionAvailability, + ...(seedContext?.regionAvailability || []), + ], + regions: [...initialContext.regions, ...(seedContext?.regions || [])], + volumes: [...initialContext.volumes, ...(seedContext?.volumes || [])], + }; + + const extraHandlers = extraMswPresets.reduce( + (acc, cur: MockPresetExtra) => { + return [...resolveMockPreset(cur, mergedContext), ...acc]; + }, + [] + ); + + const baseHandlers = resolveMockPreset(mswPreset, mergedContext); + const worker = mswWorker(extraHandlers, baseHandlers); await worker.start({ onUnhandledRequest: 'bypass' }); } - devTools.install(store); + devTools.install(store, client); } /** @@ -26,5 +138,5 @@ export async function loadDevTools(store: ApplicationStore) { * * Define `REACT_APP_ENABLE_DEV_TOOLS` to explicitly enable or disable dev tools */ -export const shouldEnableDevTools = +export const shouldLoadDevTools = ENABLE_DEV_TOOLS !== undefined ? ENABLE_DEV_TOOLS : import.meta.env.DEV; diff --git a/packages/manager/src/dev-tools/utils.ts b/packages/manager/src/dev-tools/utils.ts new file mode 100644 index 00000000000..6335041c8f3 --- /dev/null +++ b/packages/manager/src/dev-tools/utils.ts @@ -0,0 +1,148 @@ +import { defaultBaselineMockPreset, extraMockPresets } from 'src/mocks/presets'; + +import { + LOCAL_STORAGE_KEY, + LOCAL_STORAGE_PRESET_EXTRAS_KEY, + LOCAL_STORAGE_PRESET_KEY, + LOCAL_STORAGE_PRESETS_MAP_KEY, + LOCAL_STORAGE_SEEDERS_KEY, + LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY, +} from './constants'; + +import type { + MockPresetBaselineId, + MockPresetExtraId, + MockSeeder, +} from 'src/mocks/types'; + +/** + * Whether MSW is enabled via local storage setting. + * + * `true` if MSW is enabled, `false` otherwise. + */ +export const isMSWEnabled = + localStorage.getItem(LOCAL_STORAGE_KEY) === 'enabled'; + +/** + * Saves MSW enabled or disabled state to local storage. + * + * @param enabled - Whether or not to enable MSW. + */ +export const saveMSWEnabled = (enabled: boolean): void => { + localStorage.setItem(LOCAL_STORAGE_KEY, enabled ? 'enabled' : 'disabled'); +}; + +/** + * Returns the ID of the selected MSW preset that is stored in local storage. + * + * @returns ID of selected MSW preset, or `null` if no preset is saved. + */ +export const getBaselinePreset = (): MockPresetBaselineId => { + return ( + (localStorage.getItem(LOCAL_STORAGE_PRESET_KEY) as MockPresetBaselineId) ?? + defaultBaselineMockPreset.id + ); +}; + +/** + * Saves ID of selected MSW preset in local storage. + */ +export const saveBaselinePreset = (presetId: MockPresetBaselineId): void => { + localStorage.setItem(LOCAL_STORAGE_PRESET_KEY, presetId); +}; + +/** + * Retrieves the seeding count map from local storage. + */ +export const getSeedsCountMap = (): { [key: string]: number } => { + const encodedCountMap = localStorage.getItem( + LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY + ); + + return encodedCountMap ? JSON.parse(encodedCountMap) : {}; +}; + +/** + * Saves the seeding count map to local storage. + */ +export const saveSeedsCountMap = (countMap: { [key: string]: number }) => { + localStorage.setItem( + LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY, + JSON.stringify(countMap) + ); +}; + +/** + * Retrieves the presets map from local storage. + */ +export const getExtraPresetsMap = (): { + [K in MockPresetExtraId]: number; +} => { + const encodedPresetsMap = localStorage.getItem(LOCAL_STORAGE_PRESETS_MAP_KEY); + + return encodedPresetsMap ? JSON.parse(encodedPresetsMap) : {}; +}; + +/** + * Saves the presets map to local storage. + */ +export const saveExtraPresetsMap = (presetsMap: { [key: string]: number }) => { + localStorage.setItem( + LOCAL_STORAGE_PRESETS_MAP_KEY, + JSON.stringify(presetsMap) + ); +}; + +/** + * Returns an array of enabled extra MSW presets that are stored in local storage. + * + * An empty array is returned when the expected data does not exist in local + * storage. + */ +export const getExtraPresets = (): string[] => { + const encodedPresets = localStorage.getItem(LOCAL_STORAGE_PRESET_EXTRAS_KEY); + if (!encodedPresets) { + return []; + } + const storedPresets = encodedPresets.split(','); + + // Filter out any stored presets that no longer exist in the code base. + return storedPresets.filter((storedPreset) => + extraMockPresets.find( + (extraMockPreset) => extraMockPreset.id === storedPreset + ) + ); +}; + +/** + * Saves the extra MSW presets to local storage. + */ +export const saveExtraPresets = (presets: string[]) => { + localStorage.setItem(LOCAL_STORAGE_PRESET_EXTRAS_KEY, presets.join(',')); +}; + +/** + * Returns an array of enabled context seeders that are stored in local storage. + * + * An empty array is returned when the expected data does not exist in local + * storage. + */ +export const getSeeders = (dbSeeders: MockSeeder[]): string[] => { + const encodedPopulators = localStorage.getItem(LOCAL_STORAGE_SEEDERS_KEY); + if (!encodedPopulators) { + return []; + } + const storedSeeders = encodedPopulators.split(','); + + // Filter out any stored populators that no longer exist in the code base. + return storedSeeders.filter((storedSeeder) => + dbSeeders.find((dbSeeder) => dbSeeder.id === storedSeeder) + ); +}; + +/** + * Saves the context seeders to local storage. + */ +export const saveSeeders = (populators: string[]) => { + localStorage.setItem(LOCAL_STORAGE_SEEDERS_KEY, populators.join(',')); +}; diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index 241deeea45f..eff241f37d5 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -45,7 +45,7 @@ export const accountFactory = Factory.Sync.makeFactory({ 'Linodes', 'LKE HA Control Planes', 'Machine Images', - 'Managed Databases V2', + 'Managed Databases', 'NodeBalancers', 'Object Storage Access Key Regions', 'Object Storage Endpoint Types', diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 87ee964126f..657a262d804 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -16,14 +16,16 @@ import type { PostgresReplicationType, } from '@linode/api-v4/lib/databases/types'; -// These are not all of the possible statuses, but these are some common ones. export const possibleStatuses: DatabaseStatus[] = [ - 'provisioning', 'active', - 'failed', 'degraded', - 'restoring', + 'failed', + 'provisioning', 'resizing', + 'restoring', + 'resuming', + 'suspended', + 'suspending', ]; export const possibleMySQLReplicationTypes: MySQLReplicationType[] = [ diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index c6bc613b7e4..2295f39bf0f 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -117,6 +117,20 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ }, }); +export const linodeBackupFactory = Factory.Sync.makeFactory({ + available: true, + configs: ['My Alpine 3.17 Disk Profile'], + created: '2020-01-01', + disks: [], + finished: '2020-01-01', + id: Factory.each((i) => i), + label: Factory.each((i) => `Backup ${i}`), + region: 'us-east', + status: 'successful', + type: 'auto', + updated: '2020-01-01', +}); + export const linodeBackupsFactory = Factory.Sync.makeFactory({ enabled: true, last_successful: '2020-01-01', @@ -255,6 +269,7 @@ export const proDedicatedTypeFactory = Factory.Sync.makeFactory({ export const linodeFactory = Factory.Sync.makeFactory({ alerts: linodeAlertsFactory.build(), backups: linodeBackupsFactory.build(), + capabilities: [], created: '2020-01-01', disk_encryption: 'enabled', group: '', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index fc9989379ae..57cc999ce71 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -54,8 +54,9 @@ interface BetaFeatureFlag extends BaseFeatureFlag { beta: boolean; } -interface GaFeatureFlag extends BaseFeatureFlag { +interface GeckoFeatureFlag extends BaseFeatureFlag { ga: boolean; + la: boolean; } interface AclpFlag { @@ -84,6 +85,7 @@ export interface Flags { aclpReadEndpoint: string; aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; apiMaintenance: APIMaintenance; + apicliButtonCopy: string; apicliDxToolsAdditions: boolean; blockStorageEncryption: boolean; cloudManagerDesignUpdatesBanner: DesignUpdatesBannerFlag; @@ -92,13 +94,10 @@ export interface Flags { databases: boolean; dbaasV2: BetaFeatureFlag; disableLargestGbPlans: boolean; - eventMessagesV2: boolean; - gecko: boolean; // @TODO gecko: delete this after next release - gecko2: GaFeatureFlag; + gecko2: GeckoFeatureFlag; gpuv2: gpuV2; imageServiceGen2: boolean; ipv6Sharing: boolean; - linodeCreateRefactor: boolean; linodeCreateWithFirewall: boolean; linodeDiskEncryption: boolean; mainContentBanner: MainContentBanner; @@ -108,7 +107,6 @@ export interface Flags { objectStorageGen2: BaseFeatureFlag; oneClickApps: OneClickApp; oneClickAppsDocsOverride: Record; - placementGroups: BetaFeatureFlag; productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; promotionalOffers: PromotionalOffer[]; @@ -121,6 +119,7 @@ export interface Flags { taxCollectionBanner: TaxCollectionBanner; taxId: BaseFeatureFlag; taxes: Taxes; + testdxtoolabexperiment: string; tpaProviders: Provider[]; } diff --git a/packages/manager/src/features/Account/ObjectStorageSettings.tsx b/packages/manager/src/features/Account/ObjectStorageSettings.tsx index fe30d15cf50..316847725d7 100644 --- a/packages/manager/src/features/Account/ObjectStorageSettings.tsx +++ b/packages/manager/src/features/Account/ObjectStorageSettings.tsx @@ -20,7 +20,7 @@ export const ObjectStorageSettings = () => { const { error, - isLoading: isCancelLoading, + isPending: isCancelLoading, mutateAsync: cancelObjectStorage, reset, } = useCancelObjectStorageMutation(); diff --git a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx index 7fdd87b63e7..5924c18ec8c 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx @@ -20,7 +20,7 @@ export const useParentChildAuthentication = () => { const { error: createTokenError, - isLoading: createTokenLoading, + isPending: createTokenLoading, mutateAsync: createProxyToken, } = useCreateChildAccountPersonalAccessTokenMutation(); diff --git a/packages/manager/src/features/Backups/BackupDrawer.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 55cf416029c..7df3711a553 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -60,7 +60,7 @@ export const BackupDrawer = (props: Props) => { const { error: updateAccountSettingsError, - isLoading: isUpdatingAccountSettings, + isPending: isUpdatingAccountSettings, mutateAsync: updateAccountSettings, } = useMutateAccountSettings(); @@ -70,7 +70,7 @@ export const BackupDrawer = (props: Props) => { const { data: enableBackupsResult, - isLoading: isEnablingBackups, + isPending: isEnablingBackups, mutateAsync: enableBackups, } = useEnableBackupsOnLinodesMutation(); diff --git a/packages/manager/src/features/Backups/utils.ts b/packages/manager/src/features/Backups/utils.ts index 156a604d1b6..85193d82625 100644 --- a/packages/manager/src/features/Backups/utils.ts +++ b/packages/manager/src/features/Backups/utils.ts @@ -21,28 +21,26 @@ export const useEnableBackupsOnLinodesMutation = () => { (EnableBackupsFufilledResult | EnableBackupsRejectedResult)[], unknown, Linode[] - >( - async (linodes) => { + >({ + mutationFn: async (linodes) => { const data = await Promise.allSettled( linodes.map((linode) => enableBackups(linode.id)) ); return linodes.map((linode, idx) => ({ linode, ...data[idx] })); }, - { - onSuccess(_, variables) { - queryClient.invalidateQueries(linodeQueries.linodes); - for (const linode of variables) { - queryClient.invalidateQueries({ - exact: true, - queryKey: linodeQueries.linode(linode.id).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: linodeQueries.linode(linode.id)._ctx.backups.queryKey, - }); - } - }, - } - ); + onSuccess(_, variables) { + queryClient.invalidateQueries(linodeQueries.linodes); + for (const linode of variables) { + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linode.id).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linode.id)._ctx.backups.queryKey, + }); + } + }, + }); }; interface FailureNotificationProps { diff --git a/packages/manager/src/features/Betas/BetaSignup.tsx b/packages/manager/src/features/Betas/BetaSignup.tsx index 9e6273a9040..448d6dad3fa 100644 --- a/packages/manager/src/features/Betas/BetaSignup.tsx +++ b/packages/manager/src/features/Betas/BetaSignup.tsx @@ -138,7 +138,7 @@ EAP and the MSA, this EAP shall be deemed controlling only with respect to its e <> - {isLoading ? ( + {isLoading || !beta ? ( ) : ( diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx index 3364e13861a..e0b1aeb6cfa 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx @@ -9,6 +9,7 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { EDIT_BILLING_CONTACT } from 'src/features/Billing/constants'; +import { StyledAutorenewIcon } from 'src/features/TopMenu/NotificationMenu/NotificationMenu'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useNotificationsQuery } from 'src/queries/account/notifications'; @@ -239,14 +240,8 @@ const ContactInformation = (props: Props) => { {invalidTaxIdNotification && ( svg': { - fontSize: '18px', - }, - paddingBottom: 0, - paddingTop: 0, - }} - status="warning" + icon={} + status="other" text={invalidTaxIdNotification.label} /> )} diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx index 76b13950a9d..0bab8d585f8 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -30,7 +30,7 @@ const excludedUSRegions = ['Micronesia', 'Marshall Islands', 'Palau']; const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { const { data: account } = useAccount(); - const { error, isLoading, mutateAsync } = useMutateAccount(); + const { error, isPending, mutateAsync } = useMutateAccount(); const { data: notifications, refetch } = useNotificationsQuery(); const { classes } = useStyles(); const emailRef = React.useRef(); @@ -391,7 +391,7 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { 'data-testid': 'save-contact-info', disabled: isReadOnly, label: 'Save Changes', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx index f8cfbe748fd..5f03e47e53c 100644 --- a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx +++ b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx @@ -10,7 +10,7 @@ export const CloudPulseLanding = () => { <> }> diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index ac019a321d3..3d010993877 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -154,6 +154,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const { data: jweToken, isError: isJweTokenError, + isLoading: isJweTokenLoading, } = useCloudPulseJWEtokenQuery( dashboard?.service_type, getJweTokenPayload(), @@ -168,7 +169,12 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { ); } - if (isMetricDefinitionLoading || isDashboardLoading || isResourcesLoading) { + if ( + isMetricDefinitionLoading || + isDashboardLoading || + isResourcesLoading || + isJweTokenLoading + ) { return ; } @@ -237,11 +243,11 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { ); }; - const renderPlaceHolder = (subtitle: string) => { + const renderPlaceHolder = (title: string) => { return ( - + ); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx index d54a52a0500..d0cfe9b3470 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx @@ -8,7 +8,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseDashboardLanding } from './CloudPulseDashboardLanding'; const dashboardLabel = 'Factory Dashboard-1'; -const selectDashboardLabel = 'Select a Dashboard'; +const selectDashboardLabel = 'Select Dashboard'; const queryMocks = vi.hoisted(() => ({ useCloudPulseDashboardsQuery: vi.fn().mockReturnValue({}), useLoadUserPreferences: vi.fn().mockReturnValue({}), diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index 89f5f6db2c9..b31ee7f84a8 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -6,7 +6,7 @@ import { CircleProgress } from 'src/components/CircleProgress'; import { StyledPlaceholder } from 'src/features/StackScripts/StackScriptBase/StackScriptBase.styles'; import { GlobalFilters } from '../Overview/GlobalFilters'; -import { REGION, RESOURCE_ID } from '../Utils/constants'; +import { REFRESH, REGION, RESOURCE_ID } from '../Utils/constants'; import { checkIfAllMandatoryFiltersAreSelected, getMetricsCallCustomFilters, @@ -104,6 +104,11 @@ export const CloudPulseDashboardLanding = () => { filterValue, dashboard.service_type )} + manualRefreshTimeStamp={ + filterValue[REFRESH] && typeof filterValue[REFRESH] === 'number' + ? filterValue[REFRESH] + : undefined + } region={ typeof filterValue[REGION] === 'string' ? (filterValue[REGION] as string) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx new file mode 100644 index 00000000000..5cdd820736c --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { dashboardFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseDashboardWithFilters } from './CloudPulseDashboardWithFilters'; + +const queryMocks = vi.hoisted(() => ({ + useCloudPulseDashboardByIdQuery: vi.fn().mockReturnValue({}), +})); + +const selectTimeDurationPlaceholder = 'Select Time Duration'; +const circleProgress = 'circle-progress'; +const mandatoryFiltersError = 'Mandatory Filters not Selected'; +const customNodeTypePlaceholder = 'Select Node Type'; + +vi.mock('src/queries/cloudpulse/dashboards', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/dashboards'); + return { + ...actual, + useCloudPulseDashboardByIdQuery: queryMocks.useCloudPulseDashboardByIdQuery, + }; +}); +const mockDashboard = dashboardFactory.build(); + +queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: { + data: mockDashboard, + }, + error: false, + isLoading: false, +}); + +describe('CloudPulseDashboardWithFilters component tests', () => { + it('renders a CloudPulseDashboardWithFilters component with error placeholder', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: { + data: mockDashboard, + }, + error: false, + isError: true, + isLoading: false, + }); + + const screen = renderWithTheme( + + ); + + expect( + screen.getByText('Error while loading Dashboard with Id - 1') + ).toBeDefined(); + }); + + it('renders a CloudPulseDashboardWithFilters component successfully without error placeholders', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: mockDashboard, + error: false, + isError: false, + isLoading: false, + }); + + const screen = renderWithTheme( + + ); + + expect(screen.getByText(selectTimeDurationPlaceholder)).toBeDefined(); + expect(screen.getByTestId(circleProgress)).toBeDefined(); // the dashboards started to render + }); + + it('renders a CloudPulseDashboardWithFilters component successfully for dbaas', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: { ...mockDashboard, service_type: 'dbaas' }, + error: false, + isError: false, + isLoading: false, + }); + + const screen = renderWithTheme( + + ); + + expect(screen.getByText(selectTimeDurationPlaceholder)).toBeDefined(); + expect(screen.getByTestId(circleProgress)).toBeDefined(); // the dashboards started to render + }); + + it('renders a CloudPulseDashboardWithFilters component with mandatory filter error for dbaas', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: { ...mockDashboard, service_type: 'dbaas' }, + error: false, + isError: false, + isLoading: false, + }); + + const screen = renderWithTheme( + + ); + + expect(screen.getByTestId('CloseIcon')).toBeDefined(); + + const inputBox = screen.getByPlaceholderText(customNodeTypePlaceholder); + fireEvent.change(inputBox, { target: { value: '' } }); // clear the value + expect(screen.getByText(mandatoryFiltersError)).toBeDefined(); + }); + + it('renders a CloudPulseDashboardWithFilters component with no filters configured error', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: { ...mockDashboard, service_type: 'xyz' }, + error: false, + isError: false, + isLoading: false, + }); + + const screen = renderWithTheme( + + ); + + expect( + screen.getByText('No Filters Configured for Service Type - xyz') + ).toBeDefined(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx new file mode 100644 index 00000000000..2354bda5533 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -0,0 +1,163 @@ +import { Divider, Grid, styled } from '@mui/material'; +import React from 'react'; + +import CloudPulseIcon from 'src/assets/icons/entityIcons/monitor.svg'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { Paper } from 'src/components/Paper'; +import { Placeholder } from 'src/components/Placeholder/Placeholder'; +import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; + +import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; +import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; +import { FILTER_CONFIG } from '../Utils/FilterConfig'; +import { + checkIfFilterBuilderNeeded, + checkMandatoryFiltersSelected, + getDashboardProperties, +} from '../Utils/ReusableDashboardFilterUtils'; +import { CloudPulseDashboard } from './CloudPulseDashboard'; + +import type { FilterValueType } from './CloudPulseDashboardLanding'; +import type { TimeDuration } from '@linode/api-v4'; + +export interface CloudPulseDashboardWithFiltersProp { + /** + * The id of the dashboard that needs to be rendered + */ + dashboardId: number; + /** + * The resource id for which the metrics will be listed + */ + resource: number; +} + +export const CloudPulseDashboardWithFilters = React.memo( + (props: CloudPulseDashboardWithFiltersProp) => { + const { dashboardId, resource } = props; + const { data: dashboard, isError } = useCloudPulseDashboardByIdQuery( + dashboardId + ); + + const [filterValue, setFilterValue] = React.useState<{ + [key: string]: FilterValueType; + }>({}); + + const [timeDuration, setTimeDuration] = React.useState({ + unit: 'min', + value: 30, + }); + + const onFilterChange = React.useCallback( + (filterKey: string, value: FilterValueType) => { + setFilterValue((prev) => ({ ...prev, [filterKey]: value })); + }, + [] + ); + + const handleTimeRangeChange = React.useCallback( + (timeDuration: TimeDuration) => { + setTimeDuration(timeDuration); + }, + [] + ); + + const renderPlaceHolder = (title: string) => { + return ( + + + + ); + }; + + if (isError) { + return ( + + ); + } + + if (!dashboard) { + return ; + } + + if (!FILTER_CONFIG.get(dashboard.service_type)) { + return ( + + ); + } + + const isFilterBuilderNeeded = checkIfFilterBuilderNeeded(dashboard); + const isMandatoryFiltersSelected = checkMandatoryFiltersSelected({ + dashboardObj: dashboard, + filterValue, + resource, + timeDuration, + }); + + return ( + <> + + + + + + + + {isFilterBuilderNeeded && ( + <> + + + + )} + + {isMandatoryFiltersSelected ? ( + + ) : ( + renderPlaceHolder('Mandatory Filters not Selected') + )} + + ); + } +); + +// keeping it here to avoid recreating +const StyledPlaceholder = styled(Placeholder, { + label: 'StyledPlaceholder', +})({ + flex: 'auto', +}); diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index 0953eaa6303..248e1fcfa33 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -1,5 +1,5 @@ +import { IconButton } from '@mui/material'; import { Grid } from '@mui/material'; -import { IconButton, Tooltip } from '@mui/material'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -9,6 +9,7 @@ import { Divider } from 'src/components/Divider'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; +import { REFRESH } from '../Utils/constants'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { Dashboard, TimeDuration } from '@linode/api-v4'; @@ -34,7 +35,6 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleDashboardChange, handleTimeDurationChange, } = props; - const [selectedDashboard, setSelectedDashboard] = React.useState< Dashboard | undefined >(); @@ -61,7 +61,15 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { [handleAnyFilterChange] ); - const handleGlobalRefresh = React.useCallback(() => {}, []); + const handleGlobalRefresh = React.useCallback( + (dashboardObj?: Dashboard) => { + if (!dashboardObj) { + return; + } + handleAnyFilterChange(REFRESH, Date.now()); + }, + [handleAnyFilterChange] + ); return ( @@ -86,17 +94,16 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { hideLabel label="Select Time Range" /> - - - - - + handleGlobalRefresh(selectedDashboard)} + size="small" + > + +
    diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index fef6da64c10..40d5d2d480b 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -92,6 +92,11 @@ interface graphDataOptionsProps { */ unit: string; + /** + * widget chart type + */ + widgetChartType: string; + /** * preferred color for the widget's graph */ @@ -150,6 +155,7 @@ export const generateGraphData = (props: graphDataOptionsProps) => { serviceType, status, unit, + widgetChartType, widgetColor, } = props; @@ -190,8 +196,9 @@ export const generateGraphData = (props: graphDataOptionsProps) => { const dimension = { backgroundColor: color, - borderColor: '', + borderColor: color, data: seriesDataFormatter(transformedData.values, start, end), + fill: widgetChartType === 'area', label: getLabelName(labelOptions), }; // construct a legend row with the dimension @@ -305,9 +312,10 @@ export const mapResourceIdToName = ( id: string | undefined, resources: CloudPulseResources[] ): string => { - return ( - resources.find((resourceObj) => resourceObj?.id === id)?.label ?? id ?? '' + const resourcesObj = resources.find( + (resourceObj) => String(resourceObj.id) === id ); + return resourcesObj?.label ?? id ?? ''; }; /** diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index caf4ecf683a..cc6782926a2 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -41,7 +41,7 @@ export const LINODE_CONFIG: Readonly = { isMetricsFilter: true, isMultiSelect: false, name: TIME_DURATION, - neededInServicePage: true, + neededInServicePage: false, placeholder: 'Select Duration', priority: 3, }, @@ -113,12 +113,37 @@ export const DBAAS_CONFIG: Readonly = { isMetricsFilter: true, isMultiSelect: false, name: TIME_DURATION, - neededInServicePage: true, + neededInServicePage: false, // we will have a static time duration component, no need render from filter builder placeholder: 'Select Duration', priority: 4, }, name: TIME_DURATION, }, + { + configuration: { + filterKey: 'role', + filterType: 'string', + isFilterable: true, // isFilterable -- this determines whether you need to pass it metrics api + isMetricsFilter: false, // if it is false, it will go as a part of filter params, else global filter + isMultiSelect: false, + name: 'Node Type', + neededInServicePage: true, + options: [ + { + id: 'primary', + label: 'Primary', + }, + { + id: 'secondary', + label: 'Secondary', + }, + ], + placeholder: 'Select Node Type', + priority: 5, + type: CloudPulseSelectTypes.static, + }, + name: 'Node Type', + }, ], serviceType: 'dbaas', }; diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts new file mode 100644 index 00000000000..3bdfea69058 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts @@ -0,0 +1,134 @@ +import { dashboardFactory } from 'src/factories'; + +import { + checkIfFilterBuilderNeeded, + checkIfFilterNeededInMetricsCall, + checkMandatoryFiltersSelected, + constructDimensionFilters, + getDashboardProperties, +} from './ReusableDashboardFilterUtils'; + +const mockDashboard = dashboardFactory.build(); + +it('test getDashboardProperties method', () => { + const result = getDashboardProperties({ + dashboardObj: mockDashboard, + filterValue: { region: 'us-east' }, + resource: 1, + }); + + expect(result).toBeDefined(); + expect(result.dashboardId).toEqual(mockDashboard.id); + expect(result.resources).toEqual(['1']); +}); + +it('test checkMandatoryFiltersSelected method for time duration and resource', () => { + let result = checkMandatoryFiltersSelected({ + dashboardObj: mockDashboard, + filterValue: { region: 'us-east' }, + resource: 0, + }); + expect(result).toBe(false); + + result = checkMandatoryFiltersSelected({ + dashboardObj: mockDashboard, + filterValue: { region: 'us-east' }, + resource: 1, + timeDuration: { unit: 'min', value: 30 }, + }); + + expect(result).toBe(true); + + result = checkMandatoryFiltersSelected({ + dashboardObj: mockDashboard, + filterValue: { region: 'us-east' }, + resource: 1, + timeDuration: undefined, // here time duration is undefined, so it should return false + }); + + expect(result).toBe(false); + + result = checkMandatoryFiltersSelected({ + dashboardObj: mockDashboard, + filterValue: { region: 'us-east' }, + resource: 0, // here resource is 0, so it should return false + timeDuration: { unit: 'min', value: 30 }, + }); + + expect(result).toBe(false); +}); + +it('test checkMandatoryFiltersSelected method for role', () => { + // check for dbaas + let result = checkMandatoryFiltersSelected({ + dashboardObj: { ...mockDashboard, service_type: 'dbaas' }, + filterValue: { region: 'us-east' }, // here role is missing + resource: 1, + timeDuration: { unit: 'min', value: 30 }, + }); + + expect(result).toBe(false); + + result = checkMandatoryFiltersSelected({ + dashboardObj: { ...mockDashboard, service_type: 'dbaas' }, + filterValue: { region: 'us-east', role: 'primary' }, + resource: 1, + timeDuration: { unit: 'min', value: 30 }, + }); + + expect(result).toBe(true); +}); + +it('test constructDimensionFilters method', () => { + mockDashboard.service_type = 'dbaas'; + const result = constructDimensionFilters({ + dashboardObj: mockDashboard, + filterValue: { role: 'primary' }, + resource: 1, + }); + + expect(result.length).toEqual(1); + expect(result[0].filterKey).toEqual('role'); + expect(result[0].filterValue).toEqual('primary'); +}); + +it('test checkIfFilterNeededInMetricsCall method', () => { + let result = checkIfFilterNeededInMetricsCall('region', 'linode'); + expect(result).toEqual(false); + + result = checkIfFilterNeededInMetricsCall('resource_id', 'linode'); + expect(result).toEqual(false); // not needed as dimension filter + + result = checkIfFilterNeededInMetricsCall('role', 'dbaas'); + expect(result).toEqual(true); + + result = checkIfFilterNeededInMetricsCall('engine', 'dbaas'); + expect(result).toEqual(false); + + result = checkIfFilterNeededInMetricsCall('role', 'xyz'); // xyz service type + expect(result).toEqual(false); +}); + +it('test checkIfFilterBuilderNeeded method', () => { + let result = checkIfFilterBuilderNeeded({ + ...mockDashboard, + service_type: 'linode', + }); + expect(result).toBe(false); // should be false for linode + + result = checkIfFilterBuilderNeeded({ + ...mockDashboard, + service_type: 'dbaas', + }); + expect(result).toBe(true); // should be true for dbaas, as we have the role filter + + result = checkIfFilterBuilderNeeded({ + ...mockDashboard, + service_type: '', + }); + expect(result).toBe(false); // should be false for empty / undefined case + + result = checkIfFilterBuilderNeeded(undefined); + + expect(result).toBe(false); // should be false for empty / undefined dashboard +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts new file mode 100644 index 00000000000..969b5f1f543 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts @@ -0,0 +1,155 @@ +import { FILTER_CONFIG } from './FilterConfig'; + +import type { DashboardProperties } from '../Dashboard/CloudPulseDashboard'; +import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseMetricsAdditionalFilters } from '../Widget/CloudPulseWidget'; +import type { Dashboard, TimeDuration } from '@linode/api-v4'; + +/** + * This interface is used to get method parameters for this utility + */ +interface ReusableDashboardFilterUtilProps { + /** + * The selected dashboard object + */ + dashboardObj: Dashboard; + /** + * The selected filter values + */ + filterValue: { [key: string]: FilterValueType }; + /** + * The selected resource id + */ + resource: number; + /** + * The selected time duration + */ + timeDuration?: TimeDuration; +} + +/** + * @param props The props required for constructing the dashboard properties + * @returns The properties compatible for rendering dashboard component + */ +export const getDashboardProperties = ( + props: ReusableDashboardFilterUtilProps +): DashboardProperties => { + const { dashboardObj, filterValue, resource, timeDuration } = props; + return { + additionalFilters: constructDimensionFilters({ + dashboardObj, + filterValue, + resource, + }), + dashboardId: dashboardObj.id, + duration: timeDuration ?? { unit: 'min', value: 30 }, + resources: [String(resource)], + savePref: false, + }; +}; + +/** + * @param props The props required for checking the mandatory filter selection + * @returns True if all mandatory filters are selected for a given service type , else false + */ +export const checkMandatoryFiltersSelected = ( + props: ReusableDashboardFilterUtilProps +): boolean => { + const { dashboardObj, filterValue, resource, timeDuration } = props; + const serviceTypeConfig = FILTER_CONFIG.get(dashboardObj.service_type); + + if (!serviceTypeConfig) { + return true; + } + + if (!timeDuration || !resource) { + return false; + } + + return serviceTypeConfig.filters.every(({ configuration }) => { + const { filterKey, neededInServicePage } = configuration; + + // If the filter is not needed, skip it + if (!neededInServicePage) { + return true; + } + + const filterValueForKey = filterValue[filterKey]; + + // Check if the filter value is defined and has a valid selection + return ( + filterValueForKey !== undefined && + (Array.isArray(filterValueForKey) + ? Boolean(filterValueForKey.length) + : true) + ); + }); +}; + +/** + * @param filterKey The current filterKey for which the check needs to made against the config + * @param serviceType The serviceType of the selected dashboard + * @returns True, if the filter is needed in the metrics call, else false + */ +export const checkIfFilterNeededInMetricsCall = ( + filterKey: string, + serviceType: string +): boolean => { + const serviceTypeConfig = FILTER_CONFIG.get(serviceType); + + if (!serviceTypeConfig) { + return false; + } + + return serviceTypeConfig.filters.some(({ configuration }) => { + const { + filterKey: configFilterKey, + isFilterable, + neededInServicePage, + } = configuration; + + return ( + configFilterKey === filterKey && + Boolean(isFilterable) && + neededInServicePage // Indicates if this filter should be included in the metrics call + ); + }); +}; + +/** + * @param props The props required for building the dimension filters + * @returns Array of additional filters to be passed in the metrics api call + */ +export const constructDimensionFilters = ( + props: ReusableDashboardFilterUtilProps +): CloudPulseMetricsAdditionalFilters[] => { + const { dashboardObj, filterValue } = props; + return Object.keys(filterValue) + .filter((key) => + checkIfFilterNeededInMetricsCall(key, dashboardObj.service_type) + ) + .map((key) => ({ + filterKey: key, + filterValue: filterValue[key], + })); +}; + +/** + * @param dashboard The dashboard that needs to be rendered + * @returns True if some filter is needed in service provider page, else false + */ +export const checkIfFilterBuilderNeeded = (dashboard?: Dashboard): boolean => { + if (!dashboard) { + return false; + } + + const serviceTypeConfig = FILTER_CONFIG.get(dashboard.service_type); + + if (!serviceTypeConfig) { + return false; + } + + return serviceTypeConfig.filters.some( + ({ configuration }) => configuration.neededInServicePage + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 2004ba5af41..7c168f4e487 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -141,7 +141,7 @@ export const getAllDashboards = ( let error = ''; let isLoading = false; const data: Dashboard[] = queryResults - .filter((queryResult: UseQueryResult, index) => { + .filter((queryResult, index) => { if (queryResult.isError) { error += serviceTypes[index] + ' ,'; } diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index a7172590950..06847a0573e 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -142,6 +142,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const flags = useFlags(); + const jweTokenExpiryError = 'Token expired'; + /** * * @param zoomInValue: True if zoom in clicked & False if zoom out icon clicked @@ -274,6 +276,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { serviceType, status, unit, + widgetChartType: widget.chart_type, widgetColor: widget.color, }); @@ -282,6 +285,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { today = generatedData.today; currentUnit = generatedData.unit; } + + const metricsApiCallError = error?.[0]?.reason; return ( @@ -338,8 +343,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { { formatData={(data: number) => convertValueToUnit(data, currentUnit)} formatTooltip={(value: number) => formatToolTip(value, unit)} gridSize={widget.size} - loading={isLoading} + loading={isLoading || metricsApiCallError === jweTokenExpiryError} // keep loading until we fetch the refresh token nativeLegend showToday={today} timezone={timezone} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx index dd74c70a1ef..90c36cdf381 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.test.tsx @@ -47,7 +47,7 @@ describe('ComponentRenderer component tests', () => { ); - expect(getByPlaceholderText('Select a Region')).toBeDefined(); + expect(getByPlaceholderText('Select Region')).toBeDefined(); }), it('it should render provided resource filter in props', () => { const resourceProps = linodeFilterConfig?.filters.find( diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx index 4b7ecbd4c29..d4a401825ca 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx @@ -136,7 +136,6 @@ describe('CloudPulseCustomSelect component tests', () => { expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); const keyDown = screen.getByTestId(keyboardArrowDownIcon); fireEvent.click(keyDown); - fireEvent.click(screen.getByText('Test1')); expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 expect(screen.getAllByText('Test2').length).toEqual(1); // since we didn't select this option it should be 1 fireEvent.click(screen.getByText('Test2')); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index f43f0c8c74f..098d3a301e7 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -145,13 +145,13 @@ export const CloudPulseCustomSelect = React.memo( filterKey, handleSelectionChange, isMultiSelect: isMultiSelect ?? false, - options: options ?? [], + options: options || queriedResources || [], savePreferences: savePreferences ?? false, }) ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [savePreferences, options, apiV4QueryKey]); // only execute this use efffect one time or if savePreferences or options or dataApiUrl changes + }, [savePreferences, options, apiV4QueryKey, queriedResources]); // only execute this use efffect one time or if savePreferences or options or dataApiUrl changes const handleChange = ( _: React.SyntheticEvent, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx index 8088e488946..aae73a9acd6 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx @@ -21,7 +21,7 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { expect(getByTestId('region-select')).toBeDefined(); }); - it('it should render successfully when the required props are passed for service type dbass', async () => { + it('it should render successfully when the required props are passed for service type dbaas', async () => { const { getByPlaceholderText } = renderWithTheme( { ); expect(getByPlaceholderText('Select DB Cluster Names')).toBeDefined(); - expect(getByPlaceholderText('Select a Region')).toBeDefined(); + expect(getByPlaceholderText('Select Region')).toBeDefined(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx index 1ff706d78d5..a4f0ce13c4a 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx @@ -66,7 +66,7 @@ describe('CloudPulse Dashboard select', () => { ); expect(getByTestId('cloudpulse-dashboard-select')).toBeInTheDocument(); - expect(getByPlaceholderText('Select a Dashboard')).toBeInTheDocument(); + expect(getByPlaceholderText('Select Dashboard')).toBeInTheDocument(); }), it('Should render dashboard select component with data', () => { renderWithTheme(); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index 6b62eba2ea2..93504ac2882 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -59,7 +59,7 @@ export const CloudPulseDashboardSelect = React.memo( const errorText: string = getErrorText(); - const placeHolder = 'Select a Dashboard'; + const placeHolder = 'Select Dashboard'; // sorts dashboards by service type. Required due to unexpected autocomplete grouping behaviour const getSortedDashboardsList = (options: Dashboard[]): Dashboard[] => { @@ -116,7 +116,7 @@ export const CloudPulseDashboardSelect = React.memo( fullWidth groupBy={(option: Dashboard) => option.service_type} isOptionEqualToValue={(option, value) => option.id === value.id} - label="Select a Dashboard" + label="Select Dashboard" loading={dashboardsLoading || serviceTypesLoading} options={getSortedDashboardsList(dashboardsList ?? [])} placeholder={placeHolder} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index 3245d9c211f..fa0595d6c81 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { themes } from 'src/utilities/theme'; import { RESOURCES } from '../Utils/constants'; import { @@ -58,7 +59,7 @@ export const CloudPulseResourcesSelect = React.memo( React.useEffect(() => { const saveResources = getUserPreferenceObject()?.resources; const defaultResources = Array.isArray(saveResources) - ? Array.of(saveResources).map((resourceId) => String(resourceId)) + ? saveResources.map((resourceId) => String(resourceId)) : undefined; if (resources) { if (defaultResources) { @@ -94,6 +95,9 @@ export const CloudPulseResourcesSelect = React.memo( sx: { maxHeight: '55px', overflow: 'auto', + svg: { + color: themes.light.color.grey3, + }, }, }, hideLabel: true, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx index 5d6bc306e72..e799aecbe2c 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx @@ -7,7 +7,7 @@ describe('Utility Functions', () => { const GMT_november_20_2019_849PM = 1574282998; expect( - generateStartTime('Past 30 Minutes', GMT_november_20_2019_849PM) + generateStartTime('Last 30 Minutes', GMT_november_20_2019_849PM) ).toEqual( DateTime.fromSeconds(GMT_november_20_2019_849PM) .minus({ minutes: 30 }) @@ -15,7 +15,7 @@ describe('Utility Functions', () => { ); expect( - generateStartTime('Past 12 Hours', GMT_november_20_2019_849PM) + generateStartTime('Last 12 Hours', GMT_november_20_2019_849PM) ).toEqual( DateTime.fromSeconds(GMT_november_20_2019_849PM) .minus({ hours: 12 }) @@ -23,7 +23,7 @@ describe('Utility Functions', () => { ); expect( - generateStartTime('Past 24 Hours', GMT_november_20_2019_849PM) + generateStartTime('Last 24 Hours', GMT_november_20_2019_849PM) ).toEqual( DateTime.fromSeconds(GMT_november_20_2019_849PM) .minus({ hours: 24 }) @@ -31,7 +31,7 @@ describe('Utility Functions', () => { ); expect( - generateStartTime('Past 7 Days', GMT_november_20_2019_849PM) + generateStartTime('Last 7 Days', GMT_november_20_2019_849PM) ).toEqual( DateTime.fromSeconds(GMT_november_20_2019_849PM) .minus({ days: 7 }) @@ -39,7 +39,7 @@ describe('Utility Functions', () => { ); expect( - generateStartTime('Past 30 Days', GMT_november_20_2019_849PM) + generateStartTime('Last 30 Days', GMT_november_20_2019_849PM) ).toEqual( DateTime.fromSeconds(GMT_november_20_2019_849PM) .minus({ hours: 24 * 30 }) diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx index cb1eff771e2..e6aeb855f23 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx @@ -24,17 +24,17 @@ export interface CloudPulseTimeRangeSelectProps savePreferences?: boolean; } -const PAST_7_DAYS = 'Past 7 Days'; -const PAST_12_HOURS = 'Past 12 Hours'; -const PAST_24_HOURS = 'Past 24 Hours'; -const PAST_30_DAYS = 'Past 30 Days'; -const PAST_30_MINUTES = 'Past 30 Minutes'; +const PAST_7_DAYS = 'Last 7 Days'; +const PAST_12_HOURS = 'Last 12 Hours'; +const PAST_24_HOURS = 'Last 24 Hours'; +const PAST_30_DAYS = 'Last 30 Days'; +const PAST_30_MINUTES = 'Last 30 Minutes'; export type Labels = - | 'Past 7 Days' - | 'Past 12 Hours' - | 'Past 24 Hours' - | 'Past 30 Days' - | 'Past 30 Minutes'; + | 'Last 7 Days' + | 'Last 12 Hours' + | 'Last 24 Hours' + | 'Last 30 Days' + | 'Last 30 Minutes'; export const CloudPulseTimeRangeSelect = React.memo( (props: CloudPulseTimeRangeSelectProps) => { @@ -56,7 +56,8 @@ export const CloudPulseTimeRangeSelect = React.memo( handleStatsChange(getTimeDurationFromTimeRange(item.value)); } setSelectedTimeRange(item); - }, [handleStatsChange, getDefaultValue]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // need to execute only once, during mounting of this component const handleChange = (item: Item) => { updateGlobalFilterPreference({ @@ -66,6 +67,7 @@ export const CloudPulseTimeRangeSelect = React.memo( if (handleStatsChange) { handleStatsChange(getTimeDurationFromTimeRange(item.value)); } + setSelectedTimeRange(item); // update the state variable to retain latest selections }; return ( @@ -165,11 +167,11 @@ const getTimeDurationFromTimeRange = (label: string): TimeDuration => { } if (label === PAST_7_DAYS) { - return { unit: 'day', value: 7 }; + return { unit: 'days', value: 7 }; } if (label === PAST_30_DAYS) { - return { unit: 'day', value: 30 }; + return { unit: 'days', value: 30 }; } return { unit: 'min', value: 30 }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx index 580d3b78eca..0faa8c693c9 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx @@ -3,11 +3,10 @@ import { createMemoryHistory } from 'history'; import * as React from 'react'; import { Router } from 'react-router-dom'; -import { databaseTypeFactory } from 'src/factories'; +import { accountFactory, databaseTypeFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; -import { mockMatchMedia } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import DatabaseCreate from './DatabaseCreate'; @@ -79,4 +78,80 @@ describe('Database Create', () => { expect(nodeRadioBtns).toHaveTextContent('$60/month $0.09/hr'); expect(nodeRadioBtns).toHaveTextContent('$140/month $0.21/hr'); }); + + it('should display the correct nodes for account with Managed Databases', async () => { + server.use( + http.get('*/account', () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases'], + }); + return HttpResponse.json(account); + }) + ); + + // Mock route history so the Plan Selection table displays prices without requiring a region in the DB Create flow. + const history = createMemoryHistory(); + history.push('databases/create'); + + const { getAllByText, getByTestId } = renderWithTheme( + + + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // default to $0 if no plan is selected + const nodeRadioBtns = getByTestId('database-nodes'); + expect(nodeRadioBtns).toHaveTextContent('$0/month $0/hr'); + + // update node pricing if a plan is selected + const radioBtn = getAllByText('Nanode 1 GB')[0]; + fireEvent.click(radioBtn); + expect(nodeRadioBtns).toHaveTextContent('$60/month $0.09/hr'); + expect(nodeRadioBtns).not.toHaveTextContent('$100/month $0.15/hr'); + expect(nodeRadioBtns).toHaveTextContent('$140/month $0.21/hr'); + }); + + it('should display the correct nodes for account with Managed Databases V2', async () => { + server.use( + http.get('*/account', () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases V2'], + }); + return HttpResponse.json(account); + }) + ); + + // Mock route history so the Plan Selection table displays prices without requiring a region in the DB Create flow. + const history = createMemoryHistory(); + history.push('databases/create'); + + const flags = { + dbaasV2: { + beta: true, + enabled: true, + }, + }; + + const { getAllByText, getByTestId } = renderWithTheme( + + + , + { flags } + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // default to $0 if no plan is selected + const nodeRadioBtns = getByTestId('database-nodes'); + expect(nodeRadioBtns).toHaveTextContent('$0/month $0/hr'); + + // update node pricing if a plan is selected + const radioBtn = getAllByText('Nanode 1 GB')[0]; + fireEvent.click(radioBtn); + expect(nodeRadioBtns).toHaveTextContent('$60/month $0.09/hr'); + expect(nodeRadioBtns).toHaveTextContent('$100/month $0.15/hr'); + expect(nodeRadioBtns).toHaveTextContent('$140/month $0.21/hr'); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 349347ce978..20fadc053c9 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -32,10 +32,11 @@ import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; import { EngineOption } from 'src/features/Databases/DatabaseCreate/EngineOption'; +import { DatabaseLogo } from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; import { databaseEngineMap } from 'src/features/Databases/DatabaseLanding/DatabaseRow'; +import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; -import { useFlags } from 'src/hooks/useFlags'; import { useCreateDatabaseMutation, useDatabaseEnginesQuery, @@ -63,6 +64,9 @@ import type { Item } from 'src/components/EnhancedSelect/Select'; import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; import type { ExtendedIP } from 'src/utilities/ipUtils'; +const V1 = 'Managed Databases'; +const V2 = `Managed Databases V2`; + const useStyles = makeStyles()((theme: Theme) => ({ btnCtn: { alignItems: 'center', @@ -187,6 +191,7 @@ const getEngineOptions = (engines: DatabaseEngine[]) => { }; interface NodePricing { + double: DatabasePriceObject | undefined; multi: DatabasePriceObject | undefined; single: DatabasePriceObject | undefined; } @@ -194,7 +199,6 @@ interface NodePricing { const DatabaseCreate = () => { const { classes } = useStyles(); const history = useHistory(); - const flags = useFlags(); const { data: regionsData, @@ -214,12 +218,15 @@ const DatabaseCreate = () => { isLoading: typesLoading, } = useDatabaseTypesQuery(); + const { isDatabasesV2Beta, isDatabasesV2Enabled } = useIsDatabasesEnabled(); + const formRef = React.useRef(null); const { mutateAsync: createDatabase } = useCreateDatabaseMutation(); const [nodePricing, setNodePricing] = React.useState(); const [createError, setCreateError] = React.useState(); const [ipErrorsFromAPI, setIPErrorsFromAPI] = React.useState(); + const [selectedTab, setSelectedTab] = React.useState(0); const engineOptions = React.useMemo(() => { if (!engines) { @@ -356,33 +363,46 @@ const DatabaseCreate = () => { }); }, [dbtypes, selectedEngine]); - const labelToolTip = ( -
    - Label must: -
      -
    • Begin with an alpha character
    • -
    • Contain only alpha characters or single hyphens
    • -
    • Be between 3 - 32 characters
    • -
    -
    - ); + const nodeOptions = React.useMemo(() => { + const hasDedicated = displayTypes.some( + (type) => type.class === 'dedicated' + ); - const nodeOptions = [ - { - label: ( - - 1 Node {` `} -
    - - {`$${nodePricing?.single?.monthly || 0}/month $${ - nodePricing?.single?.hourly || 0 - }/hr`} - -
    - ), - value: 1, - }, - { + const options = [ + { + label: ( + + 1 Node {` `} +
    + + {`$${nodePricing?.single?.monthly || 0}/month $${ + nodePricing?.single?.hourly || 0 + }/hr`} + +
    + ), + value: 1, + }, + ]; + + if (hasDedicated && selectedTab === 0 && isDatabasesV2Enabled) { + options.push({ + label: ( + + 2 Nodes - High Availability +
    + + {`$${nodePricing?.double?.monthly || 0}/month $${ + nodePricing?.double?.hourly || 0 + }/hr`} + +
    + ), + value: 2, + }); + } + + options.push({ label: ( 3 Nodes - High Availability (recommended) @@ -395,8 +415,25 @@ const DatabaseCreate = () => { ), value: 3, - }, - ]; + }); + + return options; + }, [selectedTab, nodePricing, displayTypes, isDatabasesV2Enabled]); + + const labelToolTip = ( +
    + Label must: +
      +
    • Begin with an alpha character
    • +
    • Contain only alpha characters or single hyphens
    • +
    • Be between 3 - 32 characters
    • +
    +
    + ); + + const handleTabChange = (index: number) => { + setSelectedTab(index); + }; React.useEffect(() => { if (values.type.length === 0 || !dbtypes) { @@ -411,6 +448,9 @@ const DatabaseCreate = () => { const engineType = values.engine.split('/')[0] as Engine; setNodePricing({ + double: type.engines[engineType].find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === 2 + )?.price, multi: type.engines[engineType].find( (cluster: DatabaseClusterSizeObject) => cluster.quantity === 3 )?.price, @@ -453,7 +493,7 @@ const DatabaseCreate = () => { }, ], labelOptions: { - suffixComponent: flags.dbaasV2?.beta ? ( + suffixComponent: isDatabasesV2Beta ? ( ) : null, }, @@ -464,7 +504,10 @@ const DatabaseCreate = () => { {createError && ( - + )} @@ -501,7 +544,7 @@ const DatabaseCreate = () => { setFieldValue('region', region.id)} @@ -519,6 +562,7 @@ const DatabaseCreate = () => { className={classes.selectPlanPanel} data-qa-select-plan error={errors.type} + handleTabChange={handleTabChange} header="Choose a Plan" isCreate regionsData={regionsData} @@ -620,6 +664,7 @@ const DatabaseCreate = () => { Create Database Cluster + {isDatabasesV2Enabled && } ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.test.tsx index bb057b14fac..128121d878f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.test.tsx @@ -30,4 +30,22 @@ describe('Access Controls', () => { screen.getByTestId('ip-removal-confirmation-warning') ).toBeInTheDocument(); }); + + it('Should disable "Manage Access Control" button if disabled = true', () => { + const database = databaseFactory.build(); + const { getByTitle } = renderWithTheme( + + ); + const manageAccessControlBtn = getByTitle('Manage Access Controls'); + expect(manageAccessControlBtn).toBeDisabled(); + }); + + it('Should enable "Manage Access Control" button if disabled = false', () => { + const database = databaseFactory.build(); + const { getByTitle } = renderWithTheme( + + ); + const manageAccessControlBtn = getByTitle('Manage Access Controls'); + expect(manageAccessControlBtn).toBeEnabled(); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx index b60a58ede7f..69d4d2e4d74 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx @@ -1,6 +1,3 @@ -import { Database } from '@linode/api-v4/lib/databases'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -15,10 +12,14 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; import { useDatabaseMutation } from 'src/queries/databases/databases'; -import { ExtendedIP, stringToExtendedIP } from 'src/utilities/ipUtils'; +import { stringToExtendedIP } from 'src/utilities/ipUtils'; import AddAccessControlDrawer from './AddAccessControlDrawer'; +import type { APIError, Database } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; + const useStyles = makeStyles()((theme: Theme) => ({ addAccessControlBtn: { minWidth: 225, @@ -81,12 +82,14 @@ const useStyles = makeStyles()((theme: Theme) => ({ interface Props { database: Database; description?: JSX.Element; + disabled?: boolean; } export const AccessControls = (props: Props) => { const { database: { allow_list: allowList, engine, id }, description, + disabled, } = props; const { classes } = useStyles(); @@ -107,7 +110,7 @@ export const AccessControls = (props: Props) => { const [extendedIPs, setExtendedIPs] = React.useState([]); const { - isLoading: databaseUpdating, + isPending: databaseUpdating, mutateAsync: updateDatabase, } = useDatabaseMutation(engine, id); @@ -163,6 +166,7 @@ export const AccessControls = (props: Props) => { handleClickRemove(accessControl)} /> @@ -195,6 +199,7 @@ export const AccessControls = (props: Props) => { setAddAccessControlDrawerOpen(true)} /> diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupActionMenu.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupActionMenu.tsx index 0960c88c0a2..0d7e8b43b42 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupActionMenu.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupActionMenu.tsx @@ -14,12 +14,13 @@ const useStyles = makeStyles()(() => ({ interface Props { backup: DatabaseBackup; + disabled?: boolean; onRestore: (id: number) => void; } const DatabaseBackupActionMenu = (props: Props) => { const { classes } = useStyles(); - const { backup, onRestore } = props; + const { backup, disabled, onRestore } = props; const actions = [ { @@ -33,6 +34,7 @@ const DatabaseBackupActionMenu = (props: Props) => { {actions.map((thisAction) => ( diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupTableRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupTableRow.tsx index 5de90fb3b78..8cfaf2a5779 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupTableRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupTableRow.tsx @@ -10,10 +10,11 @@ import DatabaseBackupActionMenu from './DatabaseBackupActionMenu'; interface Props { backup: DatabaseBackup; + disabled?: boolean; onRestore: (id: number) => void; } -export const BackupTableRow = ({ backup, onRestore }: Props) => { +export const BackupTableRow = ({ backup, disabled, onRestore }: Props) => { const { created, id } = backup; return ( @@ -23,7 +24,11 @@ export const BackupTableRow = ({ backup, onRestore }: Props) => { {parseAPIDate(created).toRelative()} - + ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx index 0ac9d790b01..7f260fff41a 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx @@ -59,4 +59,56 @@ describe('Database Backups', () => { expect(await findByText('No backups to display.')).toBeInTheDocument(); }); + + it('should disable the restore button if disabled = true', async () => { + const backups = databaseBackupFactory.buildList(7); + + server.use( + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); + }), + http.get('*/databases/:engine/instances/:id', () => { + return HttpResponse.json(databaseFactory.build()); + }), + http.get('*/databases/:engine/instances/:id/backups', () => { + return HttpResponse.json(makeResourcePage(backups)); + }) + ); + + const { findAllByText } = renderWithTheme( + + ); + const buttonSpans = await findAllByText('Restore'); + expect(buttonSpans.length).toEqual(7); + buttonSpans.forEach((span: HTMLSpanElement) => { + const button = span.closest('button'); + expect(button).toBeDisabled(); + }); + }); + + it('should enable the restore button if disabled = false', async () => { + const backups = databaseBackupFactory.buildList(7); + + server.use( + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); + }), + http.get('*/databases/:engine/instances/:id', () => { + return HttpResponse.json(databaseFactory.build()); + }), + http.get('*/databases/:engine/instances/:id/backups', () => { + return HttpResponse.json(makeResourcePage(backups)); + }) + ); + + const { findAllByText } = renderWithTheme( + + ); + const buttonSpans = await findAllByText('Restore'); + expect(buttonSpans.length).toEqual(7); + buttonSpans.forEach((span: HTMLSpanElement) => { + const button = span.closest('button'); + expect(button).toBeEnabled(); + }); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index b753ae5ebb7..2ebd021cc7c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -22,7 +22,12 @@ import { import RestoreFromBackupDialog from './RestoreFromBackupDialog'; import { BackupTableRow } from './DatabaseBackupTableRow'; -export const DatabaseBackups = () => { +interface Props { + disabled?: boolean; +} + +export const DatabaseBackups = (props: Props) => { + const { disabled } = props; const { databaseId, engine } = useParams<{ databaseId: string; engine: Engine; @@ -87,6 +92,7 @@ export const DatabaseBackups = () => { .map((backup) => ( diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx index f4ea747f158..fb6b690e69f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx @@ -1,9 +1,7 @@ -import { Database, DatabaseBackup } from '@linode/api-v4/lib/databases'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { DialogProps } from 'src/components/Dialog/Dialog'; import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; @@ -12,6 +10,9 @@ import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; +import type { Database, DatabaseBackup } from '@linode/api-v4'; +import type { DialogProps } from 'src/components/Dialog/Dialog'; + interface Props extends Omit { backup: DatabaseBackup; database: Database; @@ -27,7 +28,7 @@ export const RestoreFromBackupDialog: React.FC = (props) => { const { error, - isLoading, + isPending, mutateAsync: restore, } = useRestoreFromBackupMutation(database.engine, database.id, backup.id); @@ -54,7 +55,7 @@ export const RestoreFromBackupDialog: React.FC = (props) => { timezone: profile?.timezone, })}`} label={'Database Label'} - loading={isLoading} + loading={isPending} onClick={handleRestoreDatabase} onClose={onClose} open={open} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx index f9ed2e10136..a2e8aef8909 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx @@ -120,6 +120,17 @@ describe('database resize', () => { fireEvent.click(resizeButton); getByText(`Resize Database Cluster ${database.label}?`); }); + + it('Should disable the "Resize Database Cluster" button when disabled = true', async () => { + const { getByTestId, getByText } = renderWithTheme( + + ); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const resizeDatabaseBtn = getByText('Resize Database Cluster').closest( + 'button' + ); + expect(resizeDatabaseBtn).toBeDisabled(); + }); }); describe('should be disabled smaller plans', () => { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx index faa30e72f40..8441c8319f1 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx @@ -1,10 +1,3 @@ -import { - Database, - DatabaseClusterSizeObject, - DatabasePriceObject, - DatabaseType, - Engine, -} from '@linode/api-v4/lib/databases/types'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -16,7 +9,6 @@ import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -import { PlanSelectionType } from 'src/features/components/PlansPanel/types'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { useDatabaseMutation } from 'src/queries/databases/databases'; @@ -30,11 +22,21 @@ import { } from './DatabaseResize.style'; import { DatabaseResizeCurrentConfiguration } from './DatabaseResizeCurrentConfiguration'; +import type { + Database, + DatabaseClusterSizeObject, + DatabasePriceObject, + DatabaseType, + Engine, +} from '@linode/api-v4'; +import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; + interface Props { database: Database; + disabled?: boolean; } -export const DatabaseResize = ({ database }: Props) => { +export const DatabaseResize = ({ database, disabled = false }: Props) => { const history = useHistory(); const [planSelected, setPlanSelected] = React.useState(); @@ -57,7 +59,7 @@ export const DatabaseResize = ({ database }: Props) => { const { error: resizeError, - isLoading: submitInProgress, + isPending: submitInProgress, mutateAsync: updateDatabase, } = useDatabaseMutation(database.engine, database.id); @@ -228,6 +230,7 @@ export const DatabaseResize = ({ database }: Props) => { setPlanSelected(selected)} @@ -242,7 +245,7 @@ export const DatabaseResize = ({ database }: Props) => { setIsResizeConfirmationDialogOpen(true); }} buttonType="primary" - disabled={shouldSubmitBeDisabled} + disabled={shouldSubmitBeDisabled || disabled} type="submit" > Resize Database Cluster diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.test.tsx index a2b561c17f4..d310fbfc8ae 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.test.tsx @@ -23,4 +23,32 @@ describe('DatabaseSettings Component', () => { expect(headings[1].textContent).toBe('Reset Root Password'); expect(headings[2].textContent).toBe('Delete Cluster'); }); + + it('Should disable buttons if disabled = true', () => { + const { getByTitle } = renderWithTheme( + + ); + const disabledButtons = [ + 'Manage Access Controls', + 'Reset Root Password', + 'Save Changes', + ]; + + for (const buttonTitle of disabledButtons) { + const button = getByTitle(buttonTitle); + expect(button).toBeDisabled(); + } + }); + + it('Should enable buttons if disabled = false', () => { + const { getByTitle } = renderWithTheme( + + ); + const enabledButtons = ['Manage Access Controls', 'Reset Root Password']; + + for (const buttonTitle of enabledButtons) { + const button = getByTitle(buttonTitle); + expect(button).toBeEnabled(); + } + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx index 70594ad618c..f7bead67c5c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx @@ -14,10 +14,11 @@ import MaintenanceWindow from './MaintenanceWindow'; interface Props { database: Database; + disabled?: boolean; } export const DatabaseSettings: React.FC = (props) => { - const { database } = props; + const { database, disabled } = props; const { data: profile } = useProfile(); const accessControlCopy = ( @@ -58,11 +59,16 @@ export const DatabaseSettings: React.FC = (props) => { return ( <> - + @@ -75,7 +81,11 @@ export const DatabaseSettings: React.FC = (props) => { sectionTitle="Delete Cluster" /> - + { data-qa-settings-button={buttonText} disabled={disabled} onClick={onClick} + title={buttonText} > {buttonText} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx index 882142160c5..6866fcc99f5 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx @@ -1,4 +1,3 @@ -import { Engine } from '@linode/api-v4/lib/databases'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -7,6 +6,8 @@ import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useDatabaseCredentialsMutation } from 'src/queries/databases/databases'; +import type { Engine } from '@linode/api-v4'; + interface Props { databaseEngine: Engine; databaseID: number; @@ -36,7 +37,7 @@ const renderActions = ( export const DatabaseSettingsResetPasswordDialog: React.FC = (props) => { const { databaseEngine, databaseID, onClose, open } = props; - const { error, isLoading, mutateAsync } = useDatabaseCredentialsMutation( + const { error, isPending, mutateAsync } = useDatabaseCredentialsMutation( databaseEngine, databaseID ); @@ -48,7 +49,7 @@ export const DatabaseSettingsResetPasswordDialog: React.FC = (props) => { return ( ({ interface Props { database: Database; + disabled?: boolean; timezone?: string; } export const MaintenanceWindow = (props: Props) => { - const { database, timezone } = props; + const { database, disabled, timezone } = props; const [maintenanceUpdateError, setMaintenanceUpdateError] = React.useState< APIError[] @@ -217,6 +218,7 @@ export const MaintenanceWindow = (props: Props) => { value={daySelectionMap.find( (thisOption) => thisOption.value === values.day_of_week )} + disabled={disabled} errorText={touched.day_of_week ? errors.day_of_week : undefined} isClearable={false} label="Day of Week" @@ -248,6 +250,7 @@ export const MaintenanceWindow = (props: Props) => { value={hourSelectionMap.find( (thisOption) => thisOption.value === values.hour_of_day )} + disabled={disabled} isClearable={false} label="Time of Day (UTC)" menuPlacement="top" @@ -296,6 +299,7 @@ export const MaintenanceWindow = (props: Props) => { ); } }} + disabled={disabled} > { buttonType="primary" className={classes.sectionButton} compactX - disabled={!formTouched || isSubmitting} + disabled={!formTouched || isSubmitting || disabled} loading={isSubmitting} + title="Save Changes" type="submit" > Save Changes diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index 691f76f5968..10cd81ba3be 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -13,10 +13,11 @@ import ConnectionDetails from './DatabaseSummaryConnectionDetails'; interface Props { database: Database; + disabled?: boolean; } export const DatabaseSummary: React.FC = (props) => { - const { database } = props; + const { database, disabled } = props; const description = ( <> @@ -46,7 +47,11 @@ export const DatabaseSummary: React.FC = (props) => {
    - + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx index 1d17c5312bb..fb48868cbb0 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -7,12 +7,14 @@ import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; +import { Notice } from 'src/components/Notice/Notice'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { useEditableLabelState } from 'src/hooks/useEditableLabelState'; import { useFlags } from 'src/hooks/useFlags'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useDatabaseMutation, useDatabaseQuery, @@ -45,6 +47,12 @@ export const DatabaseDetail = () => { const { mutateAsync: updateDatabase } = useDatabaseMutation(engine, id); + const isDatabasesGrantReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'database', + id, + }); + const { editableLabelError, resetEditableLabel, @@ -150,24 +158,44 @@ export const DatabaseDetail = () => { }, pathname: location.pathname, }} + disabledBreadcrumbEditButton={isDatabasesGrantReadOnly} title={database.label} /> + {isDatabasesGrantReadOnly && ( + + )} + - + - + {flags.databaseResize ? ( - + ) : null} - + diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx index 2d8c4cde06b..7060c149b6b 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx @@ -3,6 +3,8 @@ import { useHistory } from 'react-router-dom'; import DatabaseIcon from 'src/assets/icons/entityIcons/database.svg'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -15,11 +17,16 @@ import { export const DatabaseEmptyState = () => { const { push } = useHistory(); + const isRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_databases', + }); + return ( { sendEvent({ action: 'Click:button', @@ -28,6 +35,11 @@ export const DatabaseEmptyState = () => { }); push('/databases/create'); }, + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Databases', + }), }, ]} gettingStartedGuidesData={gettingStartedGuides} diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx index e88a861e1f6..d23e132ec4b 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx @@ -16,6 +16,18 @@ import { import DatabaseLanding from './DatabaseLanding'; import DatabaseRow from './DatabaseRow'; +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({ data: { restricted: false } }), +})); + +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); + return { + ...actual, + useProfile: queryMocks.useProfile, + }; +}); + beforeAll(() => mockMatchMedia()); const loadingTestId = 'circle-progress'; @@ -98,3 +110,37 @@ describe('Database Table', () => { ).toBeInTheDocument(); }); }); + +describe('Database Landing', () => { + it('should have the "Create Database Cluster" button disabled for restricted users', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: true } }); + + const { container, getByTestId } = renderWithTheme(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const createClusterButton = container.querySelector('button'); + + expect(createClusterButton).toBeInTheDocument(); + expect(createClusterButton).toHaveTextContent('Create Database Cluster'); + expect(createClusterButton).toBeDisabled(); + }); + + it('should have the "Create Database Cluster" button enabled for users with full access', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: false } }); + + const { container, getByTestId } = renderWithTheme(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const createClusterButton = container.querySelector('button'); + + expect(createClusterButton).toBeInTheDocument(); + expect(createClusterButton).toHaveTextContent('Create Database Cluster'); + expect(createClusterButton).not.toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index 0a3b16606fa..99363528efe 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -1,4 +1,3 @@ -import { DatabaseInstance } from '@linode/api-v4/lib/databases'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -15,17 +14,25 @@ import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useDatabasesQuery } from 'src/queries/databases/databases'; import { useInProgressEvents } from 'src/queries/events/events'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + import { DatabaseEmptyState } from './DatabaseEmptyState'; import { DatabaseRow } from './DatabaseRow'; +import type { DatabaseInstance } from '@linode/api-v4/lib/databases'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; + const preferenceKey = 'databases'; const DatabaseLanding = () => { const history = useHistory(); const pagination = usePagination(1, preferenceKey); + const isRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_databases', + }); const { data: events } = useInProgressEvents(); @@ -71,7 +78,15 @@ const DatabaseLanding = () => { return ( history.push('/databases/create')} title="Database Clusters" diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index dd981780f18..51c367ac709 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -29,6 +29,7 @@ describe('useIsDatabasesEnabled', () => { await waitFor(() => { expect(result.current.isDatabasesEnabled).toBe(true); expect(result.current.isDatabasesV1Enabled).toBe(true); + expect(result.current.isDatabasesV2Beta).toBe(false); expect(result.current.isDatabasesV2Enabled).toBe(false); }); }); @@ -54,6 +55,7 @@ describe('useIsDatabasesEnabled', () => { await waitFor(() => { expect(result.current.isDatabasesEnabled).toBe(true); expect(result.current.isDatabasesV1Enabled).toBe(false); + expect(result.current.isDatabasesV2Beta).toBe(true); expect(result.current.isDatabasesV2Enabled).toBe(true); }); }); diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 73e64f1f8e3..8e1e203235c 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -39,6 +39,7 @@ export const useIsDatabasesEnabled = () => { return { isDatabasesEnabled: isDatabasesV1Enabled || isDatabasesV2Enabled, isDatabasesV1Enabled, + isDatabasesV2Beta: isDatabasesV2Enabled && flags.dbaasV2?.beta, isDatabasesV2Enabled, }; } diff --git a/packages/manager/src/features/Domains/DeleteDomain.tsx b/packages/manager/src/features/Domains/DeleteDomain.tsx index 1e38448325b..377e877daef 100644 --- a/packages/manager/src/features/Domains/DeleteDomain.tsx +++ b/packages/manager/src/features/Domains/DeleteDomain.tsx @@ -1,10 +1,11 @@ +import { styled } from '@mui/material/styles'; +import { useSnackbar } from 'notistack'; import * as React from 'react'; + import { Button } from 'src/components/Button/Button'; import { DeletionDialog } from 'src/components/DeletionDialog/DeletionDialog'; -import { styled } from '@mui/material/styles'; import { useDeleteDomainMutation } from 'src/queries/domains'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { useSnackbar } from 'notistack'; export interface DeleteDomainProps { domainId: number; @@ -18,9 +19,9 @@ export const DeleteDomain = (props: DeleteDomainProps) => { const { enqueueSnackbar } = useSnackbar(); const { - mutateAsync: deleteDomain, error, - isLoading, + isPending, + mutateAsync: deleteDomain, } = useDeleteDomainMutation(domainId); const [open, setOpen] = React.useState(false); @@ -42,18 +43,18 @@ export const DeleteDomain = (props: DeleteDomainProps) => { Delete Domain setOpen(false)} onDelete={onDelete} + open={open} + typeToConfirm /> ); diff --git a/packages/manager/src/features/Domains/DisableDomainDialog.tsx b/packages/manager/src/features/Domains/DisableDomainDialog.tsx index 67a15e2bf05..fd770db6110 100644 --- a/packages/manager/src/features/Domains/DisableDomainDialog.tsx +++ b/packages/manager/src/features/Domains/DisableDomainDialog.tsx @@ -1,4 +1,3 @@ -import { Domain } from '@linode/api-v4/lib/domains'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -6,6 +5,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { useUpdateDomainMutation } from 'src/queries/domains'; import { sendDomainStatusChangeEvent } from 'src/utilities/analytics/customEventAnalytics'; +import type { Domain } from '@linode/api-v4'; + interface DisableDomainDialogProps { domain: Domain | undefined; onClose: () => void; @@ -17,7 +18,7 @@ export const DisableDomainDialog = React.memo( const { domain, onClose, open } = props; const { error, - isLoading, + isPending, mutateAsync: updateDomain, reset, } = useUpdateDomainMutation(); @@ -48,7 +49,7 @@ export const DisableDomainDialog = React.memo( { const { error: deleteError, - isLoading: isDeleting, + isPending: isDeleting, mutateAsync: deleteDomain, } = useDeleteDomainMutation(selectedDomain?.id ?? 0); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx index ff05bf764f6..b021e5fa729 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx @@ -1,8 +1,7 @@ -import { CreateTransferPayload } from '@linode/api-v4/lib/entity-transfers'; import Grid from '@mui/material/Unstable_Grid2'; +import { useQueryClient } from '@tanstack/react-query'; import { curry } from 'ramda'; import * as React from 'react'; -import { QueryClient, useQueryClient } from '@tanstack/react-query'; import { useHistory } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -20,15 +19,15 @@ import { import { LinodeTransferTable } from './LinodeTransferTable'; import { TransferCheckoutBar } from './TransferCheckoutBar'; import { TransferHeader } from './TransferHeader'; -import { - TransferableEntity, - defaultTransferState, - transferReducer, -} from './transferReducer'; +import { defaultTransferState, transferReducer } from './transferReducer'; + +import type { TransferableEntity } from './transferReducer'; +import type { CreateTransferPayload } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; export const EntityTransfersCreate = () => { const { push } = useHistory(); - const { error, isLoading, mutateAsync: createTransfer } = useCreateTransfer(); + const { error, isPending, mutateAsync: createTransfer } = useCreateTransfer(); const queryClient = useQueryClient(); /** @@ -115,7 +114,7 @@ export const EntityTransfersCreate = () => { handleSubmit={(payload) => handleCreateTransfer(payload, queryClient) } - isCreating={isLoading} + isCreating={isPending} removeEntities={removeEntitiesFromTransfer} selectedEntities={state} /> diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx index 55d4c031f37..13f40092654 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx @@ -1,7 +1,5 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; -import { Theme } from '@mui/material/styles'; -import useMediaQuery from '@mui/material/useMediaQuery'; import { useTheme } from '@mui/material'; +import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -15,7 +13,10 @@ import { useSpecificTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; import { TransferTable } from './TransferTable'; -import { Entity, TransferEntity } from './transferReducer'; + +import type { Entity, TransferEntity } from './transferReducer'; +import type { Linode } from '@linode/api-v4/lib/linodes'; +import type { Theme } from '@mui/material/styles'; interface Props { handleRemove: (linodesToRemove: string[]) => void; @@ -77,6 +78,7 @@ export const LinodeTransferTable = React.memo((props: Props) => { page={pagination.page} pageSize={pagination.pageSize} requestPage={pagination.handlePageChange} + searchText={searchText} toggleSelectAll={toggleSelectAll} > void; + searchText: string; toggleSelectAll: (isToggled: boolean) => void; } @@ -36,6 +37,7 @@ export const TransferTable = React.memo((props: Props) => { page, pageSize, requestPage, + searchText, toggleSelectAll, } = props; @@ -57,6 +59,7 @@ export const TransferTable = React.memo((props: Props) => { label="Search by label" onSearch={handleSearch} placeholder="Search by label" + value={searchText} /> diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/CreateTransferSuccessDialog.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/CreateTransferSuccessDialog.tsx index e6b0ebb1156..76ff9ae2422 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/CreateTransferSuccessDialog.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/CreateTransferSuccessDialog.tsx @@ -92,7 +92,7 @@ This token will expire ${parseAPIDate(transfer.expiry).toLocaleString( @@ -115,7 +115,7 @@ This token will expire ${parseAPIDate(transfer.expiry).toLocaleString( { - describe('formatEventWithUsername utility', () => { - it('it should return a message for an event without a username unchanged', () => { - const message = 'a message'; - expect( - formatEventWithUsername('linode_boot' as EventAction, null, message) - ).toEqual(message); - }); - - it('should append the username to a normal event', () => { - const message = 'a message'; - expect( - formatEventWithUsername( - 'linode_boot' as EventAction, - 'test-user-001', - message - ) - ).toEqual('a message by **test-user-001**.'); - }); - - it('should append the username to a normal event', () => { - const message = 'a message'; - expect( - formatEventWithUsername( - 'lassie_reboot' as EventAction, - 'test-user-001', - message - ) - ).toEqual(message); - }); - - it('should not reappend a username to any event already containing one', () => { - const message = 'a message by **test-user-001**'; - expect( - formatEventWithUsername( - 'resize_disk' as EventAction, - 'test-user-001', - message - ) - ).toEqual(message); - }); - }); - - describe('formatEventWithAppendedText utility', () => { - it('should append text to the end of an existing event message', () => { - const message = 'a message by **test-user-001**.'; - const appendedText = 'Text to append.'; - const mockEvent = eventFactory.build({ - username: 'test-user-001', - }); - - expect( - formatEventWithAppendedText(mockEvent, message, appendedText) - ).toEqual(`${message} ${appendedText}`); - }); - - it('should append hyperlinked text to the end of an existing event message', () => { - const message = 'a message.'; - const appendedText = 'Link to append.'; - const link = 'https://cloud.linode.com'; - const mockEvent = eventFactory.build({ - username: null, - }); - - expect( - formatEventWithAppendedText(mockEvent, message, appendedText, link) - ).toEqual( - `${message} ${appendedText}.` - ); - }); - }); - - describe('maybeRemoveTrailingPeriod', () => { - it('should remove trailing periods', () => { - expect(maybeRemoveTrailingPeriod('hello world.')).toBe('hello world'); - expect(maybeRemoveTrailingPeriod('hello wor..ld')).toBe('hello wor..ld'); - expect(maybeRemoveTrailingPeriod('hello world')).toBe('hello world'); - expect(maybeRemoveTrailingPeriod('hello. world')).toBe('hello. world'); - }); - }); -}); diff --git a/packages/manager/src/features/Events/Event.helpers.ts b/packages/manager/src/features/Events/Event.helpers.ts deleted file mode 100644 index aa3a0aee266..00000000000 --- a/packages/manager/src/features/Events/Event.helpers.ts +++ /dev/null @@ -1,61 +0,0 @@ -// TODO eventMessagesV2: delete when flag is removed -import { Event, EventAction } from '@linode/api-v4/lib/account'; - -export const maybeRemoveTrailingPeriod = (string: string) => { - const lastChar = string[string.length - 1]; - if (lastChar === '.') { - return string.substr(0, string.length - 1); - } - return string; -}; - -export const ACTIONS_WITHOUT_USERNAMES: EventAction[] = [ - 'entity_transfer_accept', - 'entity_transfer_accept_recipient', - 'entity_transfer_cancel', - 'entity_transfer_create', - 'entity_transfer_fail', - 'entity_transfer_stale', - 'lassie_reboot', - 'community_like', - 'community_mention', - 'community_question_reply', -]; - -export const formatEventWithUsername = ( - action: EventAction, - username: null | string, - message: string -) => { - return username && - !ACTIONS_WITHOUT_USERNAMES.includes(action) && - !message.includes(`by **${username}**`) - ? /* - The event message for Lassie events already includes "by the Lassie Watchdog service" and event messages - formatted with appended text may already include a username, so we don't want to add "by Linode" after that. - */ - `${maybeRemoveTrailingPeriod(message)} by **${username}**.` - : message; -}; - -export const formatEventWithAppendedText = ( - event: Event, - message: string, - text: string, - link?: string -) => { - if (!message) { - return ''; - } - const messageWithUsername = formatEventWithUsername( - event.action, - event.username, - message - ); - const appendedMessage = link - ? `${text}` - : text; - return `${messageWithUsername} ${maybeRemoveTrailingPeriod( - appendedMessage - )}.`; -}; diff --git a/packages/manager/src/features/Events/EventMessage.tsx b/packages/manager/src/features/Events/EventMessage.tsx deleted file mode 100644 index d555fb04eb1..00000000000 --- a/packages/manager/src/features/Events/EventMessage.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -interface MessageLinkEntity { - message: null | string; -} - -/** - * Renders a message with inline code blocks. - * Meant to be used in the context of an event message. - * This component is only used to render {e.message} in the case of potential ticks we want to render as
    .
    - */
    -export const EventMessage = (props: MessageLinkEntity) => {
    -  const { message } = props;
    -
    -  if (!message) {
    -    return null;
    -  }
    -
    -  return formatTicks(message);
    -};
    -
    -const formatTicks = (message: string): JSX.Element => {
    -  const parts = message.split(/(`[^`]*`)/g);
    -
    -  return (
    -    <>
    -      {parts.map((part, i) =>
    -        part.startsWith('`') && part.endsWith('`') ? (
    -          {part.slice(1, -1)}
    -        ) : (
    -          part
    -        )
    -      )}
    -    
    -  );
    -};
    -
    -const StyledPre = styled('pre')(({ theme }) => ({
    -  backgroundColor: theme.name === 'dark' ? '#222' : '#f4f4f4',
    -  borderRadius: 4,
    -  display: 'inline',
    -  fontSize: '0.75rem',
    -  padding: '0.15rem 0.25rem',
    -}));
    diff --git a/packages/manager/src/features/Events/EventRow.styles.ts b/packages/manager/src/features/Events/EventRow.styles.ts
    index 08624554348..cf2743a97ed 100644
    --- a/packages/manager/src/features/Events/EventRow.styles.ts
    +++ b/packages/manager/src/features/Events/EventRow.styles.ts
    @@ -1,10 +1,14 @@
    +// @TODO: delete file once Gravatar is sunset
     import { styled } from '@mui/material/styles';
     
    +import { fadeIn } from 'src/styles/keyframes';
    +
     import { GravatarByUsername } from '../../components/GravatarByUsername';
     
     export const StyledGravatar = styled(GravatarByUsername, {
       label: 'StyledGravatar',
     })(({ theme }) => ({
    +  animation: `${fadeIn} .2s ease-in-out forwards`,
       height: theme.spacing(3),
       width: theme.spacing(3),
     }));
    diff --git a/packages/manager/src/features/Events/EventRow.test.tsx b/packages/manager/src/features/Events/EventRow.test.tsx
    index 15c8ce4cc3b..311b12b1a74 100644
    --- a/packages/manager/src/features/Events/EventRow.test.tsx
    +++ b/packages/manager/src/features/Events/EventRow.test.tsx
    @@ -1,34 +1,34 @@
    -// TODO eventMessagesV2: delete when flag is removed
    -import { DateTime } from 'luxon';
     import * as React from 'react';
     
    -import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers';
    +import { eventFactory } from 'src/factories';
    +import {
    +  renderWithTheme,
    +  resizeScreenSize,
    +  wrapWithTableBody,
    +} from 'src/utilities/testHelpers';
     
    -import { Row, RowProps } from './EventRow';
    +import { EventRow } from './EventRow';
     
    -const message = 'this is a message.';
    -const props: RowProps = {
    -  action: 'linode_boot',
    -  message,
    -  timestamp: DateTime.now(),
    -  type: 'linode',
    -  username: null,
    -};
    +import type { Event } from '@linode/api-v4/lib/account';
     
    -describe('EventRow component', () => {
    -  it('should render an event with a message', () => {
    -    const { getByText } = renderWithTheme(
    -      wrapWithTableBody()
    -    );
    -
    -    expect(getByText(message)).toBeInTheDocument();
    +describe('EventRow', () => {
    +  const mockEvent: Event = eventFactory.build({
    +    action: 'tfa_enabled',
    +    status: 'notification',
    +    username: 'test_user',
       });
     
    -  it("shouldn't render events without a message", () => {
    -    const emptyMessageProps = { ...props, message: undefined };
    -    const { container } = renderWithTheme(
    -      wrapWithTableBody()
    +  it('displays the correct data', () => {
    +    resizeScreenSize(1600);
    +    const { getByRole } = renderWithTheme(
    +      wrapWithTableBody()
         );
    -    expect(container.closest('tr')).toBeNull();
    +
    +    expect(
    +      getByRole('cell', {
    +        name: /Two-factor authentication has been enabled./i,
    +      })
    +    ).toBeInTheDocument();
    +    expect(getByRole('cell', { name: /test_user/i })).toBeInTheDocument();
       });
     });
    diff --git a/packages/manager/src/features/Events/EventRow.tsx b/packages/manager/src/features/Events/EventRow.tsx
    index 1df3708daf4..40acfd1a063 100644
    --- a/packages/manager/src/features/Events/EventRow.tsx
    +++ b/packages/manager/src/features/Events/EventRow.tsx
    @@ -1,19 +1,25 @@
    -// TODO eventMessagesV2: delete when flag is removed
    -import { Event, EventAction } from '@linode/api-v4/lib/account';
    -import { DateTime } from 'luxon';
    -import { pathOr } from 'ramda';
    +import { useTheme } from '@mui/material';
     import * as React from 'react';
     
    +import { Avatar } from 'src/components/Avatar/Avatar';
    +import { BarPercent } from 'src/components/BarPercent';
    +import { Box } from 'src/components/Box';
     import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
    +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar';
     import { Hidden } from 'src/components/Hidden';
    -import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/HighlightedMarkdown';
     import { TableCell } from 'src/components/TableCell';
     import { TableRow } from 'src/components/TableRow';
    -import { generateEventMessage } from 'src/features/Events/eventMessageGenerator';
    +import { useProfile } from 'src/queries/profile/profile';
     import { getEventTimestamp } from 'src/utilities/eventUtils';
    -import { getLinkForEvent } from 'src/utilities/getEventsActionLink';
     
     import { StyledGravatar } from './EventRow.styles';
    +import {
    +  formatProgressEvent,
    +  getEventMessage,
    +  getEventUsername,
    +} from './utils';
    +
    +import type { Event } from '@linode/api-v4/lib/account';
     
     interface EventRowProps {
       entityId?: number;
    @@ -21,71 +27,74 @@ interface EventRowProps {
     }
     
     export const EventRow = (props: EventRowProps) => {
    -  const { entityId, event } = props;
    -  const link = getLinkForEvent(event.action, event.entity);
    -  const type = pathOr('linode', ['entity', 'type'], event);
    +  const { event } = props;
    +  const theme = useTheme();
       const timestamp = getEventTimestamp(event);
    -
    -  const rowProps = {
    +  const { action, message, username } = {
         action: event.action,
    -    entityId,
    -    link,
    -    message: generateEventMessage(event),
    -    timestamp,
    -    type,
    -    username: event.username,
    +    message: getEventMessage(event),
    +    username: getEventUsername(event),
       };
    +  const { data: profile } = useProfile();
     
    -  return ;
    -};
    -
    -export interface RowProps {
    -  action: EventAction;
    -  link?: (() => void) | string;
    -  message?: string | void;
    -  status?: string;
    -  timestamp: DateTime;
    -  type:
    -    | 'database'
    -    | 'domain'
    -    | 'linode'
    -    | 'nodebalancer'
    -    | 'placement_group'
    -    | 'stackscript'
    -    | 'subnet'
    -    | 'volume'
    -    | 'vpc';
    -  username: null | string;
    -}
    -
    -export const Row = (props: RowProps) => {
    -  const { action, message, timestamp, username } = props;
    -
    -  /** Some event types may not be handled by our system (or new types
    -   * may be added). Filter these out so we don't display blank messages to the user.
    -   */
       if (!message) {
         return null;
       }
     
    +  const { progressEventDisplay, showProgress } = formatProgressEvent(event);
    +
       return (
         
    +      
    +        {message}
    +        {showProgress && (
    +          
    +        )}
    +      
           
    -        
    -          
    +        
    +          
    +            
    +              }
    +              gravatar={
    +                
    +              }
    +              height={24}
    +              width={24}
    +            />
    +            {username}
    +          
             
           
    -      
    -        
    -      
           
    -        {timestamp.toRelative()}
    +        {progressEventDisplay}
    +        {username && (
    +          
    +            
    + + by {username} + +
    + )}
    diff --git a/packages/manager/src/features/Events/EventRowV2.test.tsx b/packages/manager/src/features/Events/EventRowV2.test.tsx deleted file mode 100644 index 357876fadf0..00000000000 --- a/packages/manager/src/features/Events/EventRowV2.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// TODO eventMessagesV2: rename to EventRow.test.tsx when flag is removed -import * as React from 'react'; - -import { eventFactory } from 'src/factories'; -import { - renderWithTheme, - resizeScreenSize, - wrapWithTableBody, -} from 'src/utilities/testHelpers'; - -import { EventRowV2 } from './EventRowV2'; - -import type { Event } from '@linode/api-v4/lib/account'; - -describe('EventRowV2', () => { - const mockEvent: Event = eventFactory.build({ - action: 'tfa_enabled', - status: 'notification', - username: 'test_user', - }); - - it('displays the correct data', () => { - resizeScreenSize(1600); - const { getByRole } = renderWithTheme( - wrapWithTableBody() - ); - - expect( - getByRole('cell', { - name: /Two-factor authentication has been enabled./i, - }) - ).toBeInTheDocument(); - expect(getByRole('cell', { name: 'test_user' })).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/Events/EventRowV2.tsx b/packages/manager/src/features/Events/EventRowV2.tsx deleted file mode 100644 index b949f5b34ca..00000000000 --- a/packages/manager/src/features/Events/EventRowV2.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// TODO eventMessagesV2: rename to EventRow.tsx when flag is removed -import * as React from 'react'; - -import { BarPercent } from 'src/components/BarPercent'; -import { Box } from 'src/components/Box'; -import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import { Hidden } from 'src/components/Hidden'; -import { TableCell } from 'src/components/TableCell'; -import { TableRow } from 'src/components/TableRow'; -import { getEventTimestamp } from 'src/utilities/eventUtils'; - -import { StyledGravatar } from './EventRow.styles'; -import { - formatProgressEvent, - getEventMessage, - getEventUsername, -} from './utils'; - -import type { Event } from '@linode/api-v4/lib/account'; - -interface EventRowProps { - entityId?: number; - event: Event; -} - -export const EventRowV2 = (props: EventRowProps) => { - const { event } = props; - const timestamp = getEventTimestamp(event); - const { action, message, username } = { - action: event.action, - message: getEventMessage(event), - username: getEventUsername(event), - }; - - if (!message) { - return null; - } - - const { progressEventDisplay, showProgress } = formatProgressEvent(event); - - return ( - - - {message} - {showProgress && ( - - )} - - - - - - {username} - - - - - {progressEventDisplay} - {username && ( - -
    - - by {username} - -
    - )} -
    - - - - - -
    - ); -}; diff --git a/packages/manager/src/features/Events/EventsLanding.styles.ts b/packages/manager/src/features/Events/EventsLanding.styles.ts index f0b853ebc4b..cbad399bfbc 100644 --- a/packages/manager/src/features/Events/EventsLanding.styles.ts +++ b/packages/manager/src/features/Events/EventsLanding.styles.ts @@ -7,7 +7,6 @@ import { Typography } from 'src/components/Typography'; export const StyledTableCell = styled(TableCell, { label: 'StyledTableCell', })(({ theme }) => ({ - color: theme.textColors.tableHeader, fontFamily: theme.font.bold, fontSize: '0.875rem', })); @@ -15,7 +14,6 @@ export const StyledTableCell = styled(TableCell, { export const StyledLabelTableCell = styled(TableCell, { label: 'StyledLabelTableCell', })(({ theme }) => ({ - color: theme.textColors.tableHeader, fontFamily: theme.font.bold, fontSize: '0.875rem', minWidth: 200, diff --git a/packages/manager/src/features/Events/EventsLanding.test.tsx b/packages/manager/src/features/Events/EventsLanding.test.tsx index 16c41f3a3c4..588eccaa406 100644 --- a/packages/manager/src/features/Events/EventsLanding.test.tsx +++ b/packages/manager/src/features/Events/EventsLanding.test.tsx @@ -45,10 +45,19 @@ describe('EventsLanding', () => { http.get('*/events', () => HttpResponse.json(makeResourcePage([event]))) ); - const { findByText } = renderWithTheme(); + const { findByRole } = renderWithTheme(); + + const messageCell = await findByRole('cell', { + name: /volume.*my-volume.*is being.*created/i, + }); + + expect(messageCell).toHaveTextContent(/volume/i); + expect(messageCell).toHaveTextContent(/my-volume/i); + expect(messageCell).toHaveTextContent(/is being/i); + expect(messageCell).toHaveTextContent(/created/i); - await findByText('my-volume'); - await findByText('is being created by user.', { exact: false }); + const volumeLink = await findByRole('link', { name: /my-volume/i }); + expect(volumeLink).toHaveAttribute('href', '/volumes'); }); it('renders a message when there are no more events to load', async () => { diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 68cf12add5d..1838fd761b4 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -11,11 +11,9 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; import { EventRow } from './EventRow'; -import { EventRowV2 } from './EventRowV2'; import { StyledH1Header, StyledLabelTableCell, @@ -32,7 +30,6 @@ interface Props { export const EventsLanding = (props: Props) => { const { emptyMessage, entityId } = props; - const flags = useFlags(); const filter: Filter = { ...EVENTS_LIST_FILTER }; @@ -71,21 +68,13 @@ export const EventsLanding = (props: Props) => { } else { return ( <> - {events?.map((event) => - flags.eventMessagesV2 ? ( - - ) : ( - - ) - )} + {events?.map((event) => ( + + ))} {isFetchingNextPage && ( { - {!flags.eventMessagesV2 && ( - - - - )} Event - {flags.eventMessagesV2 && ( - - - User - - - )} + + + User + + Relative Date diff --git a/packages/manager/src/features/Events/EventsMessages.stories.tsx b/packages/manager/src/features/Events/EventsMessages.stories.tsx index 35ad2beb4cf..ae39354f47c 100644 --- a/packages/manager/src/features/Events/EventsMessages.stories.tsx +++ b/packages/manager/src/features/Events/EventsMessages.stories.tsx @@ -22,7 +22,7 @@ const event: Event = eventFactory.build({ type: 'linode', url: 'https://google.com', }, - message: 'message with a `ticked` word', + message: 'message with a `ticked` word - please contact Support', secondary_entity: { id: 1, label: '{secondary entity}', diff --git a/packages/manager/src/features/Events/EventMessage.test.tsx b/packages/manager/src/features/Events/FormattedEventMessage.test.tsx similarity index 50% rename from packages/manager/src/features/Events/EventMessage.test.tsx rename to packages/manager/src/features/Events/FormattedEventMessage.test.tsx index cf0b7ad69f9..62463801db6 100644 --- a/packages/manager/src/features/Events/EventMessage.test.tsx +++ b/packages/manager/src/features/Events/FormattedEventMessage.test.tsx @@ -2,18 +2,20 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { EventMessage } from './EventMessage'; +import { FormattedEventMessage } from './FormattedEventMessage'; -describe('EventMessage', () => { +describe('FormattedEventMessage', () => { it('renders null when message is null', () => { - const { queryByRole } = renderWithTheme(); + const { queryByRole } = renderWithTheme( + + ); expect(queryByRole('pre')).toBeNull(); }); it('renders message without ticks as plain text', () => { const { getByText, queryByRole } = renderWithTheme( - + ); expect(getByText('Hello, world!')).toBeInTheDocument(); @@ -22,10 +24,22 @@ describe('EventMessage', () => { it('renders message with ticks as inline code blocks', () => { const { container, getByText } = renderWithTheme( - + ); expect(getByText(/Hello,/)).toBeInTheDocument(); expect(container.querySelector('pre')).toHaveTextContent('world'); }); + + it('converts contact support links', () => { + const { container, getByText } = renderWithTheme( + + ); + + expect(getByText('contact Support')).toBeInTheDocument(); + expect(container.querySelector('a')).toHaveAttribute( + 'href', + '/support/tickets' + ); + }); }); diff --git a/packages/manager/src/features/Events/FormattedEventMessage.tsx b/packages/manager/src/features/Events/FormattedEventMessage.tsx new file mode 100644 index 00000000000..6a0677a7d75 --- /dev/null +++ b/packages/manager/src/features/Events/FormattedEventMessage.tsx @@ -0,0 +1,66 @@ +import { styled } from '@mui/material/styles'; +import * as React from 'react'; + +import { SupportLink } from 'src/components/SupportLink'; + +interface MessageLinkEntity { + message: null | string; +} + +/** + * Renders a message with inline code blocks. + * Meant to be used in the context of an event message. + * This component is only used to render {e.message} as JSX in order to: + * - render ticks as
    .
    + *  - render "contact support" strings as .
    + */
    +export const FormattedEventMessage = (props: MessageLinkEntity) => {
    +  const { message } = props;
    +
    +  if (!message) {
    +    return null;
    +  }
    +
    +  return formatMessage(message);
    +};
    +
    +const formatMessage = (message: string): JSX.Element => {
    +  const parts = message.split(/(`[^`]*`)/g);
    +  const supportLinkMatch = /(contact support)/i;
    +
    +  return (
    +    <>
    +      {parts.map((part, i) => {
    +        let formattedPart: JSX.Element | string = part;
    +
    +        if (part.startsWith('`') && part.endsWith('`')) {
    +          formattedPart = (
    +            {part.slice(1, -1)}
    +          );
    +        }
    +
    +        if (part.match(supportLinkMatch)) {
    +          const [before, linkText, after] = part.split(supportLinkMatch);
    +
    +          formattedPart = (
    +            
    +              {before}
    +              
    +              {after}
    +            
    +          );
    +        }
    +
    +        return formattedPart;
    +      })}
    +    
    +  );
    +};
    +
    +const StyledPre = styled('pre')(({ theme }) => ({
    +  backgroundColor: theme.name === 'dark' ? '#222' : '#f4f4f4',
    +  borderRadius: 4,
    +  display: 'inline',
    +  fontSize: '0.75rem',
    +  padding: '0.15rem 0.25rem',
    +}));
    diff --git a/packages/manager/src/features/Events/asyncToasts.test.tsx b/packages/manager/src/features/Events/asyncToasts.test.tsx
    new file mode 100644
    index 00000000000..0ed1b44a26a
    --- /dev/null
    +++ b/packages/manager/src/features/Events/asyncToasts.test.tsx
    @@ -0,0 +1,140 @@
    +import { createToast } from './asyncToasts';
    +
    +describe('createToast', () => {
    +  it('should handle case with both failure and success options as true or empty objects', () => {
    +    const trueOptions = { failure: true, success: true };
    +    const emptyObjectOptions = { failure: {}, success: {} };
    +
    +    [trueOptions, emptyObjectOptions].forEach((options) => {
    +      const result = createToast(options);
    +      const expected = {
    +        failure: {
    +          message: expect.any(Function),
    +        },
    +        success: {
    +          message: expect.any(Function),
    +        },
    +      };
    +
    +      expect(result).toEqual(expected);
    +    });
    +  });
    +
    +  it('should handle case with only failure option as true or empty object or with success as false', () => {
    +    const scenarios = [
    +      { failure: true },
    +      { failure: {} },
    +      { failure: true, success: false },
    +      { failure: {}, success: false },
    +    ];
    +
    +    scenarios.forEach((options) => {
    +      const result = createToast(options);
    +      const expected = {
    +        failure: {
    +          message: expect.any(Function),
    +        },
    +      };
    +
    +      expect(result).toEqual(expected);
    +    });
    +  });
    +
    +  it('should handle case with only success option as true or empty object or with failure as false', () => {
    +    const scenarios = [
    +      { success: true },
    +      { success: {} },
    +      { failure: false, success: true },
    +      { failure: false, success: {} },
    +    ];
    +
    +    scenarios.forEach((options) => {
    +      const result = createToast(options);
    +      const expected = {
    +        success: {
    +          message: expect.any(Function),
    +        },
    +      };
    +
    +      expect(result).toEqual(expected);
    +    });
    +  });
    +
    +  it('should return an empty object if both failure and success are false or not provided', () => {
    +    const falseOptions = { failure: false, success: false };
    +    const emptyOptions = {};
    +    [falseOptions, emptyOptions].forEach((options) => {
    +      const result = createToast(options);
    +
    +      expect(result).toEqual({});
    +    });
    +  });
    +
    +  it('should handle cases with specific values for only failure or success options', () => {
    +    // Only the failure options with specific values
    +    const failureOnlyOptions = {
    +      failure: { persist: true },
    +    };
    +    const result1 = createToast(failureOnlyOptions);
    +    const expected1 = {
    +      failure: {
    +        message: expect.any(Function),
    +        persist: true,
    +      },
    +    };
    +    expect(result1).toEqual(expected1);
    +
    +    // Only the success options with specific values
    +    const successOnlyOptions = {
    +      success: { invertVariant: true, persist: false },
    +    };
    +    const result2 = createToast(successOnlyOptions);
    +    const expected2 = {
    +      success: {
    +        invertVariant: true,
    +        message: expect.any(Function),
    +        persist: false,
    +      },
    +    };
    +    expect(result2).toEqual(expected2);
    +  });
    +
    +  it('should handle case with both failure and success options with specific values', () => {
    +    const options1 = {
    +      failure: { invertVariant: true, persist: true },
    +      success: { invertVariant: true, persist: false },
    +    };
    +    const result1 = createToast(options1);
    +    const expected1 = {
    +      failure: {
    +        invertVariant: true,
    +        message: expect.any(Function),
    +        persist: true,
    +      },
    +      success: {
    +        invertVariant: true,
    +        message: expect.any(Function),
    +        persist: false,
    +      },
    +    };
    +    expect(result1).toEqual(expected1);
    +
    +    const options2 = {
    +      failure: { persist: true },
    +      success: { invertVariant: true },
    +    };
    +
    +    const result2 = createToast(options2);
    +    const expected2 = {
    +      failure: {
    +        message: expect.any(Function),
    +        persist: true,
    +      },
    +      success: {
    +        invertVariant: true,
    +        message: expect.any(Function),
    +      },
    +    };
    +    expect(result2).toEqual(expected2);
    +  });
    +});
    diff --git a/packages/manager/src/features/Events/asyncToasts.tsx b/packages/manager/src/features/Events/asyncToasts.tsx
    new file mode 100644
    index 00000000000..5c3cdc9553e
    --- /dev/null
    +++ b/packages/manager/src/features/Events/asyncToasts.tsx
    @@ -0,0 +1,95 @@
    +import { getEventMessage } from './utils';
    +
    +import type { Event, EventAction } from '@linode/api-v4';
    +
    +interface ToastMessage {
    +  /**
    +   * If true, the toast will be displayed with an error variant for success messages \
    +   * or a success variant for error messages.
    +   */
    +  invertVariant?: boolean;
    +  message: ((event: Event) => JSX.Element | null | string | undefined) | string;
    +  persist?: boolean;
    +}
    +
    +interface Toast {
    +  failure?: ToastMessage;
    +  success?: ToastMessage;
    +}
    +
    +type Toasts = {
    +  [key in EventAction]?: Toast;
    +};
    +
    +interface ToastOption {
    +  invertVariant?: boolean;
    +  persist?: boolean;
    +}
    +
    +interface ToastOptions {
    +  failure?: ToastOption | boolean;
    +  success?: ToastOption | boolean;
    +}
    +
    +export const createToast = (options: ToastOptions) => {
    +  const toastConfig: Toast = {};
    +
    +  const getToastMessage = (option: ToastOption | boolean): ToastMessage => {
    +    const message: ToastMessage['message'] = (e) => getEventMessage(e);
    +
    +    if (typeof option === 'boolean') {
    +      return { message };
    +    }
    +
    +    return {
    +      message,
    +      ...(option.invertVariant !== undefined && {
    +        invertVariant: option.invertVariant,
    +      }),
    +      ...(option.persist !== undefined && { persist: option.persist }),
    +    };
    +  };
    +
    +  if (options.failure) {
    +    toastConfig.failure = getToastMessage(options.failure);
    +  }
    +
    +  if (options.success) {
    +    toastConfig.success = getToastMessage(options.success);
    +  }
    +
    +  return toastConfig;
    +};
    +
    +/**
    + * This constant defines toast notifications that will be displayed
    + * when our events polling system gets a new event.
    + *
    + * Use this feature to notify users of *asynchronous tasks* such as migrating a Linode.
    + *
    + * DO NOT use this feature to notify the user of tasks like changing the label of a Linode.
    + * Toasts for that can be handled at the time of making the PUT request.
    + */
    +export const toasts: Toasts = {
    +  backups_restore: createToast({ failure: { persist: true } }),
    +  disk_delete: createToast({ failure: false, success: true }),
    +  disk_imagize: createToast({ failure: { persist: true }, success: true }),
    +  disk_resize: createToast({ failure: { persist: true }, success: true }),
    +  image_delete: createToast({ failure: true, success: true }),
    +  image_upload: createToast({ failure: { persist: true }, success: true }),
    +  linode_clone: createToast({ failure: true, success: true }),
    +  linode_migrate: createToast({ failure: true, success: true }),
    +  linode_migrate_datacenter: createToast({ failure: true, success: true }),
    +  linode_resize: createToast({ failure: true, success: true }),
    +  linode_snapshot: createToast({ failure: { persist: true } }),
    +  longviewclient_create: createToast({ failure: true, success: true }),
    +  tax_id_invalid: createToast({
    +    failure: true,
    +    success: { invertVariant: true, persist: true },
    +  }),
    +  volume_attach: createToast({ failure: true, success: true }),
    +  volume_create: createToast({ failure: true, success: true }),
    +  volume_delete: createToast({ failure: true, success: true }),
    +  volume_detach: createToast({ failure: true, success: true }),
    +  volume_migrate: createToast({ failure: true, success: true }),
    +};
    diff --git a/packages/manager/src/features/Events/constants.ts b/packages/manager/src/features/Events/constants.ts
    index 8d07b3e0273..8db0910bff0 100644
    --- a/packages/manager/src/features/Events/constants.ts
    +++ b/packages/manager/src/features/Events/constants.ts
    @@ -1,135 +1,4 @@
    -// TODO eventMessagesV2: delete when flag is removed
    -import type { Event } from '@linode/api-v4';
    -
    -export const EVENT_ACTIONS: Event['action'][] = [
    -  'account_settings_update',
    -  'account_update',
    -  'backups_cancel',
    -  'backups_enable',
    -  'backups_restore',
    -  'community_like',
    -  'community_mention',
    -  'community_question_reply',
    -  'credit_card_updated',
    -  'database_backup_restore',
    -  'database_create',
    -  'database_credentials_reset',
    -  'database_delete',
    -  'database_resize_create',
    -  'database_resize',
    -  'database_update_failed',
    -  'database_update',
    -  'disk_create',
    -  'disk_delete',
    -  'disk_duplicate',
    -  'disk_imagize',
    -  'disk_resize',
    -  'disk_update',
    -  'domain_create',
    -  'domain_delete',
    -  'domain_record_create',
    -  'domain_record_delete',
    -  'domain_record_updated',
    -  'domain_update',
    -  'entity_transfer_accept',
    -  'entity_transfer_cancel',
    -  'entity_transfer_create',
    -  'entity_transfer_fail',
    -  'entity_transfer_stale',
    -  'firewall_create',
    -  'firewall_delete',
    -  'firewall_device_add',
    -  'firewall_device_remove',
    -  'firewall_disable',
    -  'firewall_enable',
    -  'firewall_update',
    -  'host_reboot',
    -  'image_delete',
    -  'image_update',
    -  'image_upload',
    -  'lassie_reboot',
    -  'linode_addip',
    -  'linode_boot',
    -  'linode_clone',
    -  'linode_config_create',
    -  'linode_config_delete',
    -  'linode_config_update',
    -  'linode_create',
    -  'linode_delete',
    -  'linode_deleteip',
    -  'linode_migrate_datacenter_create',
    -  'linode_migrate_datacenter',
    -  'linode_migrate',
    -  'linode_mutate_create',
    -  'linode_mutate',
    -  'linode_reboot',
    -  'linode_rebuild',
    -  'linode_resize_create',
    -  'linode_resize_warm_create',
    -  'linode_resize',
    -  'linode_shutdown',
    -  'linode_snapshot',
    -  'linode_update',
    -  'lke_node_create',
    -  'longviewclient_create',
    -  'longviewclient_delete',
    -  'longviewclient_update',
    -  'nodebalancer_config_create',
    -  'nodebalancer_config_delete',
    -  'nodebalancer_config_update',
    -  'nodebalancer_create',
    -  'nodebalancer_delete',
    -  'nodebalancer_update',
    -  'password_reset',
    -  'placement_group_assign',
    -  'placement_group_became_compliant',
    -  'placement_group_became_non_compliant',
    -  'placement_group_create',
    -  'placement_group_delete',
    -  'placement_group_unassign',
    -  'placement_group_update',
    -  'profile_update',
    -  'stackscript_create',
    -  'stackscript_delete',
    -  'stackscript_publicize',
    -  'stackscript_revise',
    -  'stackscript_update',
    -  'subnet_create',
    -  'subnet_delete',
    -  'subnet_update',
    -  'tax_id_invalid',
    -  'tax_id_valid',
    -  'tfa_disabled',
    -  'tfa_enabled',
    -  'ticket_attachment_upload',
    -  'ticket_update',
    -  'token_create',
    -  'token_delete',
    -  'token_update',
    -  'user_ssh_key_add',
    -  'user_ssh_key_delete',
    -  'user_ssh_key_update',
    -  'volume_attach',
    -  'volume_clone',
    -  'volume_create',
    -  'volume_delete',
    -  'volume_detach',
    -  'volume_migrate_scheduled',
    -  'volume_migrate',
    -  'volume_resize',
    -  'volume_update',
    -  'vpc_create',
    -  'vpc_delete',
    -  'vpc_update',
    -];
    -
    -export const EVENT_STATUSES: Event['status'][] = [
    -  'scheduled',
    -  'started',
    -  'finished',
    -  'failed',
    -  'notification',
    -];
    +import type { Event, EventAction } from '@linode/api-v4';
     
     export const ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS: Event['action'][] = [
       'linode_resize',
    @@ -155,6 +24,19 @@ export const ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS: Event['action'][] = [
       'database_resize',
     ];
     
    +export const ACTIONS_WITHOUT_USERNAMES: EventAction[] = [
    +  'entity_transfer_accept',
    +  'entity_transfer_accept_recipient',
    +  'entity_transfer_cancel',
    +  'entity_transfer_create',
    +  'entity_transfer_fail',
    +  'entity_transfer_stale',
    +  'lassie_reboot',
    +  'community_like',
    +  'community_mention',
    +  'community_question_reply',
    +];
    +
     /**
      * This is our base filter for GETing /v4/account/events.
      *
    diff --git a/packages/manager/src/features/Events/eventMessageGenerator.test.ts b/packages/manager/src/features/Events/eventMessageGenerator.test.ts
    deleted file mode 100644
    index fc31ac3e226..00000000000
    --- a/packages/manager/src/features/Events/eventMessageGenerator.test.ts
    +++ /dev/null
    @@ -1,188 +0,0 @@
    -import { Event } from '@linode/api-v4/lib/account';
    -
    -import { entityFactory, eventFactory } from 'src/factories/events';
    -
    -import {
    -  applyBolding,
    -  applyLinking,
    -  eventMessageCreators,
    -  generateEventMessage,
    -  safeSecondaryEntityLabel,
    -} from './eventMessageGenerator';
    -
    -beforeEach(() => {
    -  vi.spyOn(console, 'error').mockImplementation(() => {});
    -  vi.spyOn(console, 'log').mockImplementation(() => {});
    -});
    -
    -describe('Event message generation', () => {
    -  describe('getEventMessage', () => {
    -    it('should filter unknown events', () => {
    -      const mockEvent = {
    -        action: '__unknown__',
    -        status: 'started',
    -      };
    -      const result = generateEventMessage(mockEvent as Event);
    -
    -      expect(result).toBe('__unknown__');
    -    });
    -
    -    it('should filter mangled events', () => {
    -      const mockEvent = {
    -        action: 'linode_reboot',
    -        entity: null,
    -        status: 'scheduled',
    -      };
    -      const result = generateEventMessage(mockEvent as Event);
    -
    -      expect(result).toBe('');
    -    });
    -
    -    it('should call the message generator with the event', () => {
    -      const mockEvent = {
    -        action: 'linode_reboot',
    -        entity: { label: 'test-linode-123' },
    -        status: 'scheduled',
    -      };
    -
    -      /** Mock the message creator */
    -      eventMessageCreators.linode_reboot.scheduled = vi.fn();
    -
    -      /** Invoke the function. */
    -      generateEventMessage(mockEvent as Event);
    -
    -      /** Check that the mocked creator was called w/ the mock event. */
    -      expect(eventMessageCreators.linode_reboot.scheduled).toHaveBeenCalledWith(
    -        mockEvent
    -      );
    -    });
    -  });
    -
    -  describe('safeSecondaryEventLabel', () => {
    -    it('should return a correct message if the secondary entity is present', () => {
    -      const mockEventWithSecondaryEntity = eventFactory.build({
    -        secondary_entity: entityFactory.build({ label: 'secondary-entity' }),
    -      });
    -      expect(
    -        safeSecondaryEntityLabel(
    -          mockEventWithSecondaryEntity,
    -          'booted with',
    -          'booted'
    -        )
    -      ).toMatch('booted with secondary-entity');
    -    });
    -
    -    it('should return a safe default if the secondary entity is null', () => {
    -      const mockEventWithoutSecondaryEntity = eventFactory.build({
    -        secondary_entity: null,
    -      });
    -      expect(
    -        safeSecondaryEntityLabel(
    -          mockEventWithoutSecondaryEntity,
    -          'booted with',
    -          'booted'
    -        )
    -      ).toMatch('booted');
    -      expect(
    -        safeSecondaryEntityLabel(
    -          mockEventWithoutSecondaryEntity,
    -          'booted with',
    -          'booted'
    -        )
    -      ).not.toMatch('booted with');
    -    });
    -  });
    -
    -  describe('apply linking to labels', () => {
    -    const entity = entityFactory.build({ id: 10, label: 'foo' });
    -
    -    it('should return empty string if message is falsy', () => {
    -      const mockEvent = eventFactory.build({ action: 'domain_record_create' });
    -      const message = null;
    -      const result = applyLinking(mockEvent, message as any); // casting since message is a required prop
    -
    -      expect(result).toEqual('');
    -    });
    -
    -    it('should replace entity label with link if entity and link exist', async () => {
    -      const mockEvent = eventFactory.build({ entity });
    -      const message = 'created entity foo';
    -      const result = applyLinking(mockEvent, message);
    -
    -      expect(result).toEqual(`created entity foo `);
    -    });
    -
    -    it('should replace secondary entity label with link if entity and link exist', () => {
    -      const mockEvent = eventFactory.build({ entity });
    -      const message = 'created secondary_entity for foo';
    -      const result = applyLinking(mockEvent, message);
    -
    -      expect(result).toEqual(
    -        `created secondary_entity for foo `
    -      );
    -    });
    -
    -    it('should not replace entity label if label is inside backticks', () => {
    -      const mockEvent1 = eventFactory.build({ entity });
    -      const message1 = 'created `foo`';
    -      const result1 = applyLinking(mockEvent1, message1);
    -
    -      expect(result1).toEqual('created `foo`');
    -
    -      // In this case we also should not replace the label
    -      // This tests the regex strength of the function so
    -      // so "something.com" does not return a match for "mail.something.com"
    -      const mockEvent2 = eventFactory.build(
    -        entityFactory.build({ id: 10, label: 'something.com' })
    -      );
    -      const message2 = 'created `mail.something.com`';
    -      const result2 = applyLinking(mockEvent2, message2);
    -
    -      expect(result2).toEqual('created `mail.something.com`');
    -    });
    -
    -    it('should escape regex special characters', () => {
    -      const mockEvent = eventFactory.build({
    -        entity: entityFactory.build({
    -          id: 10,
    -          label: 'Weird label with special characters.(?)',
    -        }),
    -      });
    -      const message = 'created entity Weird label with special characters.(?)';
    -      const result = applyLinking(mockEvent, message);
    -
    -      // eslint-disable-next-line xss/no-mixed-html
    -      expect(result).toEqual(
    -        'created entity Weird label with special characters.(?) '
    -      );
    -    });
    -
    -    it('should work when label is null', () => {
    -      const mockEvent = eventFactory.build({
    -        entity: entityFactory.build({
    -          id: 10,
    -          label: null,
    -        }),
    -      });
    -      const message = 'created entity Null label';
    -      const result = applyLinking(mockEvent, message);
    -
    -      expect(result).toEqual('created entity Null label');
    -    });
    -  });
    -
    -  describe('apply bolding to messages', () => {
    -    it('should return empty for null message', () => {
    -      const message = applyBolding('');
    -      expect(message).toEqual('');
    -    });
    -    it('should correctly apply bolding', () => {
    -      const message1 = applyBolding('booted should be bolded, but not xbooted');
    -      const message2 = applyBolding('reboot booted rebooted');
    -      const message3 = applyBolding('no bolding here: reboots');
    -      expect(message1).toEqual('**booted** should be bolded, but not xbooted');
    -      expect(message2).toEqual('**reboot** **booted** **rebooted**');
    -      expect(message3).toEqual('no bolding here: reboots');
    -    });
    -  });
    -});
    diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts
    deleted file mode 100644
    index f26830b00d3..00000000000
    --- a/packages/manager/src/features/Events/eventMessageGenerator.ts
    +++ /dev/null
    @@ -1,1145 +0,0 @@
    -// TODO eventMessagesV2: delete when flag is removed
    -import { Event } from '@linode/api-v4/lib/account';
    -import { path } from 'ramda';
    -
    -import { isProductionBuild } from 'src/constants';
    -import { reportException } from 'src/exceptionReporting';
    -import {
    -  formatEventWithAppendedText,
    -  formatEventWithUsername,
    -} from 'src/features/Events/Event.helpers';
    -import { escapeRegExp } from 'src/utilities/escapeRegExp';
    -import { getLinkForEvent } from 'src/utilities/getEventsActionLink';
    -
    -import type { FirewallDeviceEntityType } from '@linode/api-v4';
    -
    -export type EventMessageCreator = (e: Event) => string;
    -
    -export interface CreatorsForStatus {
    -  failed?: EventMessageCreator;
    -  finished?: EventMessageCreator;
    -  notification?: EventMessageCreator;
    -  scheduled?: EventMessageCreator;
    -  started?: EventMessageCreator;
    -}
    -
    -export const safeSecondaryEntityLabel = (
    -  e: Event,
    -  text: string,
    -  fallback: string = ''
    -) => {
    -  const label = e?.secondary_entity?.label;
    -  return label ? `${text} ${label}` : fallback;
    -};
    -
    -const secondaryFirewallEntityNameMap: Record<
    -  FirewallDeviceEntityType,
    -  string
    -> = {
    -  linode: 'Linode',
    -  nodebalancer: 'NodeBalancer',
    -};
    -
    -export const eventMessageCreators: { [index: string]: CreatorsForStatus } = {
    -  account_agreement_eu_model: {
    -    notification: () => 'The EU Model Contract has been signed.',
    -  },
    -  account_promo_apply: {
    -    notification: (e) => `A promo code was applied to your account.`,
    -  },
    -  account_settings_update: {
    -    notification: (e) => `Your account settings have been updated.`,
    -  },
    -  account_update: {
    -    notification: (e) => `Your account settings have been updated.`,
    -  },
    -  backups_cancel: {
    -    notification: (e) => `Backups have been canceled for ${e.entity!.label}.`,
    -  },
    -  backups_enable: {
    -    notification: (e) => `Backups have been enabled for ${e.entity!.label}.`,
    -  },
    -  backups_restore: {
    -    failed: (e) =>
    -      `${formatEventWithAppendedText(
    -        e,
    -        `Backup restoration failed for ${e.entity!.label}.`,
    -        'Learn more about limits and considerations',
    -        'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations'
    -      )}`,
    -    finished: (e) => `Backup restoration completed for ${e.entity!.label}.`,
    -    notification: (e) => `Backup restoration completed for ${e.entity!.label}.`,
    -    scheduled: (e) => `Backup restoration scheduled for ${e.entity!.label}`,
    -    started: (e) => `Backup restoration started for ${e.entity!.label}`,
    -  },
    -  community_like: {
    -    notification: (e) =>
    -      e.entity?.label
    -        ? `${e.entity.label}`
    -        : `There has been a like on your community post.`,
    -  },
    -  community_mention: {
    -    notification: (e) =>
    -      e.entity?.label
    -        ? `You have been mentioned in a Community post: ${e.entity.label}.`
    -        : `You have been mentioned in a Community post.`,
    -  },
    -  community_question_reply: {
    -    notification: (e) =>
    -      e.entity?.label
    -        ? `There has been a reply to your thread: ${e.entity.label}.`
    -        : `There has been a reply to your thread.`,
    -  },
    -  credit_card_updated: {
    -    notification: (e) => `Credit card information has been updated.`,
    -  },
    -  database_backup_restore: {
    -    notification: (e) =>
    -      `Database ${e.entity!.label} has been restored from a backup.`,
    -  },
    -  database_create: {
    -    failed: (e) => `Database ${e.entity!.label} could not be created.`,
    -    finished: (e) => `Database ${e.entity!.label} has been created.`,
    -    notification: (e) =>
    -      `Database ${e.entity!.label} is scheduled for creation.`,
    -    scheduled: (e) => `Database ${e.entity!.label} is scheduled for creation.`,
    -    started: (e) => `Database ${e.entity!.label} is being created.`,
    -  },
    -  database_credentials_reset: {
    -    notification: (e) =>
    -      `Database ${e.entity!.label}'s credentials have been reset.`,
    -  },
    -  database_degraded: {
    -    notification: (e) => `Database ${e.entity!.label} has been degraded.`,
    -  },
    -  database_delete: {
    -    notification: (e) => `Database ${e.entity!.label} has been deleted.`,
    -  },
    -  database_failed: {
    -    notification: (e) => `Database ${e.entity!.label} failed to update.`,
    -  },
    -  database_low_disk_space: {
    -    finished: (e) =>
    -      `Low disk space alert for database ${e.entity!.label} has cleared.`,
    -    notification: (e) => `Database ${e.entity!.label} has low disk space.`,
    -  },
    -  database_resize: {
    -    failed: (e) => `Database ${e.entity!.label} could not be resized.`,
    -    finished: (e) => `Database ${e.entity!.label} has been resized.`,
    -    scheduled: (e) => `Database ${e.entity!.label} is scheduled for resizing.`,
    -    started: (e) => `Database ${e.entity!.label} is resizing.`,
    -  },
    -  database_resize_create: {
    -    notification: (e) => `Database ${e.entity!.label} scheduled to be resized.`,
    -  },
    -  database_scale: {
    -    failed: (e) => `Database ${e.entity!.label} could not be resized.`,
    -    finished: (e) => `Database ${e.entity!.label} has been resized.`,
    -    scheduled: (e) => `Database ${e.entity!.label} is scheduled for resizing.`,
    -    started: (e) => `Database ${e.entity!.label} is resizing.`,
    -  },
    -  database_update: {
    -    finished: (e) => `Database ${e.entity!.label} has been updated.`,
    -  },
    -  database_update_failed: {
    -    notification: (e) => `Database ${e.entity!.label} failed to update.`,
    -  },
    -  disk_create: {
    -    failed: (e) =>
    -      `${safeSecondaryEntityLabel(
    -        e,
    -        'Disk',
    -        'A disk'
    -      )} could not be added to Linode ${e.entity!.label}.`,
    -    finished: (e) =>
    -      `${safeSecondaryEntityLabel(
    -        e,
    -        'Disk',
    -        'A disk'
    -      )} has been added to Linode ${e.entity!.label}.`,
    -    scheduled: (e) =>
    -      `${safeSecondaryEntityLabel(
    -        e,
    -        'Disk',
    -        'A disk'
    -      )} is being added to Linode ${e.entity!.label}.`,
    -    started: (e) =>
    -      `${safeSecondaryEntityLabel(e, 'Disk', 'A disk')} is being added to ${
    -        e.entity!.label
    -      }.`,
    -    // notification: e => ``,
    -  },
    -  disk_delete: {
    -    failed: (e) =>
    -      `${safeSecondaryEntityLabel(e, 'Disk', 'A disk')} on Linode ${
    -        e.entity!.label
    -      } could not be deleted.`,
    -    finished: (e) =>
    -      `${safeSecondaryEntityLabel(e, 'Disk', 'A disk')} on Linode ${
    -        e.entity!.label
    -      } has been deleted`,
    -    scheduled: (e) =>
    -      `${safeSecondaryEntityLabel(e, 'Disk', 'A disk')} on Linode ${
    -        e.entity!.label
    -      } is scheduled for deletion.`,
    -    started: (e) =>
    -      `${safeSecondaryEntityLabel(e, 'Disk', 'A disk')} on Linode ${
    -        e.entity!.label
    -      } is being deleted.`,
    -    // notification: e => ``,
    -  },
    -  disk_duplicate: {
    -    failed: (e) =>
    -      `A disk on Linode ${e.entity!.label} could not be duplicated.`,
    -    finished: (e) => `A disk on Linode ${e.entity!.label} has been duplicated`,
    -    scheduled: (e) =>
    -      `A disk on Linode ${e.entity!.label} is scheduled for duplication.`,
    -    started: (e) => `A disk on Linode ${e.entity!.label} is being duplicated.`,
    -    // notification: e => ``,
    -  },
    -  disk_imagize: {
    -    failed: (e) =>
    -      `${formatEventWithAppendedText(
    -        e,
    -        `There was a problem creating Image ${
    -          e?.secondary_entity?.label ?? ''
    -        }.`,
    -        'Learn more about image technical specifications',
    -        'https://www.linode.com/docs/products/tools/images/#technical-specifications'
    -      )}`,
    -    finished: (e) =>
    -      `Image ${e?.secondary_entity?.label + ' ' ?? ''}has been created.`,
    -    scheduled: (e) =>
    -      `Image ${e?.secondary_entity?.label + ' ' ?? ''}scheduled for creation.`,
    -    started: (e) =>
    -      `Image ${e?.secondary_entity?.label + ' ' ?? ''}being created.`,
    -  },
    -  disk_resize: {
    -    failed: (e) =>
    -      `${formatEventWithAppendedText(
    -        e,
    -        `A disk on Linode ${e.entity!.label} could not be resized.`,
    -        'Learn more',
    -        'https://www.linode.com/docs/products/compute/compute-instances/guides/disks-and-storage/'
    -      )}`,
    -    finished: (e) => `A disk on Linode ${e.entity!.label} has been resized`,
    -    scheduled: (e) => `A disk on ${e.entity!.label} is scheduled for resizing.`,
    -    started: (e) => `A disk on Linode ${e.entity!.label} is being resized.`,
    -    // notification: e => ``,
    -  },
    -  disk_update: {
    -    notification: (e) =>
    -      `${safeSecondaryEntityLabel(
    -        e,
    -        'Disk',
    -        'A disk'
    -      )} has been updated on Linode ${e.entity!.label}.`,
    -  },
    -  dns_record_create: {
    -    notification: (e) => `DNS record has been added to ${e.entity!.label}`,
    -  },
    -  dns_record_delete: {
    -    notification: (e) => `DNS record has been removed from ${e.entity!.label}`,
    -  },
    -  dns_zone_create: {
    -    notification: (e) => `DNS zone has been added to ${e.entity!.label}`,
    -  },
    -  dns_zone_delete: {
    -    notification: (e) => `DNS zone has been removed from ${e.entity!.label}`,
    -  },
    -  domain_create: {
    -    notification: (e) => `Domain ${e.entity!.label} has been created.`,
    -  },
    -  domain_delete: {
    -    notification: (e) => `Domain ${e.entity!.label} has been deleted.`,
    -  },
    -  domain_import: {
    -    notification: (e) => `Domain ${e.entity?.label ?? ''} has been imported.`,
    -  },
    -  domain_record_create: {
    -    notification: (e) => `${e.message} added to ${e.entity!.label}`,
    -  },
    -  domain_record_delete: {
    -    notification: (e) =>
    -      `A domain record has been deleted from ${e.entity!.label}`,
    -  },
    -  domain_record_update: {
    -    notification: (e) => `${e.message} updated for ${e.entity!.label}`,
    -  },
    -  domain_update: {
    -    notification: (e) => `Domain ${e.entity!.label} has been updated.`,
    -  },
    -  entity_transfer_accept: {
    -    notification: (_) => `A service transfer has been accepted.`,
    -  },
    -  entity_transfer_accept_recipient: {
    -    notification: (_) => `You have accepted a service transfer.`,
    -  },
    -  entity_transfer_cancel: {
    -    notification: (_) => `A service transfer has been canceled.`,
    -  },
    -  entity_transfer_create: {
    -    notification: (_) => `A service transfer has been created.`,
    -  },
    -  entity_transfer_fail: {
    -    notification: (_) => `Service transfer failed.`,
    -  },
    -  entity_transfer_stale: {
    -    notification: (_) => `A service transfer token has expired.`,
    -  },
    -  firewall_create: {
    -    notification: (e) => `Firewall ${e.entity?.label ?? ''} has been created.`,
    -  },
    -  firewall_delete: {
    -    notification: (e) => `Firewall ${e.entity?.label ?? ''} has been deleted.`,
    -  },
    -  firewall_device_add: {
    -    notification: (e) => {
    -      if (e.secondary_entity?.type) {
    -        const secondaryEntityName =
    -          secondaryFirewallEntityNameMap[
    -            e.secondary_entity.type as FirewallDeviceEntityType
    -          ];
    -        return `${secondaryEntityName} ${
    -          e.secondary_entity?.label
    -        } has been added to Firewall ${e.entity?.label ?? ''}.`;
    -      }
    -      return `A device has been added to Firewall ${e.entity?.label ?? ''}.`;
    -    },
    -  },
    -  firewall_device_remove: {
    -    notification: (e) => {
    -      if (e.secondary_entity?.type) {
    -        const secondaryEntityName =
    -          secondaryFirewallEntityNameMap[
    -            e.secondary_entity.type as FirewallDeviceEntityType
    -          ];
    -        return `${secondaryEntityName} ${
    -          e.secondary_entity?.label
    -        } has been removed from Firewall ${e.entity?.label ?? ''}.`;
    -      }
    -      return `A device has been removed from Firewall ${
    -        e.entity?.label ?? ''
    -      }.`;
    -    },
    -  },
    -  firewall_disable: {
    -    notification: (e) => `Firewall ${e.entity?.label ?? ''} has been disabled.`,
    -  },
    -  firewall_enable: {
    -    notification: (e) => `Firewall ${e.entity?.label ?? ''} has been enabled.`,
    -  },
    -  firewall_rules_update: {
    -    notification: (e) =>
    -      `Firewall rules have been updated on ${e.entity?.label ?? ''}.`,
    -  },
    -  firewall_update: {
    -    notification: (e) => `Firewall ${e.entity?.label ?? ''} has been updated.`,
    -  },
    -  host_reboot: {
    -    failed: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } could not be booted (Host initiated restart).`,
    -    finished: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } has been booted (Host initiated restart).`,
    -    scheduled: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } is scheduled to reboot (Host initiated restart).`,
    -    started: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } is being booted (Host initiated restart).`,
    -  },
    -  image_delete: {
    -    failed: (e) => `There was a problem deleting ${e.entity?.label ?? ''}.`,
    -    finished: (e) => `Image ${e.entity?.label ?? ''} has been deleted.`,
    -    notification: (e) => `Image ${e.entity?.label ?? ''} has been deleted.`,
    -    scheduled: (e) => `Image ${e.entity?.label ?? ''} scheduled for deletion.`,
    -    started: (e) => `Image ${e.entity?.label ?? ''} is being deleted.`,
    -  },
    -  image_update: {
    -    notification: (e) => `Image ${e.entity?.label ?? ''} has been updated.`,
    -  },
    -  image_upload: {
    -    failed: (e) =>
    -      `There was a problem uploading ${
    -        e.entity?.label ?? ''
    -      }: ${e?.message?.replace(/(\d+)/g, '$1 MB')}.`,
    -    finished: (e) => `Image ${e.entity?.label ?? ''} has been uploaded.`,
    -    notification: (e) => `Image ${e.entity?.label ?? ''} has been uploaded.`,
    -    scheduled: (e) => `Image ${e.entity?.label ?? ''} scheduled for upload.`,
    -    started: (e) => `Image ${e.entity?.label ?? ''} is being uploaded.`,
    -  },
    -  ipaddress_update: {
    -    notification: (e) => `An IP address has been updated on your account.`,
    -  },
    -  ipv6pool_add: {
    -    notification: () => 'An IPv6 range has been added.',
    -  },
    -  ipv6pool_delete: {
    -    notification: () => 'An IPv6 range has been deleted.',
    -  },
    -  /**
    -   * For these events, we expect an entity (the Linode being rebooted)
    -   * but there have been cases where an event has come through with
    -   * entity === null. Handle them safely.
    -   */
    -  lassie_reboot: {
    -    failed: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } could not be booted by the Lassie watchdog service.`,
    -    finished: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } has been booted by the Lassie watchdog service.`,
    -    scheduled: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } is scheduled to be rebooted by the Lassie watchdog service.`,
    -    started: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } is being booted by the Lassie watchdog service.`,
    -  },
    -  linode_addip: {
    -    notification: (e) => `An IP has been added to ${e.entity!.label}.`,
    -  },
    -  linode_boot: {
    -    failed: (e) =>
    -      `Linode ${e.entity!.label} could not be ${safeSecondaryEntityLabel(
    -        e,
    -        'booted with config',
    -        'booted'
    -      )}.`,
    -    finished: (e) =>
    -      `Linode ${e.entity!.label} has been ${safeSecondaryEntityLabel(
    -        e,
    -        'booted with config',
    -        'booted'
    -      )}.`,
    -    scheduled: (e) =>
    -      `Linode ${e.entity!.label} is scheduled to ${safeSecondaryEntityLabel(
    -        e,
    -        'boot with config',
    -        'boot'
    -      )}.`,
    -    started: (e) =>
    -      `Linode ${e.entity!.label} is being ${safeSecondaryEntityLabel(
    -        e,
    -        'booted with config',
    -        'booted'
    -      )}.`,
    -  },
    -  linode_clone: {
    -    failed: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } could not be cloned${safeSecondaryEntityLabel(e, ' to', '')}.`,
    -    finished: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } has been cloned${safeSecondaryEntityLabel(e, ' to', '')}.`,
    -    notification: (e) =>
    -      `Linode ${e.entity?.label ?? ''} is scheduled to be cloned.`,
    -    scheduled: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } is scheduled to be cloned${safeSecondaryEntityLabel(e, ' to', '')}.`,
    -    started: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } is being cloned${safeSecondaryEntityLabel(e, ' to', '')}.`,
    -  },
    -  linode_config_create: {
    -    notification: (e) =>
    -      `${safeSecondaryEntityLabel(
    -        e,
    -        'Config',
    -        'A config'
    -      )} has been created on Linode ${e.entity!.label}.`,
    -  },
    -  linode_config_delete: {
    -    notification: (e) =>
    -      `${safeSecondaryEntityLabel(
    -        e,
    -        'Config',
    -        'A config'
    -      )} has been deleted on Linode ${e.entity!.label}.`,
    -  },
    -  linode_config_update: {
    -    notification: (e) =>
    -      `${safeSecondaryEntityLabel(
    -        e,
    -        'Config',
    -        'A config'
    -      )} has been updated on Linode ${e.entity!.label}.`,
    -  },
    -  linode_create: {
    -    failed: (e) => `Linode ${e.entity!.label} could not be created.`,
    -    finished: (e) => `Linode ${e.entity!.label} has been created.`,
    -    scheduled: (e) => `Linode ${e.entity!.label} is scheduled for creation.`,
    -    started: (e) => `Linode ${e.entity!.label} is being created.`,
    -  },
    -  linode_delete: {
    -    failed: (e) => `Linode ${e.entity!.label} could not be deleted.`,
    -    finished: (e) => `Linode ${e.entity!.label} has been deleted.`,
    -    notification: (e) => `Linode ${e.entity!.label} has been deleted.`,
    -    scheduled: (e) => `Linode ${e.entity!.label} is scheduled for deletion.`,
    -    started: (e) => `Linode ${e.entity!.label} is being deleted.`,
    -  },
    -  linode_deleteip: {
    -    notification: (e) => `An IP was deleted from Linode ${e.entity!.id}`,
    -  },
    -  linode_migrate: {
    -    failed: (e) => `Migration failed for Linode ${e.entity?.label ?? ''}.`,
    -    finished: (e) => `Linode ${e.entity?.label ?? ''} has been migrated.`,
    -    scheduled: (e) =>
    -      `Linode ${e.entity?.label ?? ''} is scheduled for migration.`,
    -    started: (e) => `Linode ${e.entity?.label ?? ''} is being migrated.`,
    -  },
    -  // These are the same as the messages for `linode_migrate`.
    -  linode_migrate_datacenter: {
    -    failed: (e) => `Migration failed for Linode ${e.entity?.label ?? ''}.`,
    -    finished: (e) => `Linode ${e.entity?.label ?? ''} has been migrated.`,
    -    scheduled: (e) =>
    -      `Linode ${e.entity?.label ?? ''} is scheduled for migration.`,
    -    started: (e) => `Linode ${e.entity?.label ?? ''} is being migrated.`,
    -  },
    -  // This event type isn't currently being displayed, but I added a message here just in case.
    -  linode_migrate_datacenter_create: {
    -    notification: (e) =>
    -      `Migration for Linode ${e.entity!.label} has been initiated.`,
    -  },
    -  linode_mutate: {
    -    failed: (e) => `Linode ${e.entity?.label ?? ''} could not be upgraded.`,
    -    finished: (e) => `Linode ${e.entity?.label ?? ''} has been upgraded.`,
    -    notification: (e) => `Linode ${e.entity?.label ?? ''} is being upgraded.`,
    -    scheduled: (e) =>
    -      `Linode ${e.entity?.label ?? ''} is scheduled for an upgrade.`,
    -    started: (e) => `Linode ${e.entity?.label ?? ''} is being upgraded.`,
    -  },
    -  // This event type isn't currently being displayed, but I added a message here just in case.
    -  linode_mutate_create: {
    -    notification: (e) =>
    -      `Upgrade for Linode ${e.entity!.label} has been initiated.`,
    -  },
    -  linode_reboot: {
    -    failed: (e) =>
    -      `Linode ${e.entity!.label} could not be ${safeSecondaryEntityLabel(
    -        e,
    -        'rebooted with config',
    -        'rebooted'
    -      )}.`,
    -    finished: (e) =>
    -      `Linode ${e.entity!.label} has been ${safeSecondaryEntityLabel(
    -        e,
    -        'rebooted with config',
    -        'rebooted'
    -      )}.`,
    -    scheduled: (e) =>
    -      `Linode ${e.entity!.label} is scheduled ${safeSecondaryEntityLabel(
    -        e,
    -        'for a reboot with config',
    -        'for a reboot'
    -      )}.`,
    -    started: (e) =>
    -      `Linode ${e.entity!.label} is being ${safeSecondaryEntityLabel(
    -        e,
    -        'rebooted with config',
    -        'rebooted'
    -      )}.`,
    -  },
    -  linode_rebuild: {
    -    failed: (e) => `Linode ${e.entity!.label} could not be rebuilt.`,
    -    finished: (e) => `Linode ${e.entity!.label} has been rebuilt.`,
    -    scheduled: (e) => `Linode ${e.entity!.label} is scheduled for rebuild.`,
    -    started: (e) => `Linode ${e.entity!.label} is being rebuilt.`,
    -  },
    -  linode_resize: {
    -    failed: (e) => `Linode ${e.entity?.label ?? ''} could not be resized`,
    -    finished: (e) => `Linode ${e.entity?.label ?? ''} has been resized.`,
    -    scheduled: (e) =>
    -      `Linode ${e.entity?.label ?? ''} is scheduled for resizing.`,
    -    started: (e) => `Linode ${e.entity?.label ?? ''} is resizing.`,
    -  },
    -  linode_resize_create: {
    -    notification: (e) =>
    -      `A cold resize for Linode ${e.entity!.label} has been initiated.`,
    -  },
    -  linode_resize_warm_create: {
    -    notification: (e) =>
    -      `A warm resize for Linode ${e.entity!.label} has been initiated.`,
    -  },
    -  linode_shutdown: {
    -    failed: (e) => `Linode ${e.entity!.label} could not be shut down.`,
    -    finished: (e) => `Linode ${e.entity!.label} has been shut down.`,
    -    scheduled: (e) => `Linode ${e.entity!.label} is scheduled for shutdown.`,
    -    started: (e) => `Linode ${e.entity!.label} is shutting down.`,
    -  },
    -  linode_snapshot: {
    -    failed: (e) =>
    -      `${formatEventWithAppendedText(
    -        e,
    -        `Snapshot backup failed on Linode ${e.entity!.label}.`,
    -        'Learn more about limits and considerations',
    -        'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations'
    -      )}`,
    -    finished: (e) =>
    -      `A snapshot backup has been created for ${e.entity!.label}.`,
    -    scheduled: (e) =>
    -      `Linode ${e.entity!.label} is scheduled for a snapshot backup.`,
    -    started: (e) =>
    -      `A snapshot backup is being created for Linode ${e.entity!.label}.`,
    -  },
    -  linode_update: {
    -    notification: (e) => `Linode ${e.entity!.label} has been updated.`,
    -  },
    -  lish_boot: {
    -    failed: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } could not be booted (Lish initiated boot).`,
    -    finished: (e) =>
    -      `Linode ${e.entity?.label ?? ''} has been booted (Lish initiated boot).`,
    -    scheduled: (e) =>
    -      `Linode ${
    -        e.entity?.label ?? ''
    -      } is scheduled to boot (Lish initiated boot).`,
    -    started: (e) =>
    -      `Linode ${e.entity?.label ?? ''} is being booted (Lish initiated boot).`,
    -  },
    -  lke_cluster_create: {
    -    notification: (e) =>
    -      `Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been created.`,
    -  },
    -  lke_cluster_delete: {
    -    notification: (e) =>
    -      `Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been deleted.`,
    -  },
    -  lke_cluster_recycle: {
    -    notification: (e) =>
    -      `Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been recycled.`,
    -  },
    -  lke_cluster_regenerate: {
    -    notification: (e) =>
    -      `Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been regenerated.`,
    -  },
    -  lke_cluster_update: {
    -    notification: (e) =>
    -      `Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been updated.`,
    -  },
    -  lke_control_plane_acl_create: {
    -    notification: (e) =>
    -      `The IP ACL for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been created.`,
    -  },
    -  lke_control_plane_acl_delete: {
    -    notification: (e) =>
    -      `The IP ACL for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been disabled.`,
    -  },
    -  lke_control_plane_acl_update: {
    -    notification: (e) =>
    -      `The IP ACL for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been updated.`,
    -  },
    -  lke_kubeconfig_regenerate: {
    -    notification: (e) =>
    -      `The kubeconfig for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been regenerated.`,
    -  },
    -  lke_node_create: {
    -    // This event is a special case; a notification means the node creation failed.
    -    // The entity is the node pool, but entity.label contains the cluster's label.
    -    notification: (e) =>
    -      `Failed to create a node on Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      }.`,
    -  },
    -  lke_node_recycle: {
    -    notification: (e) =>
    -      `The node for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been recycled.`,
    -  },
    -  lke_pool_create: {
    -    notification: (e) =>
    -      `A Node Pool for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been created.`,
    -  },
    -  lke_pool_delete: {
    -    notification: (e) =>
    -      `A Node Pool for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been deleted.`,
    -  },
    -  lke_pool_recycle: {
    -    notification: (e) =>
    -      `A Node Pool for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been recycled.`,
    -  },
    -  lke_token_rotate: {
    -    notification: (e) =>
    -      `The token for Kubernetes Cluster${
    -        e.entity?.label ? ` ${e.entity.label}` : ''
    -      } has been rotated.`,
    -  },
    -  longviewclient_create: {
    -    notification: (e) => `Longview Client ${e.entity!.label} has been created.`,
    -  },
    -  longviewclient_delete: {
    -    notification: (e) => `Longview Client ${e.entity!.label} has been deleted.`,
    -  },
    -  // managed_disabled: {
    -  //   scheduled: e => ``,
    -  //   started: e => ``,
    -  //   failed: e => ``,
    -  //   finished: e => ``,
    -  //   notification: e => ``,
    -  longviewclient_update: {
    -    notification: (e) => `Longview Client ${e.entity!.label} has been updated.`,
    -  },
    -  // },
    -  managed_enabled: {
    -    notification: (e) => `Managed has been activated on your account.`,
    -  },
    -  managed_service_create: {
    -    notification: (e) => `Managed service ${e.entity!.label} has been created.`,
    -  },
    -  managed_service_delete: {
    -    notification: (e) => `Managed service ${e.entity!.label} has been deleted.`,
    -  },
    -  nodebalancer_config_create: {
    -    notification: (e) =>
    -      `A config on NodeBalancer ${e.entity!.label} has been created.`,
    -  },
    -  nodebalancer_config_delete: {
    -    notification: (e) =>
    -      `A config on NodeBalancer ${e.entity!.label} has been deleted.`,
    -  },
    -  nodebalancer_config_update: {
    -    notification: (e) =>
    -      `A config on NodeBalancer ${e.entity!.label} has been updated.`,
    -  },
    -  nodebalancer_create: {
    -    notification: (e) => `NodeBalancer ${e.entity!.label} has been created.`,
    -  },
    -  nodebalancer_delete: {
    -    notification: (e) => `NodeBalancer ${e.entity!.label} has been deleted.`,
    -  },
    -  nodebalancer_node_create: {
    -    notification: (e) =>
    -      `A node on NodeBalancer ${e.entity!.label} has been created.`,
    -  },
    -  nodebalancer_node_delete: {
    -    notification: (e) =>
    -      `A node on NodeBalancer ${e.entity!.label} has been deleted.`,
    -  },
    -  nodebalancer_node_update: {
    -    notification: (e) =>
    -      `A node on NodeBalancer ${e.entity!.label} has been updated.`,
    -  },
    -  nodebalancer_update: {
    -    notification: (e) => `NodeBalancer ${e.entity!.label} has been updated.`,
    -  },
    -  oauth_client_create: {
    -    notification: (e) => `OAuth App ${e.entity!.label} has been created.`,
    -  },
    -  oauth_client_delete: {
    -    notification: (e) => `OAuth App ${e.entity!.label} has been deleted.`,
    -  },
    -  oauth_client_secret_reset: {
    -    notification: (e) =>
    -      `Secret for OAuth App ${e.entity!.label} has been reset.`,
    -  },
    -  oauth_client_update: {
    -    notification: (e) => `OAuth App ${e.entity!.label} has been updated.`,
    -  },
    -  obj_access_key_create: {
    -    notification: (e) => `Access Key ${e.entity!.label} has been created.`,
    -  },
    -  obj_access_key_delete: {
    -    notification: (e) => `Access Key ${e.entity!.label} has been deleted.`,
    -  },
    -  obj_access_key_update: {
    -    notification: (e) => `Access Key ${e.entity!.label} has been updated.`,
    -  },
    -  password_reset: {
    -    failed: (e) => `Password reset failed for Linode ${e.entity!.label}.`,
    -    finished: (e) => `Password has been reset on Linode ${e.entity!.label}.`,
    -    scheduled: (e) => `A password reset is scheduled for ${e.entity!.label}.`,
    -    started: (e) => `The password for ${e.entity!.label} is being reset.`,
    -  },
    -  payment_method_add: {
    -    notification: (e) => `A payment method was added.`,
    -  },
    -  payment_submitted: {
    -    notification: (e) => `A payment was successfully submitted.`,
    -  },
    -  // This event action captures Linode assignment to a Placement Group from Linode Create flow and from PlacementGroupAssignLinodeDrawer.
    -  placement_group_assign: {
    -    notification: (e) =>
    -      `Linode ${e.secondary_entity?.label} has been assigned to Placement Group ${e.entity?.label}.`,
    -  },
    -  placement_group_became_compliant: {
    -    notification: (e) =>
    -      `Placement Group ${e.entity?.label} has become compliant.`,
    -  },
    -  placement_group_became_non_compliant: {
    -    notification: (e) =>
    -      `Placement Group ${e.entity?.label} has become non-compliant.`,
    -  },
    -  placement_group_create: {
    -    notification: (e) =>
    -      `Placement Group ${e.entity?.label} has been successfully created.`,
    -  },
    -  placement_group_delete: {
    -    notification: (e) => `Placement Group ${e.entity?.label} has been deleted.`,
    -  },
    -  placement_group_unassign: {
    -    notification: (e) =>
    -      `Linode ${e.secondary_entity?.label} has been unassigned from Placement Group ${e.entity?.label}.`,
    -  },
    -  placement_group_update: {
    -    notification: (e) => `Placement Group ${e.entity?.label} has been updated.`,
    -  },
    -  profile_update: {
    -    notification: (e) => `Your profile has been updated.`,
    -  },
    -  stackscript_create: {
    -    notification: (e) => `StackScript ${e.entity!.label} has been created.`,
    -  },
    -  stackscript_delete: {
    -    notification: (e) => `StackScript ${e.entity!.label} has been deleted.`,
    -  },
    -  stackscript_publicize: {
    -    notification: (e) => `StackScript ${e.entity!.label} has been made public.`,
    -  },
    -  stackscript_revise: {
    -    notification: (e) => `StackScript ${e.entity!.label} has been revised.`,
    -  },
    -  stackscript_update: {
    -    notification: (e) => `StackScript ${e.entity!.label} has been updated.`,
    -  },
    -  subnet_create: {
    -    notification: (e) =>
    -      `Subnet ${e.entity!.label} has been created in VPC ${
    -        e.secondary_entity?.label
    -      }.`,
    -  },
    -  subnet_delete: {
    -    notification: (e) =>
    -      `Subnet ${e.entity!.label} has been deleted in VPC ${
    -        e.secondary_entity?.label
    -      }.`,
    -  },
    -  subnet_update: {
    -    notification: (e) =>
    -      `Subnet ${e.entity!.label} in VPC ${
    -        e.secondary_entity?.label
    -      } has been updated.`,
    -  },
    -  tag_create: {
    -    notification: (e) => `Tag ${e.entity!.label} has been created.`,
    -  },
    -  tag_delete: {
    -    notification: (e) => `Tag ${e.entity!.label} has been deleted.`,
    -  },
    -  tax_id_invalid: {
    -    notification: (e) => `Tax Identification Number format is invalid.`,
    -  },
    -  tax_id_valid: {
    -    notification: (e) => `Tax Identification Number has been verified.`,
    -  },
    -  tfa_disabled: {
    -    notification: (e) => `Two-factor authentication has been disabled.`,
    -  },
    -  tfa_enabled: {
    -    notification: (e) => `Two-factor authentication has been enabled.`,
    -  },
    -  ticket_attachment_upload: {
    -    notification: (e) =>
    -      `File has been successfully uploaded to support ticket ${
    -        e.entity!.label
    -      }`,
    -  },
    -  // ticket_reply: {
    -  //   scheduled: e => ``,
    -  //   started: e => ``,
    -  //   failed: e => ``,
    -  //   finished: e => ``,
    -  //   notification: e => ``,
    -  ticket_create: {
    -    notification: (e) => `New support ticket "${e.entity!.label}" created.`,
    -  },
    -  // },
    -  ticket_update: {
    -    notification: (e) =>
    -      `Support ticket "${e.entity!.label}" has been updated.`,
    -  },
    -  token_create: {
    -    notification: (e) => `Token ${e.entity!.label} has been created.`,
    -  },
    -  token_delete: {
    -    notification: (e) => `Token ${e.entity!.label} has been revoked.`,
    -  },
    -  token_update: {
    -    notification: (e) => `Token ${e.entity!.label} has been updated.`,
    -  },
    -
    -  user_create: {
    -    notification: (e) => `User ${e.entity!.label} has been created.`,
    -  },
    -  user_delete: {
    -    notification: (e) => `User ${e.entity!.label} has been deleted.`,
    -  },
    -  user_ssh_key_add: {
    -    notification: (e) => `An SSH key has been added to your profile.`,
    -  },
    -  user_ssh_key_delete: {
    -    notification: (e) => `An SSH key has been removed from your profile.`,
    -  },
    -  user_ssh_key_update: {
    -    notification: (e) => `An SSH key on your profile has been updated.`,
    -  },
    -  user_update: {
    -    notification: (e) => `User ${e.entity!.label} has been updated.`,
    -  },
    -  volume_attach: {
    -    // @todo Once we have better events, display the name of the attached Linode
    -    failed: (e) => `Volume ${e.entity!.label} failed to attach.`,
    -    finished: (e) => `Volume ${e.entity!.label} has been attached.`,
    -    notification: (e) => `Volume ${e.entity!.label} has been attached.`,
    -    // in these messages.
    -    scheduled: (e) => `Volume ${e.entity!.label} is scheduled to be attached.`,
    -    started: (e) => `Volume ${e.entity!.label} is being attached.`,
    -  },
    -  volume_clone: {
    -    notification: (e) => `Volume ${e.entity!.label} has been cloned.`,
    -  },
    -  volume_create: {
    -    failed: (e) => `Creation of volume ${e.entity!.label} failed.`,
    -    finished: (e) => `Volume ${e.entity!.label} has been created.`,
    -    notification: (e) => `Volume ${e.entity!.label} has been created.`,
    -    scheduled: (e) => `Volume ${e.entity!.label} is scheduled for creation.`,
    -    started: (e) => `Volume ${e.entity!.label} is being created.`,
    -  },
    -  volume_delete: {
    -    failed: (e) => ``,
    -    finished: (e) => ``,
    -    notification: (e) => `Volume ${e.entity!.label} has been deleted.`,
    -    scheduled: (e) => ``,
    -    started: (e) => ``,
    -  },
    -  volume_detach: {
    -    // @todo Once we have better events, display the name of the attached Linode
    -    failed: (e) => `Volume ${e.entity!.label} failed to detach.`,
    -    finished: (e) => `Volume ${e.entity!.label} has been detached.`,
    -    notification: (e) => `Volume ${e.entity!.label} has been detached.`,
    -    // in these messages.
    -    scheduled: (e) => `Volume ${e.entity!.label} is scheduled for detachment.`,
    -    started: (e) => `Volume ${e.entity!.label} is being detached.`,
    -  },
    -  volume_migrate: {
    -    failed: (e) => `Volume ${e.entity!.label} failed to upgrade to NVMe.`,
    -    finished: (e) => `Volume ${e.entity!.label} has been upgraded to NVMe.`,
    -    started: (e) => `Volume ${e.entity!.label} is being upgraded to NVMe.`,
    -  },
    -  volume_migrate_scheduled: {
    -    scheduled: (e) =>
    -      `Volume ${e.entity!.label} has been scheduled for an upgrade to NVMe.`,
    -  },
    -  volume_resize: {
    -    notification: (e) => `Volume ${e.entity!.label} has been resized.`,
    -  },
    -  volume_update: {
    -    notification: (e) => `Volume ${e.entity!.label} has been updated.`,
    -  },
    -  vpc_create: {
    -    notification: (e) => `VPC ${e.entity!.label} has been created.`,
    -  },
    -  vpc_delete: {
    -    notification: (e) => `VPC ${e.entity!.label} has been deleted.`,
    -  },
    -  vpc_update: {
    -    notification: (e) => `VPC ${e.entity!.label} has been updated.`,
    -  },
    -};
    -
    -export const formatEventWithAPIMessage = (e: Event) => {
    -  /**
    -   * It would be great to format this better, but:
    -   * 1. Action names include gotchas that prevent simple capitalization or trimming rules.
    -   * 2. Provided API messages *should* make it clear what action they're referring to,
    -   *    but we don't have such a guarantee.
    -   */
    -  return `${e.action}: ${e.message}`;
    -};
    -
    -export const generateEventMessage = (e: Event): string => {
    -  const fn = path(
    -    [e.action, e.status],
    -    eventMessageCreators
    -  );
    -
    -  /** we couldn't find the event in our list above */
    -  if (!fn) {
    -    /** log unknown events to the console */
    -    if (!isProductionBuild) {
    -      /* eslint-disable no-console */
    -      console.error('============================================');
    -      console.error('Unknown API Event Received');
    -      console.log(e);
    -      console.error('============================================');
    -    }
    -
    -    /** finally return some default fallback text */
    -    return e.message
    -      ? formatEventWithAPIMessage(e)
    -      : `${e.action}${e.entity?.label ? ` on ${e.entity.label}` : ''}`;
    -  }
    -
    -  let message = '';
    -  try {
    -    message = fn(e);
    -  } catch (error) {
    -    /** report our error to sentry */
    -    reportException('Known API Event Received with Error', {
    -      error,
    -      event_data: e,
    -    });
    -  }
    -
    -  /** add the username to message; if it already contains the username, return the original message **/
    -  const messageWithUsername = formatEventWithUsername(
    -    e.action,
    -    e.username,
    -    message
    -  );
    -
    -  /**
    -   * return either the formatted message or an empty string
    -   * fails gracefully if the message we encounter a formatting error
    -   * */
    -  try {
    -    const formattedMessage = applyLinking(e, messageWithUsername);
    -    return applyBolding(formattedMessage);
    -  } catch (error) {
    -    console.warn('Error with formatting the event message', {
    -      error,
    -      event_data: e,
    -    });
    -
    -    reportException('Error with formatting the event message', {
    -      error,
    -      event_data: e,
    -    });
    -
    -    return messageWithUsername;
    -  }
    -};
    -
    -export function applyBolding(message: string) {
    -  if (!message) {
    -    return '';
    -  }
    -
    -  const wordsToBold: string[] = [
    -    'added',
    -    'attached',
    -    'booted',
    -    'cloned',
    -    'created',
    -    'deleted',
    -    'detached',
    -    'disabled',
    -    'duplicated',
    -    'enabled',
    -    'failed',
    -    'migrated',
    -    'reboot',
    -    'rebooted',
    -    'rebuilt',
    -    'removed',
    -    'reset',
    -    'Resize',
    -    'resized',
    -    'revised',
    -    'revoked',
    -    'shut down',
    -    'updated',
    -    'Upgrade',
    -    'upgraded',
    -  ];
    -
    -  let newMessage = message;
    -
    -  for (const word of wordsToBold) {
    -    const regex = new RegExp(`\\b${word}\\b`);
    -    newMessage = newMessage.replace(
    -      // We use a RegExp with word boundary checks (\\b) to ensure we're replacing the exact word, not just a substring
    -      // This avoids situations where we used to get back 're**booted**' instead of **rebooted**, etc
    -      regex,
    -      `**${word}**`
    -    );
    -  }
    -
    -  return newMessage;
    -}
    -
    -export function applyLinking(event: Event, message: string) {
    -  if (!message) {
    -    return '';
    -  }
    -
    -  const entityLinkTarget = getLinkForEvent(event.action, event.entity);
    -  const secondaryEntityLinkTarget = getLinkForEvent(
    -    event.action,
    -    event.secondary_entity
    -  );
    -
    -  let newMessage = message;
    -
    -  if (event.entity?.label && entityLinkTarget) {
    -    const label = event.entity.label;
    -    const nonTickedLabels = new RegExp(
    -      `(\\B|^|[^\`])${escapeRegExp(label)}(\\B|$|[^\`])`,
    -      'g'
    -    );
    -
    -    newMessage = newMessage.replace(
    -      nonTickedLabels,
    -      ` ${label} `
    -    );
    -  }
    -
    -  if (event.secondary_entity?.label && secondaryEntityLinkTarget) {
    -    newMessage = newMessage.replace(
    -      event.secondary_entity.label,
    -      `${event.secondary_entity.label}`
    -    );
    -  }
    -
    -  return newMessage;
    -}
    diff --git a/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx b/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx
    deleted file mode 100644
    index c5001671c03..00000000000
    --- a/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx
    +++ /dev/null
    @@ -1,103 +0,0 @@
    -// TODO eventMessagesV2: delete when flag is removed
    -import { Event } from '@linode/api-v4/lib/account';
    -import { Linode } from '@linode/api-v4/lib/linodes';
    -import { Region } from '@linode/api-v4/lib/regions';
    -import * as React from 'react';
    -
    -import { Link } from 'src/components/Link';
    -import { formatEventWithAPIMessage } from 'src/features/Events/eventMessageGenerator';
    -
    -import { ExtendedType } from '../../utilities/extendType';
    -
    -export const eventMessageGenerator = (
    -  e: Event,
    -  linodes: Linode[] = [],
    -  types: ExtendedType[] = [],
    -  regions: Region[] = []
    -) => {
    -  const eventLinode = linodes.find(
    -    (thisLinode) => thisLinode.id === e.entity?.id
    -  );
    -
    -  if (e.message) {
    -    return formatEventWithAPIMessage(e);
    -  }
    -  switch (e.action) {
    -    case 'linode_resize':
    -      const eventLinodeType = types.find(
    -        (thisType) => thisType.id === eventLinode?.type
    -      );
    -      return `resize ${
    -        eventLinodeType ? `to ${eventLinodeType.formattedLabel} Plan` : ''
    -      }`;
    -    case 'linode_migrate':
    -    case 'linode_migrate_datacenter':
    -      const region = regions.find((r) => r.id === eventLinode?.region);
    -      return `migrate ${
    -        eventLinode ? `to ${region?.label ?? eventLinode.region}` : ''
    -      }`;
    -    case 'disk_imagize':
    -      return `create from ${e.entity?.label}`;
    -    case 'linode_boot':
    -      return `boot with ${e.secondary_entity?.label}`;
    -    case 'host_reboot':
    -      return 'reboot (Host initiated restart)';
    -    case 'lassie_reboot':
    -      return 'reboot (Lassie watchdog service)';
    -    case 'linode_reboot':
    -      if (e.secondary_entity !== null) {
    -        return `reboot with ${e.secondary_entity?.label}`;
    -      } else {
    -        return 'is rebooting.';
    -      }
    -    case 'linode_shutdown':
    -      return 'shutdown';
    -    case 'linode_delete':
    -      return 'delete';
    -    case 'linode_clone':
    -      return (
    -        <>
    -          clone to{` `}
    -          
    -            {e.secondary_entity?.label}
    -          
    -        
    -      );
    -    case 'disk_resize':
    -      return 'disk resize';
    -    case 'disk_duplicate':
    -      return 'disk duplicate';
    -    case 'backups_restore':
    -      return 'backup restore';
    -    case 'linode_snapshot':
    -      return 'snapshot backup';
    -    case 'linode_mutate':
    -      return 'upgrade';
    -    case 'linode_rebuild':
    -      return 'rebuild';
    -    case 'linode_create':
    -      return 'provisioning';
    -    case 'image_upload':
    -      return 'image uploading';
    -    case 'volume_migrate':
    -      return `Volume ${e.entity?.label} is being upgraded to NVMe.`;
    -    case 'database_resize':
    -      return 'resizing';
    -
    -    default:
    -      // If we haven't handled it explicitly here, it doesn't count as
    -      // a "Pending Action" for our purposes.
    -      return null;
    -  }
    -};
    -
    -export const eventLabelGenerator = (e: Event) => {
    -  if (['disk_imagize'].includes(e.action)) {
    -    return e.secondary_entity?.label;
    -  }
    -
    -  if (e.action == 'database_resize') {
    -    return `Database ${e.entity!.label}`;
    -  }
    -  return e.entity?.label;
    -};
    diff --git a/packages/manager/src/features/Events/factories/backup.tsx b/packages/manager/src/features/Events/factories/backup.tsx
    index 31344a45197..0e45776e06d 100644
    --- a/packages/manager/src/features/Events/factories/backup.tsx
    +++ b/packages/manager/src/features/Events/factories/backup.tsx
    @@ -22,11 +22,12 @@ export const backup: PartialEventMap<'backups'> = {
       backups_restore: {
         failed: (e) => (
           <>
    -        Backup could not be restored for
    +        Backup could not be restored for{' '}
             {e.entity!.label}.{' '}
             
    -          Learn more about limits and considerations.
    +          Learn more about limits and considerations
             
    +        .
           
         ),
         finished: (e) => (
    diff --git a/packages/manager/src/features/Events/factories/disk.tsx b/packages/manager/src/features/Events/factories/disk.tsx
    index 8168cdd76e4..761fceafb21 100644
    --- a/packages/manager/src/features/Events/factories/disk.tsx
    +++ b/packages/manager/src/features/Events/factories/disk.tsx
    @@ -1,6 +1,7 @@
     import * as React from 'react';
     
     import { Link } from 'src/components/Link';
    +import { sendLinodeDiskEvent } from 'src/utilities/analytics/customEventAnalytics';
     
     import { EventLink } from '../EventLink';
     
    @@ -76,32 +77,37 @@ export const disk: PartialEventMap<'disk'> = {
       disk_duplicate: {
         failed: (e) => (
           <>
    -        Disk on Linode  could{' '}
    -        not be duplicated.
    +        Disk  on Linode{' '}
    +         could not be{' '}
    +        duplicated.
           
         ),
         finished: (e) => (
           <>
    -        Disk on Linode  has been{' '}
    -        duplicated.
    +        Disk  on Linode{' '}
    +         has been duplicated
    +        .
           
         ),
         notification: (e) => (
           <>
    -        Disk on Linode  has been{' '}
    -        duplicated.
    +        Disk  on Linode{' '}
    +         has been duplicated
    +        .
           
         ),
         scheduled: (e) => (
           <>
    -        Disk on Linode  is scheduled to be{' '}
    +        Disk  on Linode{' '}
    +         is scheduled to be{' '}
             duplicated.
           
         ),
         started: (e) => (
           <>
    -        Disk on Linode  is being{' '}
    -        duplicated.
    +        Disk  on Linode{' '}
    +         is being duplicated
    +        .
           
         ),
       },
    @@ -111,7 +117,7 @@ export const disk: PartialEventMap<'disk'> = {
             Image  could{' '}
             not be created.{' '}
             
    -          Learn more about image technical specifications.
    +          Learn more about image technical specifications
             
             .
           
    @@ -138,36 +144,46 @@ export const disk: PartialEventMap<'disk'> = {
       disk_resize: {
         failed: (e) => (
           <>
    -        A disk on Linode  could{' '}
    -        not be resized.{' '}
    -        
    +        Disk  on Linode{' '}
    +         could not be{' '}
    +        resized.{' '}
    +         {
    +            sendLinodeDiskEvent(
    +              'Resize',
    +              'Click:link',
    +              'Disk resize failed toast'
    +            );
    +          }}
    +          to="https://www.linode.com/docs/products/compute/compute-instances/guides/disks-and-storage/"
    +        >
               Learn more
             
           
         ),
    -
         finished: (e) => (
           <>
    -        A disk on Linode  has been{' '}
    -        resized.
    +        Disk  on Linode{' '}
    +         has been resized.
           
         ),
         notification: (e) => (
           <>
    -        A disk on Linode  has been{' '}
    -        resized.
    +        Disk  on Linode{' '}
    +         has been resized.
           
         ),
         scheduled: (e) => (
           <>
    -        A disk on Linode  is scheduled to be{' '}
    +        Disk  on Linode{' '}
    +         is scheduled to be{' '}
             resized.
           
         ),
         started: (e) => (
           <>
    -        A disk on Linode  is being{' '}
    -        resized.
    +        Disk  on Linode{' '}
    +         is being resized.
           
         ),
       },
    diff --git a/packages/manager/src/features/Events/factories/domain.tsx b/packages/manager/src/features/Events/factories/domain.tsx
    index c6b02034ca3..c29d2e95c27 100644
    --- a/packages/manager/src/features/Events/factories/domain.tsx
    +++ b/packages/manager/src/features/Events/factories/domain.tsx
    @@ -1,7 +1,7 @@
     import * as React from 'react';
     
     import { EventLink } from '../EventLink';
    -import { EventMessage } from '../EventMessage';
    +import { FormattedEventMessage } from '../FormattedEventMessage';
     
     import type { PartialEventMap } from '../types';
     
    @@ -32,8 +32,8 @@ export const domain: PartialEventMap<'domain'> = {
       domain_record_create: {
         notification: (e) => (
           <>
    -         has been added to{' '}
    -        .
    +         has been{' '}
    +        added to .
           
         ),
       },
    @@ -48,16 +48,16 @@ export const domain: PartialEventMap<'domain'> = {
       domain_record_update: {
         notification: (e) => (
           <>
    -         has been updated{' '}
    -        for .
    +         has been{' '}
    +        updated for .
           
         ),
       },
       domain_record_updated: {
         notification: (e) => (
           <>
    -         has been updated{' '}
    -        for .
    +         has been{' '}
    +        updated for .
           
         ),
       },
    diff --git a/packages/manager/src/features/Events/factories/image.tsx b/packages/manager/src/features/Events/factories/image.tsx
    index dbe4ef39cf2..0cc07e47362 100644
    --- a/packages/manager/src/features/Events/factories/image.tsx
    +++ b/packages/manager/src/features/Events/factories/image.tsx
    @@ -1,6 +1,7 @@
     import * as React from 'react';
     
     import { EventLink } from '../EventLink';
    +import { FormattedEventMessage } from '../FormattedEventMessage';
     
     import type { PartialEventMap } from '../types';
     
    @@ -44,17 +45,21 @@ export const image: PartialEventMap<'image'> = {
         ),
       },
       image_upload: {
    -    failed: (e) => (
    -      <>
    -        Image  could not be{' '}
    -        uploaded: {e?.message?.replace(/(\d+)/g, '$1 MB')}.
    -      
    -    ),
    +    failed: (e) => {
    +      const message = e?.message?.replace(/(\d+)/g, '$1 MB') || '';
    +
    +      return (
    +        <>
    +          Image  could not{' '}
    +          be uploaded:{' '}
    +          .
    +        
    +      );
    +    },
     
         finished: (e) => (
           <>
    -        Image  has been{' '}
    -        uploaded.
    +        Image  is now available.
           
         ),
         notification: (e) => (
    diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx
    index ba13193cdf1..e4eda01f549 100644
    --- a/packages/manager/src/features/Events/factories/linode.tsx
    +++ b/packages/manager/src/features/Events/factories/linode.tsx
    @@ -2,7 +2,6 @@ import * as React from 'react';
     
     import { Link } from 'src/components/Link';
     import { useLinodeQuery } from 'src/queries/linodes/linodes';
    -import { useRegionsQuery } from 'src/queries/regions/regions';
     import { useTypeQuery } from 'src/queries/types';
     import { formatStorageUnits } from 'src/utilities/formatStorageUnits';
     
    @@ -290,7 +289,12 @@ export const linode: PartialEventMap<'linode'> = {
             migrated.
           
         ),
    -    started: (e) => ,
    +    started: (e) => (
    +      <>
    +        Linode  is being{' '}
    +        migrated to a new region.
    +      
    +    ),
       },
       linode_migrate_datacenter_create: {
         notification: (e) => (
    @@ -537,26 +541,6 @@ export const linode: PartialEventMap<'linode'> = {
       },
     };
     
    -const LinodeMigrateDataCenterMessage = ({ event }: { event: Event }) => {
    -  const { data: linode } = useLinodeQuery(event.entity?.id ?? -1);
    -  const { data: regions } = useRegionsQuery();
    -  const region = regions?.find((r) => r.id === linode?.region);
    -
    -  return (
    -    <>
    -      Linode  is being{' '}
    -      migrated
    -      {region && (
    -        <>
    -          {' '}
    -          to {region.label}
    -        
    -      )}
    -      .
    -    
    -  );
    -};
    -
     const LinodeResizeStartedMessage = ({ event }: { event: Event }) => {
       const { data: linode } = useLinodeQuery(event.entity?.id ?? -1);
       const type = useTypeQuery(linode?.type ?? '');
    @@ -569,7 +553,7 @@ const LinodeResizeStartedMessage = ({ event }: { event: Event }) => {
             <>
               {' '}
               to the{' '}
    -          {type.data && (
    +          {type.data?.label && (
                 {formatStorageUnits(type.data.label)}
               )}{' '}
               Plan
    diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx
    index cb1d5953599..7cb5c5bddc8 100644
    --- a/packages/manager/src/features/Events/utils.test.tsx
    +++ b/packages/manager/src/features/Events/utils.test.tsx
    @@ -1,3 +1,5 @@
    +import { DateTime } from 'luxon';
    +
     import { eventFactory } from 'src/factories';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
    @@ -9,7 +11,6 @@ import {
     } from './utils';
     
     import type { Event } from '@linode/api-v4';
    -import { DateTime } from 'luxon';
     
     describe('getEventMessage', () => {
       const mockEvent1: Event = eventFactory.build({
    diff --git a/packages/manager/src/features/Events/utils.tsx b/packages/manager/src/features/Events/utils.tsx
    index ebd7a915cb1..d9546fe31db 100644
    --- a/packages/manager/src/features/Events/utils.tsx
    +++ b/packages/manager/src/features/Events/utils.tsx
    @@ -4,10 +4,10 @@ import { ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS } from 'src/features/Events/const
     import { isInProgressEvent } from 'src/queries/events/event.helpers';
     import { getEventTimestamp } from 'src/utilities/eventUtils';
     
    +import { ACTIONS_WITHOUT_USERNAMES } from './constants';
     import { eventMessages } from './factory';
     
     import type { Event } from '@linode/api-v4';
    -import { ACTIONS_WITHOUT_USERNAMES } from './Event.helpers';
     
     type EventMessageManualInput = {
       action: Event['action'];
    @@ -54,6 +54,7 @@ export const getEventUsername = (event: Event) => {
       if (event.username && !ACTIONS_WITHOUT_USERNAMES.includes(event.action)) {
         return event.username;
       }
    +
       return 'Linode';
     };
     
    diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
    index 5efc15e910b..cd4eab3e95d 100644
    --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
    +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
    @@ -1,4 +1,3 @@
    -import { Linode } from '@linode/api-v4';
     import { useTheme } from '@mui/material';
     import { useSnackbar } from 'notistack';
     import * as React from 'react';
    @@ -19,6 +18,8 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
     import { getEntityIdsByPermission } from 'src/utilities/grants';
     import { sanitizeHTML } from 'src/utilities/sanitizeHTML';
     
    +import type { Linode } from '@linode/api-v4';
    +
     interface Props {
       helperText: string;
       onClose: () => void;
    @@ -43,7 +44,7 @@ export const AddLinodeDrawer = (props: Props) => {
       const theme = useTheme();
     
       const {
    -    isLoading: addDeviceIsLoading,
    +    isPending: addDeviceIsLoading,
         mutateAsync: addDevice,
       } = useAddFirewallDeviceMutation(Number(id));
     
    diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
    index 5667d69ada9..86f799f810a 100644
    --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
    +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
    @@ -42,7 +42,7 @@ export const AddNodebalancerDrawer = (props: Props) => {
       const theme = useTheme();
     
       const {
    -    isLoading: addDeviceIsLoading,
    +    isPending: addDeviceIsLoading,
         mutateAsync: addDevice,
       } = useAddFirewallDeviceMutation(Number(id));
     
    diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx
    index 9d199ce7d9b..23ebf136f2d 100644
    --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx
    +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx
    @@ -26,7 +26,7 @@ export const RemoveDeviceDialog = React.memo((props: Props) => {
       const { enqueueSnackbar } = useSnackbar();
       const deviceType = device?.entity.type;
     
    -  const { error, isLoading, mutateAsync } = useRemoveFirewallDeviceMutation(
    +  const { error, isPending, mutateAsync } = useRemoveFirewallDeviceMutation(
         firewallId,
         device?.id ?? -1
       );
    @@ -93,7 +93,7 @@ export const RemoveDeviceDialog = React.memo((props: Props) => {
             
                   
                 
    diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx
    index 42fb686ac9c..f992c271a6f 100644
    --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx
    +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx
    @@ -28,12 +28,12 @@ export const FirewallDialog = React.memo((props: Props) => {
     
       const {
         error: updateError,
    -    isLoading: isUpdating,
    +    isPending: isUpdating,
         mutateAsync: updateFirewall,
       } = useMutateFirewall(selectedFirewall.id);
       const {
         error: deleteError,
    -    isLoading: isDeleting,
    +    isPending: isDeleting,
         mutateAsync: deleteFirewall,
       } = useDeleteFirewall(selectedFirewall.id);
     
    diff --git a/packages/manager/src/features/GlobalNotifications/ComplianceBanner.tsx b/packages/manager/src/features/GlobalNotifications/ComplianceBanner.tsx
    index be44841c7de..0c30c3b6827 100644
    --- a/packages/manager/src/features/GlobalNotifications/ComplianceBanner.tsx
    +++ b/packages/manager/src/features/GlobalNotifications/ComplianceBanner.tsx
    @@ -8,7 +8,7 @@ import { Typography } from 'src/components/Typography';
     import { complianceUpdateContext } from 'src/context/complianceUpdateContext';
     import { useNotificationsQuery } from 'src/queries/account/notifications';
     
    -import { isEUModelContractNotification } from '../NotificationCenter/NotificationData/useFormattedNotifications';
    +import { isEUModelContractNotification } from '../NotificationCenter/utils';
     
     export const ComplianceBanner = () => {
       const context = React.useContext(complianceUpdateContext);
    diff --git a/packages/manager/src/features/GlobalNotifications/ComplianceUpdateModal.tsx b/packages/manager/src/features/GlobalNotifications/ComplianceUpdateModal.tsx
    index 4154fb918f6..7486085bfd7 100644
    --- a/packages/manager/src/features/GlobalNotifications/ComplianceUpdateModal.tsx
    +++ b/packages/manager/src/features/GlobalNotifications/ComplianceUpdateModal.tsx
    @@ -20,7 +20,7 @@ export const ComplianceUpdateModal = () => {
       const complianceModelContext = React.useContext(complianceUpdateContext);
     
       const {
    -    isLoading,
    +    isPending,
         mutateAsync: updateAccountAgreements,
       } = useMutateAccountAgreements();
     
    @@ -50,7 +50,7 @@ export const ComplianceUpdateModal = () => {
               primaryButtonProps={{
                 disabled: !checked,
                 label: 'Agree',
    -            loading: isLoading,
    +            loading: isPending,
                 onClick: handleAgree,
               }}
               secondaryButtonProps={{
    diff --git a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx
    index 2d600740b1b..8654d0858ad 100644
    --- a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx
    +++ b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx
    @@ -15,6 +15,7 @@ import { APIMaintenanceBanner } from './APIMaintenanceBanner';
     import { ComplianceBanner } from './ComplianceBanner';
     import { ComplianceUpdateModal } from './ComplianceUpdateModal';
     import { EmailBounceNotificationSection } from './EmailBounce';
    +import { GravatarSunsetBanner } from './GravatarSunsetBanner';
     import { RegionStatusBanner } from './RegionStatusBanner';
     import { TaxCollectionBanner } from './TaxCollectionBanner';
     import { DesignUpdateBanner } from './TokensUpdateBanner';
    @@ -86,6 +87,7 @@ export const GlobalNotifications = () => {
           Object.keys(flags.taxCollectionBanner).length > 0 ? (
             
           ) : null}
    +      
         
       );
     };
    diff --git a/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx b/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx
    new file mode 100644
    index 00000000000..efbc1d2ef71
    --- /dev/null
    +++ b/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx
    @@ -0,0 +1,27 @@
    +import React from 'react';
    +
    +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner';
    +import { Typography } from 'src/components/Typography';
    +import { useGravatar } from 'src/hooks/useGravatar';
    +
    +interface Props {
    +  email: string;
    +}
    +
    +export const GravatarSunsetBanner = (props: Props) => {
    +  const { email } = props;
    +  const GRAVATAR_DEPRECATION_DATE = 'September 30th, 2024';
    +
    +  const { hasGravatar, isLoadingGravatar } = useGravatar(email);
    +
    +  if (isLoadingGravatar || !hasGravatar) {
    +    return;
    +  }
    +  return (
    +    
    +      
    +        {`Support for using Gravatar as your profile photo will be deprecated on ${GRAVATAR_DEPRECATION_DATE}. Your profile photo will automatically be changed to your username initial.`}
    +      
    +    
    +  );
    +};
    diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx
    index 7047a89e20a..aac0cc5349c 100644
    --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx
    +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx
    @@ -158,7 +158,8 @@ describe('CreateImageTab', () => {
     
         // Verify distributed compute region notice renders
         await findByText(
    -      'This Linode is in a distributed compute region. Images captured from this Linode will be stored in the closest core site.'
    +      "This Linode is in a distributed compute region. These regions can't store images.",
    +      { exact: false }
         );
       });
     
    diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx
    index 2b3de896aa9..5c78d7b4ad0 100644
    --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx
    +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx
    @@ -16,7 +16,6 @@ import { Notice } from 'src/components/Notice/Notice';
     import { Paper } from 'src/components/Paper';
     import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils';
     import { Stack } from 'src/components/Stack';
    -import { SupportLink } from 'src/components/SupportLink';
     import { TagsInput } from 'src/components/TagsInput/TagsInput';
     import { TextField } from 'src/components/TextField';
     import { TooltipIcon } from 'src/components/TooltipIcon';
    @@ -190,27 +189,14 @@ export const CreateImageTab = () => {
               
                 Select Linode & Disk
                 
    -              By default, Linode images are limited to 6144 MB of data per disk.
    -              Ensure your content doesn’t exceed this limit, or{' '}
    -              {' '}
    -              to request a higher limit. Additionally, images can’t be
    -              created from a raw disk or a disk that’s formatted using a
    -              custom file system.
    +              Custom images are billed monthly, at $.10/GB. The disk you target
    +              for an image needs to meet specific{' '}
    +              
    +                requirements
    +              
    +              .
                 
    -            {linodeIsInDistributedRegion && (
    -              
    -                This Linode is in a distributed compute region. Images captured
    -                from this Linode will be stored in the closest core site.
    -              
    -            )}
    +
                  {
                   required
                   value={selectedLinodeId}
                 />
    +            {linodeIsInDistributedRegion && (
    +              
    +                This Linode is in a distributed compute region. These regions
    +                can't store images. The image is stored in the core compute
    +                region that is{' '}
    +                
    +                  geographically closest
    +                
    +                . After it's stored, you can replicate it to other core compute
    +                regions.
    +              
    +            )}
                 {showDiskEncryptionWarning && (
                   
                      ({ fontFamily: theme.font.normal })}>
    @@ -377,20 +375,6 @@ export const CreateImageTab = () => {
                   control={control}
                   name="description"
                 />
    -            
    -              Custom Images are billed at $0.10/GB per month.{' '}
    -              
    -                Learn more about requirements and considerations.{' '}
    -              
    -              For information about how to check and clean a Linux
    -              system’s disk space,{' '}
    -              
    -                read this guide.
    -              
    -            
               
             
              {
                 />
               )}
               
    -            
    +            
                   Image Details
                 
    +            
    +              Custom images are billed monthly, at $.10/GB. An uploaded image
    +              file needs to meet specific{' '}
    +              
    +                requirements
    +              
    +              .
    +            
                 {form.formState.errors.root?.message && (
                    {
                       errorText={fieldState.error?.message}
                       inputRef={field.ref}
                       label="Label"
    -                  noMarginTop
                       onBlur={field.onBlur}
                       onChange={field.onChange}
                       value={field.value ?? ''}
    diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx
    index f5be4d8398f..0d6f2124885 100644
    --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx
    +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx
    @@ -6,13 +6,13 @@ import { makeResourcePage } from 'src/mocks/serverHandlers';
     import { HttpResponse, http, server } from 'src/mocks/testServer';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
    -import { ManageImageRegionsForm } from './ManageImageRegionsForm';
    +import { ManageImageReplicasForm } from './ManageImageRegionsForm';
     
     describe('ManageImageRegionsDrawer', () => {
       it('should render a save button and a cancel button', () => {
         const image = imageFactory.build();
         const { getByText } = renderWithTheme(
    -      
    +      
         );
     
         const cancelButton = getByText('Cancel').closest('button');
    @@ -49,7 +49,7 @@ describe('ManageImageRegionsDrawer', () => {
         );
     
         const { findByText } = renderWithTheme(
    -      
    +      
         );
     
         await findByText('US, Newark, NJ');
    @@ -59,8 +59,16 @@ describe('ManageImageRegionsDrawer', () => {
       });
     
       it('should render a status of "unsaved" when a new region is selected', async () => {
    -    const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' });
    -    const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' });
    +    const region1 = regionFactory.build({
    +      capabilities: ['Object Storage'],
    +      id: 'us-east',
    +      label: 'Newark, NJ',
    +    });
    +    const region2 = regionFactory.build({
    +      capabilities: ['Object Storage'],
    +      id: 'us-west',
    +      label: 'Place, CA',
    +    });
     
         const image = imageFactory.build({
           regions: [
    @@ -78,7 +86,7 @@ describe('ManageImageRegionsDrawer', () => {
         );
     
         const { findByText, getByLabelText, getByText } = renderWithTheme(
    -      
    +      
         );
     
         const saveButton = getByText('Save').closest('button');
    @@ -127,7 +135,7 @@ describe('ManageImageRegionsDrawer', () => {
         );
     
         const { findByText, getByLabelText } = renderWithTheme(
    -      
    +      
         );
     
         // Verify both region labels have been loaded by the API
    diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx
    index d281e3645eb..3cc6a136f7c 100644
    --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx
    +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx
    @@ -32,7 +32,7 @@ interface Context {
       regions: Region[] | undefined;
     }
     
    -export const ManageImageRegionsForm = (props: Props) => {
    +export const ManageImageReplicasForm = (props: Props) => {
       const { image, onClose } = props;
     
       const imageRegionIds = image?.regions.map(({ region }) => region) ?? [];
    @@ -116,7 +116,7 @@ export const ManageImageRegionsForm = (props: Props) => {
                 shouldValidate: true,
               })
             }
    -        currentCapability={undefined}
    +        currentCapability="Object Storage" // Images use Object Storage as the storage backend
             disabledRegions={disabledRegions}
             errorText={errors.regions?.message}
             label="Add Regions"
    @@ -126,7 +126,7 @@ export const ManageImageRegionsForm = (props: Props) => {
             selectedIds={values.regions}
           />
           
    -        Image will be available in these regions ({values.regions.length})
    +        Image will be replicated in these regions ({values.regions.length})
           
            ({
    diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx
    index 4a695e8055c..1860593c336 100644
    --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx
    +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx
    @@ -21,6 +21,8 @@ describe('Image Table Row', () => {
           { region: 'us-east', status: 'available' },
           { region: 'us-southeast', status: 'pending' },
         ],
    +    size: 300,
    +    total_size: 600,
       });
     
       const handlers: Handlers = {
    @@ -41,12 +43,14 @@ describe('Image Table Row', () => {
         );
     
         // Check to see if the row rendered some data
    +
    +    expect(getByText('2 Regions')).toBeVisible();
    +    expect(getByText('0.29 GB')).toBeVisible(); // 300 / 1024 = 0.292
    +    expect(getByText('0.59 GB')).toBeVisible(); // 600 / 1024 = 0.585
    +
         getByText(image.label);
         getAllByText('Ready');
    -    getAllByText((text) => text.includes(image.regions[0].region));
    -    getAllByText('+1');
         getAllByText('Cloud-init, Distributed');
    -    expect(getAllByText('1500 MB').length).toBe(2);
         getAllByText(image.id);
     
         // Open action menu
    @@ -54,7 +58,7 @@ describe('Image Table Row', () => {
         await userEvent.click(actionMenu);
     
         getByText('Edit');
    -    getByText('Manage Regions');
    +    getByText('Manage Replicas');
         getByText('Deploy to New Linode');
         getByText('Rebuild an Existing Linode');
         getByText('Delete');
    @@ -74,7 +78,7 @@ describe('Image Table Row', () => {
         await userEvent.click(getByText('Edit'));
         expect(handlers.onEdit).toBeCalledWith(image);
     
    -    await userEvent.click(getByText('Manage Regions'));
    +    await userEvent.click(getByText('Manage Replicas'));
         expect(handlers.onManageRegions).toBeCalledWith(image);
     
         await userEvent.click(getByText('Deploy to New Linode'));
    diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx
    index c37a632c905..73cb2527106 100644
    --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx
    +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx
    @@ -1,15 +1,17 @@
     import * as React from 'react';
     
     import { Hidden } from 'src/components/Hidden';
    +import { LinkButton } from 'src/components/LinkButton';
     import { TableCell } from 'src/components/TableCell';
     import { TableRow } from 'src/components/TableRow';
     import { Typography } from 'src/components/Typography';
     import { useProfile } from 'src/queries/profile/profile';
     import { capitalizeAllWords } from 'src/utilities/capitalize';
     import { formatDate } from 'src/utilities/formatDate';
    +import { pluralize } from 'src/utilities/pluralize';
    +import { convertStorageUnit } from 'src/utilities/unitConversions';
     
     import { ImagesActionMenu } from './ImagesActionMenu';
    -import { RegionsList } from './RegionsList';
     
     import type { Handlers } from './ImagesActionMenu';
     import type { Event, Image, ImageCapabilities } from '@linode/api-v4';
    @@ -73,7 +75,14 @@ export const ImageRow = (props: Props) => {
         eventStatus: string | undefined
       ) => {
         if (status === 'available' || eventStatus === 'finished') {
    -      return `${size} MB`;
    +      const sizeInGB = convertStorageUnit('MB', size, 'GB');
    +
    +      const formattedSizeInGB = Intl.NumberFormat('en-US', {
    +        maximumFractionDigits: 2,
    +        minimumFractionDigits: 0,
    +      }).format(sizeInGB);
    +
    +      return `${formattedSizeInGB} GB`;
         } else if (isFailed) {
           return 'N/A';
         } else {
    @@ -85,18 +94,15 @@ export const ImageRow = (props: Props) => {
         
           {label}
           
    -        {status ? {getStatusForImage(status)} : null}
    +        {getStatusForImage(status)}
           
           {multiRegionsEnabled && (
             <>
               
                 
    -              {regions && regions.length > 0 && (
    -                 handlers.onManageRegions?.(image)}
    -                  regions={regions}
    -                />
    -              )}
    +               handlers.onManageRegions?.(image)}>
    +                {pluralize('Region', 'Regions', regions.length)}
    +              
                 
               
               
    @@ -122,13 +128,13 @@ export const ImageRow = (props: Props) => {
             
           
           
    -        {expiry ? (
    +        {expiry && (
               
                 {formatDate(expiry, {
                   timezone: profile?.timezone,
                 })}
               
    -        ) : null}
    +        )}
           
           {multiRegionsEnabled && (
             
    diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx
    index bd53035af38..b664da0917d 100644
    --- a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx
    +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx
    @@ -97,7 +97,7 @@ export const ImagesActionMenu = (props: Props) => {
                     {
                       disabled: isImageReadOnly || isDisabled,
                       onClick: () => onManageRegions(image),
    -                  title: 'Manage Regions',
    +                  title: 'Manage Replicas',
                       tooltip: isImageReadOnly
                         ? getRestrictedResourceText({
                             action: 'edit',
    diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx
    index 59d52947e28..8977317fad0 100644
    --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx
    +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx
    @@ -57,10 +57,10 @@ describe('Images Landing Table', () => {
         // Static text and table column headers
         expect(getAllByText('Image').length).toBe(2);
         expect(getAllByText('Status').length).toBe(2);
    -    expect(getAllByText('Region(s)').length).toBe(1);
    +    expect(getAllByText('Replicated in').length).toBe(1);
         expect(getAllByText('Compatibility').length).toBe(1);
    -    expect(getAllByText('Size').length).toBe(2);
    -    expect(getAllByText('Total Size').length).toBe(1);
    +    expect(getAllByText('Original Image').length).toBe(1);
    +    expect(getAllByText('All Replicas').length).toBe(1);
         expect(getAllByText('Created').length).toBe(2);
         expect(getAllByText('Image ID').length).toBe(1);
       });
    diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx
    index 265662eac9e..5a6b50b2f45 100644
    --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx
    +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx
    @@ -47,7 +47,7 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils';
     
     import { getEventsForImages } from '../utils';
     import { EditImageDrawer } from './EditImageDrawer';
    -import { ManageImageRegionsForm } from './ImageRegions/ManageImageRegionsForm';
    +import { ManageImageReplicasForm } from './ImageRegions/ManageImageRegionsForm';
     import { ImageRow } from './ImageRow';
     import { ImagesLandingEmptyState } from './ImagesLandingEmptyState';
     import { RebuildImageDrawer } from './RebuildImageDrawer';
    @@ -221,8 +221,8 @@ export const ImagesLanding = () => {
       const [selectedImageId, setSelectedImageId] = React.useState();
     
       const [
    -    isManageRegionsDrawerOpen,
    -    setIsManageRegionsDrawerOpen,
    +    isManageReplicasDrawerOpen,
    +    setIsManageReplicasDrawerOpen,
       ] = React.useState(false);
       const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false);
       const [isRebuildDrawerOpen, setIsRebuildDrawerOpen] = React.useState(false);
    @@ -349,7 +349,7 @@ export const ImagesLanding = () => {
         onManageRegions: multiRegionsEnabled
           ? (image) => {
               setSelectedImageId(image.id);
    -          setIsManageRegionsDrawerOpen(true);
    +          setIsManageReplicasDrawerOpen(true);
             }
           : undefined,
         onRestore: (image) => {
    @@ -373,8 +373,8 @@ export const ImagesLanding = () => {
       }
     
       if (
    -    manualImages.results === 0 &&
    -    automaticImages.results === 0 &&
    +    manualImages?.results === 0 &&
    +    automaticImages?.results === 0 &&
         !imageLabelFromParam
       ) {
         return ;
    @@ -430,7 +430,8 @@ export const ImagesLanding = () => {
               Custom Images
               
                 These are images you manually uploaded or captured from an existing
    -            Linode disk.
    +            compute instance disk. You can deploy an image to a compute instance
    +            in any region.
               
             
             
    @@ -450,7 +451,7 @@ export const ImagesLanding = () => { {multiRegionsEnabled && ( <> - Region(s) + Replicated in Compatibility @@ -463,11 +464,11 @@ export const ImagesLanding = () => { handleClick={handleManualImagesOrderChange} label="size" > - Size + {multiRegionsEnabled ? 'Original Image' : 'Size'} {multiRegionsEnabled && ( - Total Size + All Replicas )} @@ -489,13 +490,13 @@ export const ImagesLanding = () => { - {manualImages.results === 0 && ( + {manualImages?.results === 0 && ( )} - {manualImages.data.map((manualImage) => ( + {manualImages?.data.map((manualImage) => ( { - {automaticImages.results === 0 && ( + {automaticImages?.results === 0 && ( )} - {automaticImages.data.map((automaticImage) => ( + {automaticImages?.data.map((automaticImage) => ( { open={isRebuildDrawerOpen} /> setIsManageRegionsDrawerOpen(false)} - open={isManageRegionsDrawerOpen} - title={`Manage Regions for ${selectedImage?.label}`} + onClose={() => setIsManageReplicasDrawerOpen(false)} + open={isManageReplicasDrawerOpen} + title={`Manage Replicas for ${selectedImage?.label}`} > - setIsManageRegionsDrawerOpen(false)} + onClose={() => setIsManageReplicasDrawerOpen(false)} /> { - it('should render a single region', async () => { - const { findByText } = renderWithTheme( - - ); - - // Should initially fallback to region id - await findByText('us-east'); - await findByText('US, Newark, NJ'); - }); - - it('should allow expanding to view multiple regions', async () => { - const manageRegions = vi.fn(); - - const { findByRole, findByText } = renderWithTheme( - - ); - - await findByText((text) => text.includes('US, Newark, NJ')); - const expand = await findByRole('button'); - expect(expand).toHaveTextContent('+1'); - - await userEvent.click(expand); - expect(manageRegions).toBeCalled(); - }); -}); diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx deleted file mode 100644 index e17785ea634..00000000000 --- a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; -import { Typography } from 'src/components/Typography'; -import { useRegionsQuery } from 'src/queries/regions/regions'; - -import type { ImageRegion } from '@linode/api-v4'; - -interface Props { - onManageRegions: () => void; - regions: ImageRegion[]; -} - -export const RegionsList = ({ onManageRegions, regions }: Props) => { - const { data: regionsData } = useRegionsQuery(); - - return ( - - {regionsData?.find((region) => region.id == regions[0].region)?.label ?? - regions[0].region} - {regions.length > 1 && ( - <> - ,{' '} - - +{regions.length - 1} - - - )} - - ); -}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 2e82e2817e7..e8fccf93722 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -213,7 +213,7 @@ export const CreateCluster = () => { {generalError && ( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/DeleteKubernetesClusterDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/DeleteKubernetesClusterDialog.tsx index 990daad4468..b1e13f1a5ab 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/DeleteKubernetesClusterDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/DeleteKubernetesClusterDialog.tsx @@ -1,4 +1,3 @@ -import { KubeNodePoolResponse } from '@linode/api-v4'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -9,6 +8,8 @@ import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToCo import { Typography } from 'src/components/Typography'; import { useDeleteKubernetesClusterMutation } from 'src/queries/kubernetes'; +import type { KubeNodePoolResponse } from '@linode/api-v4'; + export interface Props { clusterId: number; clusterLabel: string; @@ -36,7 +37,7 @@ export const DeleteKubernetesClusterDialog = (props: Props) => { const { clusterId, clusterLabel, onClose, open } = props; const { error, - isLoading: isDeleting, + isPending: isDeleting, mutateAsync: deleteCluster, } = useDeleteKubernetesClusterMutation(); const history = useHistory(); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index abaf0379c01..a3579c98c27 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -120,7 +120,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => { const { error: resetKubeConfigError, - isLoading: isResettingKubeConfig, + isPending: isResettingKubeConfig, mutateAsync: resetKubeConfig, } = useResetKubeConfigMutation(); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index e424e4759e7..a3c18658cb4 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -80,7 +80,7 @@ export const AddNodePoolDrawer = (props: Props) => { const { data: types } = useAllTypes(open); const { error, - isLoading, + isPending, mutateAsync: createPool, } = useCreateNodePoolMutation(clusterId); @@ -165,7 +165,7 @@ export const AddNodePoolDrawer = (props: Props) => { {error && ( @@ -186,7 +186,7 @@ export const AddNodePoolDrawer = (props: Props) => { hasSelectedRegion={hasSelectedRegion} isPlanPanelDisabled={isPlanPanelDisabled} isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} - isSubmitting={isLoading} + isSubmitting={isPending} regionsData={regionsData} resetValues={resetDrawer} selectedId={selectedTypeInfo?.planId} @@ -237,7 +237,7 @@ export const AddNodePoolDrawer = (props: Props) => { primaryButtonProps={{ disabled: !selectedTypeInfo || hasInvalidPrice, label: 'Add pool', - loading: isLoading, + loading: isPending, onClick: handleAdd, }} /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx index 976c7c7ae12..5827db7f9a9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx @@ -1,11 +1,9 @@ -import { AutoscaleSettings, KubeNodePoolResponse } from '@linode/api-v4'; import { AutoscaleNodePoolSchema } from '@linode/validation/lib/kubernetes.schema'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Button } from 'src/components/Button/Button'; @@ -18,6 +16,9 @@ import { Toggle } from 'src/components/Toggle/Toggle'; import { Typography } from 'src/components/Typography'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; +import type { AutoscaleSettings, KubeNodePoolResponse } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + interface Props { clusterId: number; handleOpenResizeDrawer: (poolId: number) => void; @@ -72,7 +73,7 @@ export const AutoscalePoolDialog = (props: Props) => { const { classes, cx } = useStyles(); const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useUpdateNodePoolMutation( + const { error, isPending, mutateAsync } = useUpdateNodePoolMutation( clusterId, nodePool?.id ?? -1 ); @@ -126,7 +127,7 @@ export const AutoscalePoolDialog = (props: Props) => { values.max === autoscaler?.max) || Object.keys(errors).length !== 0, label: 'Save Changes', - loading: isLoading || isSubmitting, + loading: isPending || isSubmitting, onClick: () => handleSubmit(), }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/DeleteNodePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/DeleteNodePoolDialog.tsx index 57039d4161d..03252855280 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/DeleteNodePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/DeleteNodePoolDialog.tsx @@ -1,4 +1,3 @@ -import { KubeNodePoolResponse } from '@linode/api-v4'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -7,6 +6,8 @@ import { Typography } from 'src/components/Typography'; import { useDeleteNodePoolMutation } from 'src/queries/kubernetes'; import { pluralize } from 'src/utilities/pluralize'; +import type { KubeNodePoolResponse } from '@linode/api-v4'; + interface Props { kubernetesClusterId: number; nodePool: KubeNodePoolResponse | undefined; @@ -17,7 +18,7 @@ interface Props { export const DeleteNodePoolDialog = (props: Props) => { const { kubernetesClusterId, nodePool, onClose, open } = props; - const { error, isLoading, mutateAsync } = useDeleteNodePoolMutation( + const { error, isPending, mutateAsync } = useDeleteNodePoolMutation( kubernetesClusterId, nodePool?.id ?? -1 ); @@ -35,7 +36,7 @@ export const DeleteNodePoolDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm', label: 'Delete', - loading: isLoading, + loading: isPending, onClick: onDelete, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/RecycleNodeDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/RecycleNodeDialog.tsx index 7d049d5bb7f..29fe3e4fd48 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/RecycleNodeDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/RecycleNodeDialog.tsx @@ -19,7 +19,7 @@ export const RecycleNodeDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useRecycleNodeMutation( + const { error, isPending, mutateAsync } = useRecycleNodeMutation( clusterId, nodeId ); @@ -36,7 +36,7 @@ export const RecycleNodeDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm', label: 'Recycle', - loading: isLoading, + loading: isPending, onClick: onSubmit, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index f097ae6fd17..3bfa0f3ef1f 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -1,5 +1,3 @@ -import { KubeNodePoolResponse, Region } from '@linode/api-v4'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -22,6 +20,9 @@ import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../kubeUtils'; import { hasInvalidNodePoolPrice } from './utils'; +import type { KubeNodePoolResponse, Region } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ helperText: { paddingBottom: `calc(${theme.spacing(2)} + 1px)`, @@ -64,7 +65,7 @@ export const ResizeNodePoolDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateNodePool, } = useUpdateNodePoolMutation(kubernetesClusterId, nodePool?.id ?? -1); @@ -186,7 +187,7 @@ export const ResizeNodePoolDrawer = (props: Props) => { 'data-testid': 'submit', disabled: updatedCount === nodePool.count || hasInvalidPrice, label: 'Save Changes', - loading: isLoading, + loading: isPending, onClick: handleSubmit, }} /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleClusterDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleClusterDialog.tsx index 9f21a7e5c92..31f999342d2 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleClusterDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleClusterDialog.tsx @@ -20,7 +20,7 @@ export const RecycleClusterDialog = (props: Props) => { const { clusterId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useRecycleClusterMutation( + const { error, isPending, mutateAsync } = useRecycleClusterMutation( clusterId ); @@ -38,7 +38,7 @@ export const RecycleClusterDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm', label: 'Recycle All Cluster Nodes', - loading: isLoading, + loading: isPending, onClick: onSubmit, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleNodePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleNodePoolDialog.tsx index c1c94545378..8deb2c6ecbe 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleNodePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleNodePoolDialog.tsx @@ -22,7 +22,7 @@ export const RecycleNodePoolDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useRecycleNodePoolMutation( + const { error, isPending, mutateAsync } = useRecycleNodePoolMutation( clusterId, nodePoolId ); @@ -41,7 +41,7 @@ export const RecycleNodePoolDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm', label: 'Recycle Pool Nodes', - loading: isLoading, + loading: isPending, onClick: onRecycle, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.test.tsx index cef64a36e12..ba5d303d69c 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.test.tsx @@ -21,7 +21,7 @@ describe('Actions', () => { component: , }); - const button = getByText('Create using command line').closest('button'); + const button = getByText('Create Using Command Line').closest('button'); expect(button).toBeVisible(); expect(button).toBeEnabled(); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx index 468d4f6e606..0f95404a91c 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx @@ -1,9 +1,12 @@ +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React, { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; @@ -19,12 +22,13 @@ import type { LinodeCreateFormValues } from './utilities'; export const Actions = () => { const flags = useFlags(); - + const ldClient = useLDClient(); const { params } = useLinodeCreateQueryParams(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); const [isAPIAwarenessModalOpen, setIsAPIAwarenessModalOpen] = useState(false); - const isDxToolsAdditionsEnabled = flags?.apicliDxToolsAdditions; + const apicliButtonCopy = flags?.testdxtoolabexperiment; const { formState, @@ -44,13 +48,18 @@ export const Actions = () => { sendLinodeCreateFormInputEvent({ createType: params.type ?? 'OS', interaction: 'click', - label: isDxToolsAdditionsEnabled - ? 'View Code Snippets' - : 'Create Using Command Line', + label: apicliButtonCopy ?? 'Create Using Command Line', }); if (await trigger()) { // If validation is successful, we open the dialog. setIsAPIAwarenessModalOpen(true); + if (!isInternalAccount) { + ldClient?.track(LD_DX_TOOLS_METRICS_KEYS.OPEN_MODAL, { + variation: apicliButtonCopy, + }); + } + + ldClient?.flush(); } else { scrollErrorIntoView(undefined, { behavior: 'smooth' }); } @@ -59,9 +68,7 @@ export const Actions = () => { return (
    diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx index bb6da070f08..fb7aaa33c32 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx @@ -140,20 +140,19 @@ export const LinodeSelectTable = (props: Props) => { )} { - if (preselectedLinodeId) { - setPreselectedLinodeId(undefined); - } - setQuery(value ?? ''); - }, - value: preselectedLinodeId ? field.value?.label ?? '' : query, + onSearch={(value) => { + if (preselectedLinodeId) { + setPreselectedLinodeId(undefined); + } + setQuery(value); }} clearable + debounceTime={250} hideLabel isSearching={isFetching} label="Search" placeholder="Search" + value={preselectedLinodeId ? field.value?.label ?? '' : query} /> {matchesMdUp ? ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 631d6f417cb..5679f43bc84 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -1,4 +1,3 @@ -import { omit } from 'lodash'; import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; @@ -9,6 +8,7 @@ import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalyt import { sendLinodeCreateFormErrorEvent } from 'src/utilities/analytics/formEventAnalytics'; import { privateIPRegex } from 'src/utilities/ipUtils'; import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; +import { omitProps } from 'src/utilities/omittedProps'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { utoa } from '../LinodesCreate/utilities'; @@ -148,7 +148,7 @@ export const tabs: LinodeCreateType[] = [ export const getLinodeCreatePayload = ( formValues: LinodeCreateFormValues ): CreateLinodeRequest => { - const values = omit(formValues, [ + const values = omitProps(formValues, [ 'linode', 'hasSignedEUAgreement', 'firewallOverride', diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx index 3783116c89b..3addcabeac1 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx @@ -4,7 +4,7 @@ import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { Notice } from 'src/components/Notice/Notice'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { notificationContext as _notificationContext } from 'src/features/NotificationCenter/NotificationContext'; +import { notificationCenterContext as _notificationContext } from 'src/features/NotificationCenter/NotificationCenterContext'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useVPCConfigInterface } from 'src/hooks/useVPCConfigInterface'; import { useInProgressEvents } from 'src/queries/events/events'; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/AnsibleIntegrationResources.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/AnsibleIntegrationResources.tsx index 1deec18f58c..396b631bad1 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/AnsibleIntegrationResources.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/AnsibleIntegrationResources.tsx @@ -1,7 +1,11 @@ +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React from 'react'; import { ResourceLinks } from 'src/components/EmptyLandingPageResources/ResourcesLinks'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import type { ResourcesLinks } from 'src/components/EmptyLandingPageResources/ResourcesLinksTypes'; @@ -37,6 +41,24 @@ export const gettingStartedGuides: ResourcesLinks['links'] = [ ]; export const AnsibleIntegrationResources = () => { + const ldClient = useLDClient(); + const flags = useFlags(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); + + const apicliButtonCopy = flags?.testdxtoolabexperiment; + + const handleClick = () => { + if (!isInternalAccount) { + ldClient?.track( + LD_DX_TOOLS_METRICS_KEYS.INTEGRATION_ANSIBLE_RESOURCE_LINKS, + { + variation: apicliButtonCopy, + } + ); + } + + ldClient?.flush(); + }; return ( <> ({ mt: theme.spacing(2) })} variant="h3"> @@ -45,6 +67,7 @@ export const AnsibleIntegrationResources = () => { ); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.tsx index e5aac10e1b9..9d51b921b93 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.tsx @@ -1,4 +1,5 @@ import { styled } from '@mui/material/styles'; +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; @@ -11,7 +12,9 @@ import { TabList } from 'src/components/Tabs/TabList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import { useInProgressEvents } from 'src/queries/events/events'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; @@ -58,8 +61,10 @@ export const ApiAwarenessModal = (props: ApiAwarenessModalProps) => { const { isOpen, onClose, payLoad } = props; const flags = useFlags(); + const ldClient = useLDClient(); const history = useHistory(); const { data: events } = useInProgressEvents(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); const linodeCreationEvent = events?.find( (event) => @@ -71,13 +76,37 @@ export const ApiAwarenessModal = (props: ApiAwarenessModalProps) => { const isLinodeCreated = linodeCreationEvent !== undefined; const isDxAdditionsFeatureEnabled = flags?.apicliDxToolsAdditions; + const apicliButtonCopy = flags?.testdxtoolabexperiment; const tabs = isDxAdditionsFeatureEnabled ? [baseTabs[1], baseTabs[0], ...additionalTabs] : baseTabs; const handleTabChange = (index: number) => { - sendApiAwarenessClickEvent(`${tabs[index].type} Tab`, tabs[index].type); + const { title, type } = tabs[index]; + + sendApiAwarenessClickEvent(`${type} Tab`, type); + + const trackingKey = + type === 'INTEGRATIONS' && title !== "SDK's" + ? LD_DX_TOOLS_METRICS_KEYS.INTEGRATION_TAB_SELECTION + : type === 'API' + ? LD_DX_TOOLS_METRICS_KEYS.CURL_TAB_SELECTION + : title === "SDK's" + ? LD_DX_TOOLS_METRICS_KEYS.SDK_TAB_SELECTION + : title === 'Linode CLI' + ? LD_DX_TOOLS_METRICS_KEYS.LINODE_CLI_TAB_SELECTION + : undefined; + + if (trackingKey) { + if (!isInternalAccount) { + ldClient?.track(trackingKey, { + variation: apicliButtonCopy, + }); + } + + ldClient?.flush(); + } }; useEffect(() => { diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/CurlTabPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/CurlTabPanel.tsx index 35854082daa..872b2ca8950 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/CurlTabPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/CurlTabPanel.tsx @@ -1,9 +1,13 @@ import { useTheme } from '@mui/material/styles'; +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React, { useMemo } from 'react'; import { Link } from 'src/components/Link'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; import { generateCurlCommand } from 'src/utilities/codesnippets/generate-cURL'; @@ -18,19 +22,30 @@ export interface CurlTabPanelProps { } export const CurlTabPanel = ({ index, payLoad, title }: CurlTabPanelProps) => { + const flags = useFlags(); + const ldClient = useLDClient(); const theme = useTheme(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); const curlCommand = useMemo( () => generateCurlCommand(payLoad, '/linode/instances'), [payLoad] ); + const apicliButtonCopy = flags?.testdxtoolabexperiment; return ( Most Linode API requests need to be authenticated with a valid{' '} - sendApiAwarenessClickEvent('link', 'personal access token') - } + onClick={() => { + sendApiAwarenessClickEvent('link', 'personal access token'); + if (!isInternalAccount) { + ldClient?.track(LD_DX_TOOLS_METRICS_KEYS.CURL_RESOURCE_LINKS, { + variation: apicliButtonCopy, + }); + } + + ldClient?.flush(); + }} to="/profile/tokens" > personal access token @@ -38,28 +53,47 @@ export const CurlTabPanel = ({ index, payLoad, title }: CurlTabPanelProps) => { . The command below assumes that your personal access token has been stored within the TOKEN shell variable. For more information, see{' '} + onClick={() => { sendApiAwarenessClickEvent( 'link', 'Get Started with the Linode API' - ) - } + ); + if (!isInternalAccount) { + ldClient?.track(LD_DX_TOOLS_METRICS_KEYS.CURL_RESOURCE_LINKS, { + variation: apicliButtonCopy, + }); + } + + ldClient?.flush(); + }} to="https://www.linode.com/docs/products/tools/api/get-started/" > Get Started with the Linode API {' '} and{' '} - sendApiAwarenessClickEvent('link', 'Linode API Guides') - } + onClick={() => { + sendApiAwarenessClickEvent('link', 'Linode API Guides'); + if (!isInternalAccount) { + ldClient?.track(LD_DX_TOOLS_METRICS_KEYS.CURL_RESOURCE_LINKS, { + variation: apicliButtonCopy, + }); + } + + ldClient?.flush(); + }} to="https://www.linode.com/docs/products/tools/api/guides/" > Linode API Guides . - + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/GoSDKResources.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/GoSDKResources.tsx index cd0a6854912..9f3d8ac7b9f 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/GoSDKResources.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/GoSDKResources.tsx @@ -1,7 +1,11 @@ +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React from 'react'; import { ResourceLinks } from 'src/components/EmptyLandingPageResources/ResourcesLinks'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import type { ResourcesLinks } from 'src/components/EmptyLandingPageResources/ResourcesLinksTypes'; @@ -22,6 +26,21 @@ export const gettingStartedGuides: ResourcesLinks['links'] = [ ]; export const GoSDKResources = () => { + const ldClient = useLDClient(); + const flags = useFlags(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); + + const apicliButtonCopy = flags?.testdxtoolabexperiment; + + const handleClick = () => { + if (!isInternalAccount) { + ldClient?.track(LD_DX_TOOLS_METRICS_KEYS.SDK_GO_RESOURCE_LINKS, { + variation: apicliButtonCopy, + }); + } + + ldClient?.flush(); + }; return ( <> ({ mt: theme.spacing(2) })} variant="h3"> @@ -30,6 +49,7 @@ export const GoSDKResources = () => { ); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/IntegrationsTabPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/IntegrationsTabPanel.tsx index 541135dae56..9162467f966 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/IntegrationsTabPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/IntegrationsTabPanel.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useState } from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; import { generateAnsibleConfig } from 'src/utilities/codesnippets/generate-ansibleConfig'; import { generateTerraformConfig } from 'src/utilities/codesnippets/generate-terraformConfig'; @@ -28,7 +29,6 @@ const integrationsOptions: OptionType[] = [ export const IntegrationsTabPanel = ({ payLoad, - title, }: IntegrationsTabPanelProps) => { const [selectedIntegration, setSelectedIntegration] = useState< OptionType | undefined @@ -45,6 +45,7 @@ export const IntegrationsTabPanel = ({ const handleIntegrationChange = (option: OptionType) => { setSelectedIntegration(option); }; + return ( @@ -72,7 +73,12 @@ export const IntegrationsTabPanel = ({ ? ansibleConfig : terraformConfig } - commandType={title} + ldTrackingKey={ + selectedIntegration.value === 'ansible' + ? LD_DX_TOOLS_METRICS_KEYS.INTEGRATION_ANSIBLE_CODE_SNIPPET + : LD_DX_TOOLS_METRICS_KEYS.INTEGRATION_TERRAFORM_CODE_SNIPPET + } + commandType={selectedIntegration.value} language={'bash'} /> diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/LinodeCLIPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/LinodeCLIPanel.tsx index 8da588629d5..215be0d306d 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/LinodeCLIPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/LinodeCLIPanel.tsx @@ -1,8 +1,12 @@ +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React, { useMemo } from 'react'; import { Link } from 'src/components/Link'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; import { generateCLICommand } from 'src/utilities/codesnippets/generate-cli'; @@ -21,7 +25,11 @@ export const LinodeCLIPanel = ({ payLoad, title, }: LinodeCLIPanelProps) => { + const ldClient = useLDClient(); + const flags = useFlags(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); const cliCommand = useMemo(() => generateCLICommand(payLoad), [payLoad]); + const apicliButtonCopy = flags?.testdxtoolabexperiment; return ( @@ -29,12 +37,21 @@ export const LinodeCLIPanel = ({ Before running the command below, the Linode CLI needs to be installed and configured on your system. See the{' '} + onClick={() => { sendApiAwarenessClickEvent( 'link', 'Install and Configure the Linode CLI' - ) - } + ); + if (!isInternalAccount) { + ldClient?.track( + LD_DX_TOOLS_METRICS_KEYS.LINODE_CLI_RESOURCE_LINKS, + { + variation: apicliButtonCopy, + } + ); + } + ldClient?.flush(); + }} to="https://www.linode.com/docs/products/tools/cli/guides/install/" > Install and Configure the Linode CLI @@ -42,16 +59,30 @@ export const LinodeCLIPanel = ({ guide for instructions. To learn more and to use the Linode CLI for tasks, review additional{' '} - sendApiAwarenessClickEvent('link', 'Linode CLI Guides') - } + onClick={() => { + sendApiAwarenessClickEvent('link', 'Linode CLI Guides'); + if (!isInternalAccount) { + ldClient?.track( + LD_DX_TOOLS_METRICS_KEYS.LINODE_CLI_RESOURCE_LINKS, + { + variation: apicliButtonCopy, + } + ); + } + ldClient?.flush(); + }} to="https://www.linode.com/docs/products/tools/cli/guides/" > Linode CLI Guides . - + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/PythonSDKResources.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/PythonSDKResources.tsx index a9f3ac2898f..1a1271c3d44 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/PythonSDKResources.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/PythonSDKResources.tsx @@ -1,7 +1,11 @@ +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React from 'react'; import { ResourceLinks } from 'src/components/EmptyLandingPageResources/ResourcesLinks'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import type { ResourcesLinks } from 'src/components/EmptyLandingPageResources/ResourcesLinksTypes'; @@ -22,6 +26,21 @@ export const gettingStartedGuides: ResourcesLinks['links'] = [ ]; export const PythonSDKResources = () => { + const ldClient = useLDClient(); + const flags = useFlags(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); + + const apicliButtonCopy = flags?.testdxtoolabexperiment; + + const handleClick = () => { + if (!isInternalAccount) { + ldClient?.track(LD_DX_TOOLS_METRICS_KEYS.SDK_PYTHON_RESOURCE_LINKS, { + variation: apicliButtonCopy, + }); + } + + ldClient?.flush(); + }; return ( <> ({ mt: theme.spacing(2) })} variant="h3"> @@ -30,6 +49,7 @@ export const PythonSDKResources = () => { ); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/SDKTabPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/SDKTabPanel.tsx index 2424c373077..ca85aaf367e 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/SDKTabPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/SDKTabPanel.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useState } from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; import { generateGoLinodeSnippet } from 'src/utilities/codesnippets/generate-goSDKSnippet'; import { generatePythonLinodeSnippet } from 'src/utilities/codesnippets/generate-pythonSDKSnippet'; @@ -65,6 +66,11 @@ export const SDKTabPanel = ({ payLoad, title }: SDKTabPanelProps) => { command={ selectedSDK.value === 'go' ? linodegoSnippet : pythonLinodeSnippet } + ldTrackingKey={ + selectedSDK.value === 'go' + ? LD_DX_TOOLS_METRICS_KEYS.SDK_GO_CODE_SNIPPET + : LD_DX_TOOLS_METRICS_KEYS.SDK_PYTHON_CODE_SNIPPET + } commandType={title} language={'bash'} /> diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/TerraformIntegrationResources.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/TerraformIntegrationResources.tsx index 713a2dea71b..b2d31558b55 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/TerraformIntegrationResources.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/TerraformIntegrationResources.tsx @@ -1,7 +1,11 @@ +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React from 'react'; import { ResourceLinks } from 'src/components/EmptyLandingPageResources/ResourcesLinks'; import { Typography } from 'src/components/Typography'; +import { LD_DX_TOOLS_METRICS_KEYS } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import type { ResourcesLinks } from 'src/components/EmptyLandingPageResources/ResourcesLinksTypes'; @@ -43,6 +47,24 @@ export const gettingStartedGuides: ResourcesLinks['links'] = [ ]; export const TerraformIntegrationResources = () => { + const ldClient = useLDClient(); + const flags = useFlags(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); + + const apicliButtonCopy = flags?.testdxtoolabexperiment; + + const handleClick = () => { + if (!isInternalAccount) { + ldClient?.track( + LD_DX_TOOLS_METRICS_KEYS.INTEGRATION_TERRAFORM_RESOURCE_LINKS, + { + variation: apicliButtonCopy, + } + ); + } + + ldClient?.flush(); + }; return ( <> ({ mt: theme.spacing(2) })} variant="h3"> @@ -51,6 +73,7 @@ export const TerraformIntegrationResources = () => { ); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/CodeBlock/CodeBlock.styles.ts b/packages/manager/src/features/Linodes/LinodesCreate/CodeBlock/CodeBlock.styles.ts index a6c18b11e8d..2308b4c5abf 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/CodeBlock/CodeBlock.styles.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/CodeBlock/CodeBlock.styles.ts @@ -1,6 +1,7 @@ import { styled } from '@mui/material/styles'; -import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/HighlightedMarkdown'; + import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/HighlightedMarkdown'; export const StyledCommandDiv = styled('div', { label: 'StyledCommandDiv' })( ({ theme }) => ({ diff --git a/packages/manager/src/features/Linodes/LinodesCreate/CodeBlock/CodeBlock.tsx b/packages/manager/src/features/Linodes/LinodesCreate/CodeBlock/CodeBlock.tsx index a06e23eed77..9b97e149287 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/CodeBlock/CodeBlock.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/CodeBlock/CodeBlock.tsx @@ -1,6 +1,10 @@ +import { useLDClient } from 'launchdarkly-react-client-sdk'; import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; + import { StyledCommandDiv, StyledCopyTooltip, @@ -11,13 +15,26 @@ export interface CodeBlockProps { command: string; commandType: string; language: 'bash'; + ldTrackingKey?: string; } export const CodeBlock = (props: CodeBlockProps) => { - const { command, commandType, language } = props; + const flags = useFlags(); + const ldClient = useLDClient(); + const { isAkamaiAccount: isInternalAccount } = useIsAkamaiAccount(); + + const { command, commandType, language, ldTrackingKey } = props; + + const apicliButtonCopy = flags?.testdxtoolabexperiment; const handleCopyIconClick = () => { sendApiAwarenessClickEvent('Copy Icon', commandType); + if (ldTrackingKey && !isInternalAccount) { + ldClient?.track(ldTrackingKey, { + variation: apicliButtonCopy, + }); + ldClient?.flush(); + } }; return ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 96eed67ab62..c305ec6ccc6 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -420,7 +420,7 @@ export class LinodeCreate extends React.PureComponent< }; handleClickCreateUsingCommandLine = ( - isDxToolsAdditionsEnabled: boolean | undefined + apicliButtonCopy: string | undefined ) => { const payload = { authorized_users: this.props.authorized_users, @@ -444,15 +444,11 @@ export class LinodeCreate extends React.PureComponent< sendLinodeCreateFormInputEvent({ createType: 'OS', interaction: 'click', - label: isDxToolsAdditionsEnabled - ? 'View Code Snippets' - : 'Create Using Command Line', + label: apicliButtonCopy ?? 'Create Using Command Line', }); sendApiAwarenessClickEvent( 'Button', - isDxToolsAdditionsEnabled - ? 'View Code Snippets' - : 'Create Using Command Line' + apicliButtonCopy ?? 'Create Using Command Line' ); this.props.checkValidation(payload); }; @@ -641,7 +637,7 @@ export class LinodeCreate extends React.PureComponent< const hasErrorFor = getErrorMap(errorMap, errors); const generalError = getErrorMap(errorMap, errors).none; - const isDxToolsAdditionsEnabled = this.props.flags?.apicliDxToolsAdditions; + const apicliButtonCopy = this.props.flags?.apicliButtonCopy; if (regionsLoading || imagesLoading || linodesLoading || typesLoading) { return ; @@ -842,7 +838,7 @@ export class LinodeCreate extends React.PureComponent< {generalError && ( @@ -1205,16 +1201,12 @@ export class LinodeCreate extends React.PureComponent< secureVMViolation } onClick={() => - this.handleClickCreateUsingCommandLine( - isDxToolsAdditionsEnabled - ) + this.handleClickCreateUsingCommandLine(apicliButtonCopy) } buttonType="outlined" data-qa-api-cli-linode > - {isDxToolsAdditionsEnabled - ? 'View Code Snippets' - : 'Create using command line'} + {apicliButtonCopy ?? ' Create using command line'} { target: { value: defaultProps.linodes[0].label }, }); - await expect((await findAllByRole('row')).length).toBe(2); - - expect((await findAllByRole('row'))[1]).toHaveTextContent( - defaultProps.linodes[0].label - ); + await waitFor(async () => { + // wait for rows to be rendered + const rows = await findAllByRole('row'); + // Assert that 2 rows are rendered + expect(rows.length).toBe(2); + // Check that the second row has the correct text content + expect(rows[1]).toHaveTextContent(defaultProps.linodes[0].label); + }); }); it('displays the heading, notices and error', () => { @@ -188,13 +191,14 @@ describe('SelectLinodePanel (cards, mobile)', () => { target: { value: defaultProps.linodes[0].label }, }); - await expect( - container.querySelectorAll('[data-qa-selection-card]').length - ).toBe(1); - - expect( - container.querySelectorAll('[data-qa-selection-card]')[0] - ).toHaveTextContent(defaultProps.linodes[0].label); + await waitFor(() => { + expect( + container.querySelectorAll('[data-qa-selection-card]').length + ).toBe(1); + expect( + container.querySelectorAll('[data-qa-selection-card]')[0] + ).toHaveTextContent(defaultProps.linodes[0].label); + }); }); it('prefills the search box when mounted with a selected linode', async () => { diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx index 7fc4a1a78d0..c9bc7bfd1b7 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx @@ -1,4 +1,3 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; import { useMediaQuery } from '@mui/material'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -8,7 +7,7 @@ import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextFiel import { List } from 'src/components/List'; import { ListItem } from 'src/components/ListItem'; import { Notice } from 'src/components/Notice/Notice'; -import { OrderByProps, sortData } from 'src/components/OrderBy'; +import { sortData } from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Paper } from 'src/components/Paper'; @@ -21,6 +20,9 @@ import { PowerActionsDialog } from '../../PowerActionsDialogOrDrawer'; import { SelectLinodeCards } from './SelectLinodeCards'; import { SelectLinodeTable } from './SelectLinodeTable'; +import type { Linode } from '@linode/api-v4/lib/linodes'; +import type { OrderByProps } from 'src/components/OrderBy'; + interface Props { disabled?: boolean; error?: string; @@ -138,10 +140,6 @@ export const SelectLinodePanel = (props: Props) => { {!!header ? header : 'Select Linode'} { expand={true} hideLabel label="" + onSearch={setUserSearchText} placeholder="Search" + value={searchText} /> { const { error, - isLoading, + isPending, mutateAsync: cancelBackups, } = useLinodeBackupsCancelMutation(linodeId); @@ -43,7 +43,7 @@ export const CancelBackupsDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm-cancel', label: 'Cancel Backups', - loading: isLoading, + loading: isPending, onClick: onCancelBackups, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx index e25355458de..7cc32e768e6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx @@ -28,7 +28,7 @@ export const CaptureSnapshot = (props: Props) => { const { error: snapshotError, - isLoading: isSnapshotLoading, + isPending: isSnapshotLoading, mutateAsync: takeSnapshot, reset, } = useLinodeBackupSnapshotMutation(linodeId); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx index 7a3c74ff111..9c650c75a7a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx @@ -28,7 +28,7 @@ export const EnableBackupsDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: enableBackups, reset, } = useLinodeBackupsEnableMutation(linodeId ?? -1); @@ -82,7 +82,7 @@ export const EnableBackupsDialog = (props: Props) => { 'data-testid': 'confirm-enable-backups', disabled: hasBackupsMonthlyPriceError, label: 'Enable Backups', - loading: isLoading, + loading: isPending, onClick: handleEnableBackups, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx index 4abf31d5e11..197a2448624 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx @@ -47,7 +47,7 @@ export const RestoreToLinodeDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: restoreBackup, reset: resetMutation, } = useLinodeBackupRestoreMutation(); @@ -155,7 +155,7 @@ export const RestoreToLinodeDrawer = (props: Props) => { primaryButtonProps={{ 'data-testid': 'restore-submit', label: 'Restore', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx index 504bf41fcc6..bf34e71d935 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx @@ -32,7 +32,7 @@ export const ScheduleSettings = (props: Props) => { const { error: updateLinodeError, - isLoading: isUpdating, + isPending: isUpdating, mutateAsync: updateLinode, } = useLinodeUpdateMutation(linodeId); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx index 59562064701..fc03df168c5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx @@ -1,4 +1,3 @@ -import { Config } from '@linode/api-v4'; import { useSnackbar } from 'notistack'; import React from 'react'; @@ -8,6 +7,8 @@ import { Typography } from 'src/components/Typography'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useRebootLinodeMutation } from 'src/queries/linodes/linodes'; +import type { Config } from '@linode/api-v4'; + interface Props { config: Config | undefined; linodeId: number; @@ -19,7 +20,7 @@ export const BootConfigDialog = (props: Props) => { const { config, linodeId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useRebootLinodeMutation(linodeId); + const { error, isPending, mutateAsync } = useRebootLinodeMutation(linodeId); const { checkForNewEvents } = useEventsPollingActions(); @@ -36,7 +37,7 @@ export const BootConfigDialog = (props: Props) => { { const { config, linodeId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useLinodeConfigDeleteMutation( + const { error, isPending, mutateAsync } = useLinodeConfigDeleteMutation( linodeId, config?.id ?? -1 ); @@ -35,7 +36,7 @@ export const DeleteConfigDialog = (props: Props) => { { onClose: vi.fn(), }; - const { - getAllByPlaceholderText, - findByText, - getByTestId, - rerender, - } = renderWithTheme( + const { findByDisplayValue, rerender } = renderWithTheme( ); @@ -175,17 +168,7 @@ describe('LinodeConfigDialog', () => { /> ); - const loadingTestId = 'circle-progress'; - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - const interfaceSelectMenu = getAllByPlaceholderText('Select an Interface'); - - await userEvent.click(interfaceSelectMenu[0]); - - await findByText('VPC'); - await findByText('Public Internet'); + await findByDisplayValue('VPC'); + await findByDisplayValue('Public Internet'); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index 83806a57416..f018743f17f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -94,14 +94,14 @@ export const AddIPDrawer = (props: Props) => { const { error: ipv4Error, - isLoading: ipv4Loading, + isPending: ipv4Loading, mutateAsync: allocateIPAddress, reset: resetIPv4, } = useAllocateIPMutation(linodeId); const { error: ipv6Error, - isLoading: ipv6Loading, + isPending: ipv6Loading, mutateAsync: createIPv6Range, reset: resetIPv6, } = useCreateIPv6RangeMutation(); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx index 16221f0a588..5450a53bfdf 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx @@ -17,7 +17,7 @@ export const DeleteIPDialog = (props: Props) => { const { address, linodeId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync: removeIP } = useLinodeIPDeleteMutation( + const { error, isPending, mutateAsync: removeIP } = useLinodeIPDeleteMutation( linodeId, address ); @@ -34,7 +34,7 @@ export const DeleteIPDialog = (props: Props) => { { const { error, - isLoading, + isPending, mutateAsync: removeRange, } = useLinodeRemoveRangeMutation(range.range); @@ -37,7 +37,7 @@ export const DeleteRangeDialog = (props: Props) => { { + it('renders the drawer correctly', () => { + const { getAllByRole, getByText } = renderWithTheme( + + ); + + // confirm drawer title and form fields render + expect(getByText('Edit Reverse DNS')).toBeVisible(); + expect(getByText('Leave this field blank to reset RDNS')).toBeVisible(); + expect(getByText('Enter a domain name')).toBeVisible(); + + // confirm buttons render + expect(getAllByRole('button')).toHaveLength(3); + expect(getByText('Cancel')).toBeVisible(); + expect(getByText('Save')).toBeVisible(); + }); + + it('closes the drawer', () => { + const { getByText } = renderWithTheme(); + + const cancelButton = getByText('Cancel'); + fireEvent.click(cancelButton); + expect(props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditIPRDNSDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditIPRDNSDrawer.tsx index 9944b5e1602..80c94d6a5f7 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditIPRDNSDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditIPRDNSDrawer.tsx @@ -1,4 +1,3 @@ -import { IPAddress } from '@linode/api-v4/lib/networking'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -7,10 +6,11 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { Typography } from 'src/components/Typography'; import { useLinodeIPMutation } from 'src/queries/linodes/networking'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { IPAddress } from '@linode/api-v4/lib/networking'; + interface Props { ip: IPAddress | undefined; onClose: () => void; @@ -23,7 +23,7 @@ export const EditIPRDNSDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateIP, reset, } = useLinodeIPMutation(); @@ -38,24 +38,27 @@ export const EditIPRDNSDrawer = (props: Props) => { address: ip?.address ?? '', rdns: values.rdns === '' ? null : values.rdns, }); - enqueueSnackbar(`Successfully updated RNS for ${ip?.address}`, { + enqueueSnackbar(`Successfully updated RDNS for ${ip?.address}`, { variant: 'success', }); onClose(); }, }); - React.useEffect(() => { - if (open) { - reset(); - formik.resetForm(); - } - }, [open]); + const onExited = () => { + formik.resetForm(); + reset(); + }; const errorMap = getErrorMap(['rdns'], error); return ( - +
    {Boolean(errorMap.none) && ( {errorMap.none} @@ -63,6 +66,7 @@ export const EditIPRDNSDrawer = (props: Props) => { { placeholder="Enter a domain name" value={formik.values.rdns} /> - - Leave this field blank to reset RDNS - diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.test.tsx new file mode 100644 index 00000000000..7fcab0a808d --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditRangeRDNSDrawer } from './EditRangeRDNSDrawer'; + +const props = { + linodeId: 1, + onClose: vi.fn(), + open: true, + range: undefined, +}; + +describe('EditRangeRDNSDrawer', () => { + it('renders the drawer correctly', () => { + const { getAllByRole, getByText } = renderWithTheme( + + ); + + // confirm drawer title and fields render + expect(getByText('Edit Reverse DNS')).toBeVisible(); + expect(getByText('Enter an IPv6 address')).toBeVisible(); + expect(getByText('Enter a domain name')).toBeVisible(); + expect(getByText('Leave this field blank to reset RDNS')).toBeVisible(); + + // confirm buttons render + expect(getAllByRole('button')).toHaveLength(3); + expect(getByText('Close')).toBeVisible(); + expect(getByText('Save')).toBeVisible(); + }); + + it('closes the drawer', () => { + const { getByText } = renderWithTheme(); + + const cancelButton = getByText('Close'); + fireEvent.click(cancelButton); + expect(props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx index a94ff66ec1d..b2d2193f80f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx @@ -45,7 +45,7 @@ export const EditRangeRDNSDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateIP, reset, } = useLinodeIPMutation(); @@ -61,7 +61,7 @@ export const EditRangeRDNSDrawer = (props: Props) => { address: values.address ?? '', rdns: values.rdns === '' ? null : values.rdns, }); - enqueueSnackbar(`Successfully updated RNS for ${range?.range}`, { + enqueueSnackbar(`Successfully updated RDNS for ${range?.range}`, { variant: 'success', }); onClose(); @@ -70,17 +70,20 @@ export const EditRangeRDNSDrawer = (props: Props) => { const theme = useTheme(); - React.useEffect(() => { - if (open) { - formik.resetForm(); - reset(); - } - }, [open]); + const onExited = () => { + formik.resetForm(); + reset(); + }; const errorMap = getErrorMap(['rdns'], error); return ( - +
    {Boolean(errorMap.none) && ( @@ -98,20 +101,18 @@ export const EditRangeRDNSDrawer = (props: Props) => { - - Leave this field blank to reset RDNS - { Existing Records
    {ips.map((ip) => ( -
    +
    {ip.address} {ip.rdns || ''}
    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 9ec025ef01b..accb632dbe4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx @@ -17,7 +17,7 @@ interface Props { export const LinodeNetworkingSummaryPanel = React.memo((props: Props) => { // @todo maybe move this query closer to the consuming component const { data: linode } = useLinodeQuery(props.linodeId); - const { isGeckoGAEnabled } = useIsGeckoEnabled(); + const { isGeckoLAEnabled } = useIsGeckoEnabled(); const theme = useTheme(); if (!linode) { @@ -25,7 +25,7 @@ export const LinodeNetworkingSummaryPanel = React.memo((props: Props) => { } const hideNetworkTransfer = - isGeckoGAEnabled && linode.site_type === 'distributed'; + isGeckoLAEnabled && linode.site_type === 'distributed'; return ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index fd5ac87dd92..a88ad4753bf 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -78,7 +78,7 @@ export const LinodeResize = (props: Props) => { const { error: resizeError, - isLoading, + isPending, mutateAsync: resizeLinode, } = useLinodeResizeMutation(linodeId ?? -1); @@ -326,7 +326,7 @@ export const LinodeResize = (props: Props) => { } buttonType="primary" data-qa-resize - loading={isLoading} + loading={isPending} type="submit" > Resize Linode diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx index ed710c27536..43923be7b1b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx @@ -29,7 +29,7 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateLinode, } = useLinodeUpdateMutation(linodeId); @@ -216,7 +216,7 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { 'data-testid': 'alerts-save', disabled: isReadOnly || !formik.dirty, label: 'Save', - loading: isLoading, + loading: isPending, onClick: () => formik.handleSubmit(), }} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx index ff27b1d8c9b..820b50b27b0 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx @@ -22,7 +22,7 @@ export const LinodeSettingsDeletePanel = (props: Props) => { const { data: linode } = useLinodeQuery(linodeId); const { error, - isLoading, + isPending, mutateAsync: deleteLinode, } = useDeleteLinodeMutation(linodeId); @@ -63,7 +63,7 @@ export const LinodeSettingsDeletePanel = (props: Props) => { }} errors={error} label={'Linode Label'} - loading={isLoading} + loading={isPending} onClick={onDelete} onClose={() => setOpen(false)} open={open} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.tsx index d3a594d4e69..abe4ea91ee2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.tsx @@ -27,7 +27,7 @@ export const LinodeSettingsLabelPanel = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateLinode, } = useLinodeUpdateMutation(linodeId); @@ -57,7 +57,7 @@ export const LinodeSettingsLabelPanel = (props: Props) => { 'data-testid': 'label-save', disabled: isReadOnly || !formik.dirty, label: 'Save', - loading: isLoading, + loading: isPending, onClick: () => formik.handleSubmit(), }} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx index e75d1c93876..b34f58107e8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx @@ -51,12 +51,12 @@ export const LinodeSettingsPasswordPanel = (props: Props) => { const { error: linodePasswordError, - isLoading: isLinodePasswordLoading, + isPending: isLinodePasswordLoading, mutateAsync: changeLinodePassword, } = useLinodeChangePasswordMutation(linodeId); const { error: diskPasswordError, - isLoading: isDiskPasswordLoading, + isPending: isDiskPasswordLoading, mutateAsync: changeLinodeDiskPassword, } = useLinodeDiskChangePasswordMutation(linodeId, selectedDiskId ?? -1); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx index e7eafa6cad4..c94d597c3a3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx @@ -24,7 +24,7 @@ export const LinodeWatchdogPanel = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateLinode, } = useLinodeUpdateMutation(linodeId); @@ -59,7 +59,7 @@ export const LinodeWatchdogPanel = (props: Props) => { label={ {linode?.watchdog_enabled ? 'Enabled' : 'Disabled'} - {isLoading && } + {isPending && } } disabled={isReadOnly} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/DeleteDiskDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/DeleteDiskDialog.tsx index 8bfd6b71dde..6ae127f9ad8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/DeleteDiskDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/DeleteDiskDialog.tsx @@ -18,7 +18,7 @@ export const DeleteDiskDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: deleteDisk, reset, } = useLinodeDeleteDiskMutation(linodeId, disk?.id ?? -1); @@ -41,7 +41,7 @@ export const DeleteDiskDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm-delete', label: 'Delete', - loading: isLoading, + loading: isPending, onClick: onDelete, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx index 6a4d181e61e..9c624845587 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx @@ -45,7 +45,7 @@ export const MutationNotification = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: startMutation, } = useStartLinodeMutationMutation(linodeId); @@ -132,7 +132,7 @@ export const MutationNotification = (props: Props) => { handleClose={() => setIsMutationDrawerOpen(false)} initMutation={initMutation} linodeId={linodeId} - loading={isLoading} + loading={isPending} open={isMutationDrawerOpen} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx index 1d5c479e392..ec74531a2af 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx @@ -29,7 +29,7 @@ export const UpgradeVolumesDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: migrateVolumes, } = useVolumesMigrateMutation(); @@ -58,7 +58,7 @@ export const UpgradeVolumesDialog = (props: Props) => { - diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx index 57d5d7d40d7..bc60145831a 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx @@ -28,7 +28,7 @@ export const DeleteLinodeDialog = (props: Props) => { linodeId !== undefined && open ); - const { error, isLoading, mutateAsync, reset } = useDeleteLinodeMutation( + const { error, isPending, mutateAsync, reset } = useDeleteLinodeMutation( linodeId ?? -1 ); @@ -77,8 +77,8 @@ export const DeleteLinodeDialog = (props: Props) => { type: 'Linode', }} errors={error} - label={'Linode Label'} - loading={isLoading} + label="Linode Label" + loading={isPending} onClick={onDelete} onClose={onClose} open={open} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index c1de987b624..52000589cae 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -1,4 +1,3 @@ -import { SxProps } from '@mui/system'; import * as React from 'react'; import Flag from 'src/assets/icons/flag.svg'; @@ -17,15 +16,13 @@ import { linodeInTransition, transitionText, } from 'src/features/Linodes/transitions'; -import { notificationContext as _notificationContext } from 'src/features/NotificationCenter/NotificationContext'; +import { notificationCenterContext as _notificationContext } from 'src/features/NotificationCenter/NotificationCenterContext'; import { useInProgressEvents } from 'src/queries/events/events'; import { useTypeQuery } from 'src/queries/types'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; -import { LinodeWithMaintenance } from 'src/utilities/linodes'; import { IPAddress } from '../IPAddress'; -import { LinodeHandlers } from '../LinodesLanding'; import { RegionIndicator } from '../RegionIndicator'; import { getLinodeIconStatus, parseMaintenanceStartTime } from '../utils'; import { @@ -34,6 +31,10 @@ import { StyledMaintenanceTableCell, } from './LinodeRow.styles'; +import type { LinodeHandlers } from '../LinodesLanding'; +import type { SxProps } from '@mui/system'; +import type { LinodeWithMaintenance } from 'src/utilities/linodes'; + interface Props extends LinodeWithMaintenance { handlers: LinodeHandlers; } diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx index aee1c6df8c9..583cc984ebe 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx @@ -174,10 +174,7 @@ describe('ConfigureForm component with price comparison', () => { {...props} currentRegion="us-east" selectedRegion="us-central" - />, - { - flags: { placementGroups: { beta: true, enabled: true } }, - } + /> ); // Verify that the PlacementGroupsSelect component is rendered diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx index 65cc84a2465..214e8b0f667 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Dialog } from 'src/components/Dialog/Dialog'; +import { ErrorMessage } from 'src/components/ErrorMessage'; import { Notice } from 'src/components/Notice/Notice'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { TooltipIcon } from 'src/components/TooltipIcon'; @@ -87,7 +88,7 @@ export const MigrateLinode = React.memo((props: Props) => { const { error, - isLoading, + isPending, mutateAsync: migrateLinode, reset, } = useLinodeMigrateMutation(linodeId ?? -1); @@ -234,7 +235,14 @@ export const MigrateLinode = React.memo((props: Props) => { open={open} title={`Migrate Linode ${linode.label ?? ''} to another region`} > - {error && } + {error && ( + + + + )} {newLabel} @@ -294,7 +302,7 @@ export const MigrateLinode = React.memo((props: Props) => { }, }} buttonType="primary" - loading={isLoading} + loading={isPending} onClick={handleMigrate} > Enter Migration Queue diff --git a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx index d69707fd69c..14a14fcd305 100644 --- a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx +++ b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx @@ -54,19 +54,19 @@ export const PowerActionsDialog = (props: Props) => { const { error: bootError, - isLoading: isBooting, + isPending: isBooting, mutateAsync: bootLinode, } = useBootLinodeMutation(linodeId ?? -1, configs); const { error: rebootError, - isLoading: isRebooting, + isPending: isRebooting, mutateAsync: rebootLinode, } = useRebootLinodeMutation(linodeId ?? -1, configs); const { error: shutdownError, - isLoading: isShuttingDown, + isPending: isShuttingDown, mutateAsync: shutdownLinode, } = useShutdownLinodeMutation(linodeId ?? -1); diff --git a/packages/manager/src/features/Linodes/index.tsx b/packages/manager/src/features/Linodes/index.tsx index 5d5e707f8e7..328b83f5951 100644 --- a/packages/manager/src/features/Linodes/index.tsx +++ b/packages/manager/src/features/Linodes/index.tsx @@ -1,8 +1,7 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { useFlags } from 'src/hooks/useFlags'; import { useAllAccountMaintenanceQuery } from 'src/queries/account/maintenance'; import { useInProgressEvents } from 'src/queries/events/events'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; @@ -18,9 +17,6 @@ const LinodesDetail = React.lazy(() => default: module.LinodeDetail, })) ); -const LinodesCreate = React.lazy( - () => import('./LinodesCreate/LinodeCreateContainer') -); const LinodesCreatev2 = React.lazy(() => import('./LinodeCreatev2').then((module) => ({ default: module.LinodeCreatev2, @@ -28,23 +24,10 @@ const LinodesCreatev2 = React.lazy(() => ); export const LinodesRoutes = () => { - const flags = useFlags(); - - // Hold this feature flag in state so that the user's Linode creation - // isn't interupted when the flag is toggled. - const [isLinodeCreateV2EnabledStale] = useState(flags.linodeCreateRefactor); - - const isLinodeCreateV2Enabled = import.meta.env.DEV - ? flags.linodeCreateRefactor - : isLinodeCreateV2EnabledStale; - return ( }> - + diff --git a/packages/manager/src/features/Lish/Glish.tsx b/packages/manager/src/features/Lish/Glish.tsx index f035e6f0944..c20835b0a86 100644 --- a/packages/manager/src/features/Lish/Glish.tsx +++ b/packages/manager/src/features/Lish/Glish.tsx @@ -10,16 +10,14 @@ import type { LinodeLishData } from '@linode/api-v4/lib/linodes'; import type { Linode } from '@linode/api-v4/lib/linodes'; import type { VncScreenHandle } from 'react-vnc'; -interface Props { +interface Props extends Omit { linode: Linode; refreshToken: () => Promise; } -type CombinedProps = Props & Omit; - let monitor: WebSocket; -const Glish = (props: CombinedProps) => { +const Glish = (props: Props) => { const { glish_url, linode, monitor_url, refreshToken, ws_protocols } = props; const ref = React.useRef(null); const [powered, setPowered] = React.useState(linode.status === 'running'); diff --git a/packages/manager/src/features/Lish/Lish.tsx b/packages/manager/src/features/Lish/Lish.tsx index b306ba80146..7bfbe47b4f4 100644 --- a/packages/manager/src/features/Lish/Lish.tsx +++ b/packages/manager/src/features/Lish/Lish.tsx @@ -86,7 +86,7 @@ const Lish = () => { await refetch(); }; - if (isLoading) { + if (isLoading || !linode || !data) { return ; } diff --git a/packages/manager/src/features/Lish/Weblish.tsx b/packages/manager/src/features/Lish/Weblish.tsx index d063a994c65..54a376bbb0f 100644 --- a/packages/manager/src/features/Lish/Weblish.tsx +++ b/packages/manager/src/features/Lish/Weblish.tsx @@ -8,7 +8,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import type { Linode } from '@linode/api-v4/lib/linodes'; import type { LinodeLishData } from '@linode/api-v4/lib/linodes'; -interface Props { +interface Props extends Pick { linode: Linode; refreshToken: () => Promise; } @@ -18,10 +18,7 @@ interface State { renderingLish: boolean; } -type CombinedProps = Props & - Pick; - -export class Weblish extends React.Component { +export class Weblish extends React.Component { mounted: boolean = false; socket: WebSocket; @@ -36,7 +33,7 @@ export class Weblish extends React.Component { this.connect(); } - componentDidUpdate(prevProps: CombinedProps) { + componentDidUpdate(prevProps: Props) { /* * If we have a new token, refresh the webosocket connection * and console with the new token @@ -110,7 +107,7 @@ export class Weblish extends React.Component { const { group, label } = linode; this.terminal = new Terminal({ - cols: 120, + cols: 80, cursorBlink: true, fontFamily: '"Ubuntu Mono", monospace, sans-serif', rows: 40, 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 24227d3d99b..3157c738200 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx @@ -49,7 +49,7 @@ export const ProcessesLanding = React.memo((props: Props) => { const { clientAPIKey, lastUpdated, lastUpdatedError, timezone } = props; // Text input for filtering processes by name or user. - const [inputText, setInputText] = React.useState(); + const [inputText, setInputText] = React.useState(); // The selected process row. const [selectedProcess, setSelectedProcess] = React.useState( diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx index 673aa8fb31c..a346b0e241f 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx @@ -1,25 +1,16 @@ -import { - ActiveLongviewPlan, - LongviewClient, - LongviewSubscription, -} from '@linode/api-v4/lib/longview/types'; import { isEmpty, pathOr } from 'ramda'; import * as React from 'react'; import { connect } from 'react-redux'; -import { Link, RouteComponentProps } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { compose } from 'recompose'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Typography } from 'src/components/Typography'; -import withLongviewClients, { - Props as LongviewProps, -} from 'src/containers/longview.container'; +import withLongviewClients from 'src/containers/longview.container'; import { useAccountSettings } from 'src/queries/account/settings'; import { useGrants, useProfile } from 'src/queries/profile/profile'; -import { State as StatsState } from 'src/store/longviewStats/longviewStats.reducer'; -import { MapState } from 'src/store/types'; import { LongviewPackageDrawer } from '../LongviewPackageDrawer'; import { sumUsedMemory } from '../shared/utilities'; @@ -36,6 +27,16 @@ import { LongviewDeleteDialog } from './LongviewDeleteDialog'; import { LongviewList } from './LongviewList'; import { SubscriptionDialog } from './SubscriptionDialog'; +import type { + ActiveLongviewPlan, + LongviewClient, + LongviewSubscription, +} from '@linode/api-v4/lib/longview/types'; +import type { RouteComponentProps } from 'react-router-dom'; +import type { Props as LongviewProps } from 'src/containers/longview.container'; +import type { State as StatsState } from 'src/store/longviewStats/longviewStats.reducer'; +import type { MapState } from 'src/store/types'; + interface Props { activeSubscription: ActiveLongviewPlan; handleAddClient: () => void; @@ -203,30 +204,32 @@ export const LongviewClients = (props: LongviewClientsCombinedProps) => { Sort by: { handleSortKeyChange(value); }} - size="small" textFieldProps={{ hideLabel: true, }} value={sortOptions.find( (thisOption) => thisOption.value === sortKey )} + disableClearable + fullWidth + label="Sort by" + options={sortOptions} + size="small" /> diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx index 33e8cf66a97..ec43a2f3ed1 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx @@ -96,6 +96,9 @@ export const ActiveCheck = (props: ActiveCheckProps) => { + props.onHealthCheckTypeChange(selected.value) + } textFieldProps={{ dataAttrs: { 'data-qa-active-check-select': true, @@ -103,15 +106,12 @@ export const ActiveCheck = (props: ActiveCheckProps) => { errorGroup: forEdit ? `${configIdx}` : undefined, }} autoHighlight + disableClearable disabled={disabled} errorText={errorMap.check} id={`type-${configIdx}`} - disableClearable label="Type" noMarginTop - onChange={(_, selected) => - props.onHealthCheckTypeChange(selected.value) - } options={typeOptions} size="small" value={defaultType || typeOptions[0]} @@ -188,6 +188,7 @@ export const ActiveCheck = (props: ActiveCheckProps) => { {['http', 'http_body'].includes(healthCheckType) && ( { {healthCheckType === 'http_body' && ( { + vi.resetAllMocks(); +}); + +const node: NodeBalancerConfigNodeFields = { + address: '', + label: '', + mode: 'accept', + modifyStatus: 'new', + port: 80, + weight: 100, +}; + +const props: NodeBalancerConfigPanelProps = { + addNode: vi.fn(), + algorithm: 'roundrobin', + checkBody: '', + checkPassive: true, + checkPath: '', + configIdx: 0, + disabled: false, + healthCheckAttempts: 2, + healthCheckInterval: 5, + healthCheckTimeout: 3, + healthCheckType: 'none', + nodes: [node], + onAlgorithmChange: vi.fn(), + onCheckBodyChange: vi.fn(), + onCheckPassiveChange: vi.fn(), + onCheckPathChange: vi.fn(), + onDelete: vi.fn(), + onHealthCheckAttemptsChange: vi.fn(), + onHealthCheckIntervalChange: vi.fn(), + onHealthCheckTimeoutChange: vi.fn(), + onHealthCheckTypeChange: vi.fn(), + onNodeAddressChange: vi.fn(), + onNodeLabelChange: vi.fn(), + onNodePortChange: vi.fn(), + onNodeWeightChange: vi.fn(), + onPortChange: vi.fn(), + onPrivateKeyChange: vi.fn(), + onProtocolChange: vi.fn(), + onProxyProtocolChange: vi.fn(), + onSave: vi.fn(), + onSessionStickinessChange: vi.fn(), + onSslCertificateChange: vi.fn(), + port: 80, + privateKey: '', + protocol: 'http', + proxyProtocol: 'none', + removeNode: vi.fn(), + sessionStickiness: 'table', + sslCertificate: '', +}; + +const activeHealthChecks = ['Interval', 'Timeout', 'Attempts']; + +describe('NodeBalancerConfigPanel', () => { + it('renders the NodeBalancerConfigPanel', () => { + const { + getByLabelText, + getByText, + queryByLabelText, + queryByTestId, + } = renderWithTheme(); + + expect(getByLabelText('Protocol')).toBeVisible(); + expect(getByLabelText('Algorithm')).toBeVisible(); + expect(getByLabelText('Session Stickiness')).toBeVisible(); + expect(getByLabelText('Type')).toBeVisible(); + expect(getByLabelText('Label')).toBeVisible(); + expect(getByLabelText('IP Address')).toBeVisible(); + expect(getByLabelText('Weight')).toBeVisible(); + expect(getByLabelText('Port')).toBeVisible(); + expect(getByText('Listen on this port.')).toBeVisible(); + expect(getByText('Active Health Checks')).toBeVisible(); + expect( + getByText( + 'Route subsequent requests from the client to the same backend.' + ) + ).toBeVisible(); + expect( + getByText( + 'Enable passive checks based on observing communication with back-end nodes.' + ) + ).toBeVisible(); + expect( + getByText( + "Active health checks proactively check the health of back-end nodes. 'HTTP Valid Status' requires a 2xx or 3xx response from the backend node. 'HTTP Body Regex' uses a regex to match against an expected result body." + ) + ).toBeVisible(); + expect(getByText('Add a Node')).toBeVisible(); + expect(getByText('Backend Nodes')).toBeVisible(); + + activeHealthChecks.forEach((type) => { + expect(queryByLabelText(type)).not.toBeInTheDocument(); + }); + expect(queryByTestId('ssl-certificate')).not.toBeInTheDocument(); + expect(queryByTestId('private-key')).not.toBeInTheDocument(); + expect(queryByTestId('http-path')).not.toBeInTheDocument(); + expect(queryByTestId('http-body')).not.toBeInTheDocument(); + expect(queryByLabelText('Proxy Protocol')).not.toBeInTheDocument(); + }); + + it('renders form fields specific to the HTTPS protocol', () => { + const { getByTestId, queryByLabelText } = renderWithTheme( + + ); + + expect(getByTestId('ssl-certificate')).toBeVisible(); + expect(getByTestId('private-key')).toBeVisible(); + expect(queryByLabelText('Proxy Protocol')).not.toBeInTheDocument(); + }); + + it('renders form fields specific to the TCP protocol', () => { + const { getByLabelText, queryByTestId } = renderWithTheme( + + ); + + expect(getByLabelText('Proxy Protocol')).toBeVisible(); + expect(queryByTestId('ssl-certificate')).not.toBeInTheDocument(); + expect(queryByTestId('private-key')).not.toBeInTheDocument(); + }); + + it('renders fields specific to the Active Health Check type of TCP Connection', () => { + const { getByLabelText, queryByTestId } = renderWithTheme( + + ); + + activeHealthChecks.forEach((type) => { + expect(getByLabelText(type)).toBeVisible(); + }); + expect(queryByTestId('http-path')).not.toBeInTheDocument(); + expect(queryByTestId('http-body')).not.toBeInTheDocument(); + }); + + it('renders fields specific to the Active Health Check type of HTTP Status', () => { + const { getByLabelText, getByTestId, queryByTestId } = renderWithTheme( + + ); + + activeHealthChecks.forEach((type) => { + expect(getByLabelText(type)).toBeVisible(); + }); + expect(getByTestId('http-path')).toBeVisible(); + expect(queryByTestId('http-body')).not.toBeInTheDocument(); + }); + + it('renders fields specific to the Active Health Check type of HTTP Body', () => { + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + activeHealthChecks.forEach((type) => { + expect(getByLabelText(type)).toBeVisible(); + }); + expect(getByTestId('http-path')).toBeVisible(); + expect(getByTestId('http-body')).toBeVisible(); + }); + + it('renders the relevant helper text for the Round Robin algorithm', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(getByText(ROUND_ROBIN_ALGORITHM_HELPER_TEXT)).toBeVisible(); + expect( + queryByText(LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT) + ).not.toBeInTheDocument(); + expect(queryByText(SOURCE_ALGORITHM_HELPER_TEXT)).not.toBeInTheDocument(); + }); + + it('renders the relevant helper text for the Least Connections algorithm', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(getByText(LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT)).toBeVisible(); + expect(queryByText(SOURCE_ALGORITHM_HELPER_TEXT)).not.toBeInTheDocument(); + expect( + queryByText(ROUND_ROBIN_ALGORITHM_HELPER_TEXT) + ).not.toBeInTheDocument(); + }); + + it('renders the relevant helper text for the Source algorithm', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(getByText(SOURCE_ALGORITHM_HELPER_TEXT)).toBeVisible(); + expect( + queryByText(ROUND_ROBIN_ALGORITHM_HELPER_TEXT) + ).not.toBeInTheDocument(); + expect( + queryByText(LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT) + ).not.toBeInTheDocument(); + }); + + it('adds another backend node', () => { + const { getByText } = renderWithTheme( + + ); + + const addNodeButton = getByText('Add a Node'); + fireEvent.click(addNodeButton); + expect(props.addNode).toHaveBeenCalled(); + }); + + it('cannot remove a backend node if there is only one node', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Remove')).not.toBeInTheDocument(); + }); + + it('removes a backend node', () => { + const { getByText } = renderWithTheme( + + ); + + const removeNodeButton = getByText('Remove'); + fireEvent.click(removeNodeButton); + expect(props.removeNode).toHaveBeenCalled(); + }); + + it('deletes the configuration panel', () => { + const { getByText } = renderWithTheme( + + ); + + const deleteConfigButton = getByText('Delete'); + fireEvent.click(deleteConfigButton); + expect(props.onDelete).toHaveBeenCalled(); + }); + + it('saves the input after editing the configuration', () => { + const { getByText } = renderWithTheme( + + ); + + const editConfigButton = getByText('Save'); + fireEvent.click(editConfigButton); + expect(props.onSave).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx index 4f0afc9b0bd..a7b726c133c 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx @@ -1,11 +1,11 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { FormHelperText } from 'src/components/FormHelperText'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; @@ -21,6 +21,12 @@ import type { NodeBalancerConfigPanelProps } from './types'; import type { NodeBalancerConfigNodeMode } from '@linode/api-v4'; const DATA_NODE = 'data-node-idx'; +export const ROUND_ROBIN_ALGORITHM_HELPER_TEXT = + 'Round robin distributes connection requests to backend servers in weighted circular order.'; +export const LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT = + 'Least connections assigns connections to the backend with the least connections.'; +export const SOURCE_ALGORITHM_HELPER_TEXT = + "Source uses the client's IPv4 address."; export const NodeBalancerConfigPanel = ( props: NodeBalancerConfigPanelProps @@ -156,6 +162,12 @@ export const NodeBalancerConfigPanel = ( return eachAlg.value === algorithm; }); + const algorithmHelperText = { + leastconn: LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT, + roundrobin: ROUND_ROBIN_ALGORITHM_HELPER_TEXT, + source: SOURCE_ALGORITHM_HELPER_TEXT, + }; + const sessionOptions = [ { label: 'None', value: 'none' }, { label: 'Table', value: 'table' }, @@ -192,7 +204,7 @@ export const NodeBalancerConfigPanel = ( type="number" value={port || ''} /> - Listen on this port + Listen on this port. { + props.onProxyProtocolChange(selected.value); + }} textFieldProps={{ dataAttrs: { 'data-qa-proxy-protocol-select': true, @@ -259,15 +276,12 @@ export const NodeBalancerConfigPanel = ( }, }} autoHighlight + disableClearable disabled={disabled} errorText={errorMap.proxy_protocol} id={`proxy-protocol-${configIdx}`} - disableClearable label="Proxy Protocol" noMarginTop - onChange={(_, selected) => { - props.onProxyProtocolChange(selected.value); - }} options={proxyProtocolOptions} size="small" value={selectedProxyProtocol || proxyProtocolOptions[0]} @@ -286,6 +300,9 @@ export const NodeBalancerConfigPanel = ( { + props.onAlgorithmChange(selected.value); + }} textFieldProps={{ dataAttrs: { 'data-qa-algorithm-select': true, @@ -293,28 +310,24 @@ export const NodeBalancerConfigPanel = ( errorGroup: forEdit ? `${configIdx}` : undefined, }} autoHighlight + disableClearable disabled={disabled} errorText={errorMap.algorithm} id={`algorithm-${configIdx}`} - disableClearable label="Algorithm" noMarginTop - onChange={(_, selected) => { - props.onAlgorithmChange(selected.value); - }} options={algOptions} size="small" value={defaultAlg || algOptions[0]} /> - - Roundrobin. Least connections assigns connections to the backend - with the least connections. Source uses the client’s IPv4 - address - + {algorithmHelperText[algorithm]} { + props.onSessionStickinessChange(selected.value); + }} textFieldProps={{ dataAttrs: { 'data-qa-session-stickiness-select': true, @@ -322,21 +335,18 @@ export const NodeBalancerConfigPanel = ( errorGroup: forEdit ? `${configIdx}` : undefined, }} autoHighlight + disableClearable disabled={disabled} errorText={errorMap.stickiness} id={`session-stickiness-${configIdx}`} - disableClearable label="Session Stickiness" noMarginTop - onChange={(_, selected) => { - props.onSessionStickinessChange(selected.value); - }} options={sessionOptions} size="small" value={defaultSession || sessionOptions[1]} /> - Route subsequent requests from the client to the same backend + Route subsequent requests from the client to the same backend. diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 16dd6225894..5ca50d8cac4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -114,7 +114,7 @@ const NodeBalancerCreate = () => { const { error, - isLoading, + isPending, mutateAsync: createNodeBalancer, } = useNodebalancerCreateMutation(); @@ -488,7 +488,10 @@ const NodeBalancerCreate = () => { /> {generalError && !isRestricted && ( - + )} {isRestricted && ( @@ -687,7 +690,7 @@ const NodeBalancerCreate = () => { }} buttonType="primary" data-qa-deploy-nodebalancer - loading={isLoading} + loading={isPending} onClick={onCreate} tooltipText={isInvalidPrice ? PRICE_ERROR_TOOLTIP_TEXT : ''} > diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx index 92c3e3765e4..982963c6716 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx @@ -19,7 +19,7 @@ export const NodeBalancerDeleteDialog = ({ onClose, open, }: Props) => { - const { error, isLoading, mutateAsync } = useNodebalancerDeleteMutation(id); + const { error, isPending, mutateAsync } = useNodebalancerDeleteMutation(id); const { push } = useHistory(); const onDelete = async () => { @@ -38,7 +38,7 @@ export const NodeBalancerDeleteDialog = ({ }} errors={error ?? undefined} label={'NodeBalancer Label'} - loading={isLoading} + loading={isPending} onClick={onDelete} onClose={onClose} open={open} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx index beddb8e18d8..9afca8890d6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx @@ -34,13 +34,13 @@ export const NodeBalancerSettings = () => { const { error: labelError, - isLoading: isUpdatingLabel, + isPending: isUpdatingLabel, mutateAsync: updateNodeBalancerLabel, } = useNodebalancerUpdateMutation(id); const { error: throttleError, - isLoading: isUpdatingThrottle, + isPending: isUpdatingThrottle, mutateAsync: updateNodeBalancerThrottle, } = useNodebalancerUpdateMutation(id); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx new file mode 100644 index 00000000000..ade67435282 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerActionMenu } from './NodeBalancerActionMenu'; + +const props = { + label: 'nodebalancer-1', + nodeBalancerId: 1, + toggleDialog: vi.fn(), +}; + +describe('NodeBalancerActionMenu', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('renders the NodeBalancerActionMenu', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Configurations')).toBeVisible(); + expect(getByText('Settings')).toBeVisible(); + expect(getByText('Delete')).toBeVisible(); + }); + + it('triggers the action to delete the NodeBalancer', () => { + const { getByText } = renderWithTheme( + + ); + + const deleteButton = getByText('Delete'); + fireEvent.click(deleteButton); + expect(props.toggleDialog).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx index 94d9de3fb16..f24b602548d 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx @@ -1,14 +1,17 @@ -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 { useHistory } from 'react-router-dom'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Hidden } from 'src/components/Hidden'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import type { Theme } from '@mui/material/styles'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + interface Props { label: string; nodeBalancerId: number; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx new file mode 100644 index 00000000000..80df645c989 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { nodeBalancerFactory } from 'src/factories'; +import { breakpoints } from 'src/foundations/breakpoints'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; + +import { NodeBalancerTableRow } from './NodeBalancerTableRow'; + +vi.mock('src/hooks/useIsResourceRestricted'); + +const props = { + ...nodeBalancerFactory.build(), + onDelete: vi.fn(), +}; + +describe('NodeBalancerTableRow', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('renders the NodeBalancer table row', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('nodebalancer-id-1')).toBeVisible(); + expect(getByText('0.0.0.0')).toBeVisible(); + expect(getByText('Configurations')).toBeVisible(); + expect(getByText('Settings')).toBeVisible(); + expect(getByText('Delete')).toBeVisible(); + }); + + it('renders the hidden columns when the screen width is large enough', () => { + resizeScreenSize(breakpoints.values.lg); + const { getByText } = renderWithTheme(); + + expect(getByText('nodebalancer-id-1')).toBeVisible(); + expect(getByText('0 up')).toBeVisible(); + expect(getByText('0 down')).toBeVisible(); + expect(getByText('0 bytes')).toBeVisible(); + expect(getByText('0.0.0.0')).toBeVisible(); + expect(getByText('us-east')).toBeVisible(); + }); + + it('deletes the NodeBalancer', () => { + const { getByText } = renderWithTheme(); + + const deleteButton = getByText('Delete'); + fireEvent.click(deleteButton); + expect(props.onDelete).toHaveBeenCalled(); + }); + + it('does not delete the NodeBalancer if the delete button is disabled', () => { + vi.mocked(useIsResourceRestricted).mockReturnValue(true); + const { getByText } = renderWithTheme(); + + const deleteButton = getByText('Delete'); + fireEvent.click(deleteButton); + expect(props.onDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx index e9a20f8274a..fc7fd795d91 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx @@ -1,4 +1,3 @@ -import { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -13,6 +12,8 @@ import { convertMegabytesTo } from 'src/utilities/unitConversions'; import { NodeBalancerActionMenu } from './NodeBalancerActionMenu'; +import type { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; + interface Props extends NodeBalancer { onDelete: () => void; } diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx new file mode 100644 index 00000000000..e320fd915ab --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx @@ -0,0 +1,67 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; + +import { nodeBalancerFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancersLanding } from './NodeBalancersLanding'; + +beforeAll(() => mockMatchMedia()); + +const loadingTestId = 'circle-progress'; + +describe('NodeBalancersLanding', () => { + it('renders the NodeBalancer empty state if there are no NodeBalancers', async () => { + server.use( + http.get('*/nodebalancers', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId, getByText } = renderWithTheme( + + ); + + // expect loading state and wait for it to disappear + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText('NodeBalancers')).toBeVisible(); + expect(getByText('Cloud-based load balancing service')).toBeVisible(); + expect( + getByText( + 'Add high availability and horizontal scaling to web applications hosted on Linode Compute Instances.' + ) + ).toBeVisible(); + }); + + it('renders the NodeBalancer table if there are NodeBalancers', async () => { + server.use( + http.get('*/nodebalancers', () => { + const nodebalancers = nodeBalancerFactory.buildList(1); + return HttpResponse.json(makeResourcePage(nodebalancers)); + }) + ); + + const { getByTestId, getByText } = renderWithTheme( + + ); + + // expect loading state and wait for it to disappear + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText('NodeBalancers')).toBeVisible(); + expect(getByText('Create NodeBalancer')).toBeVisible(); + + // confirm table headers + expect(getByText('Label')).toBeVisible(); + expect(getByText('Backend Status')).toBeVisible(); + expect(getByText('Transferred')).toBeVisible(); + expect(getByText('Ports')).toBeVisible(); + expect(getByText('IP Address')).toBeVisible(); + expect(getByText('Region')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx index 0626eca8bab..f094670b807 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx @@ -21,8 +21,8 @@ import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGran import { useNodeBalancersQuery } from 'src/queries/nodebalancers'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; -import { NodeBalancerTableRow } from './NodeBalancerTableRow'; import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; +import { NodeBalancerTableRow } from './NodeBalancerTableRow'; const preferenceKey = 'nodebalancers'; export const NodeBalancersLanding = () => { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx new file mode 100644 index 00000000000..b3277aceb8b --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx @@ -0,0 +1,35 @@ +import { waitFor } from '@testing-library/react'; +import * as React from 'react'; + +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; + +vi.mock('src/hooks/useRestrictedGlobalGrantCheck'); + +// Note: An integration test confirming the helper text and enabled Create NodeBalancer button already exists, so we're just checking for a disabled create button here +describe('NodeBalancersLandingEmptyState', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('disables the Create NodeBalancer button if user does not have permissions to create a NodeBalancer', async () => { + // disables the create button + vi.mocked(useRestrictedGlobalGrantCheck).mockReturnValue(true); + + const { getByText } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerButton = getByText('Create NodeBalancer').closest( + 'button' + ); + + expect(createNodeBalancerButton).toBeDisabled(); + expect(createNodeBalancerButton).toHaveAttribute( + 'data-qa-tooltip', + "You don't have permissions to create NodeBalancers. Please contact your account administrator to request the necessary permissions." + ); + }); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/types.ts b/packages/manager/src/features/NodeBalancers/types.ts index 756c2dbfe9b..1a4d465615e 100644 --- a/packages/manager/src/features/NodeBalancers/types.ts +++ b/packages/manager/src/features/NodeBalancers/types.ts @@ -1,8 +1,8 @@ -import { +import type { NodeBalancerConfigNodeMode, NodeBalancerProxyProtocol, } from '@linode/api-v4/lib/nodebalancers/types'; -import { APIError } from '@linode/api-v4/lib/types'; +import type { APIError } from '@linode/api-v4/lib/types'; export interface NodeBalancerConfigFieldsWithStatus extends NodeBalancerConfigFields { diff --git a/packages/manager/src/features/NotificationCenter/Events.tsx b/packages/manager/src/features/NotificationCenter/Events.tsx deleted file mode 100644 index 7fec4fbf7fa..00000000000 --- a/packages/manager/src/features/NotificationCenter/Events.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import * as React from 'react'; - -import { useEventNotifications } from './NotificationData/useEventNotifications'; -import { NotificationSection } from './NotificationSection'; - -const NUM_EVENTS_DISPLAY = 20; - -export const Events = () => { - const events = useEventNotifications(); - - return ( - - ); -}; - -export default React.memo(Events); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.test.tsx similarity index 84% rename from packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx rename to packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.test.tsx index cb7a08af07b..f549ed10a9e 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx +++ b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.test.tsx @@ -3,9 +3,9 @@ import * as React from 'react'; import { eventFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { RenderEventV2 } from './RenderEventV2'; +import { NotificationCenterEvent } from './NotificationCenterEvent'; -describe('RenderEventV2', () => { +describe('NotificationCenterEvent', () => { it('should render a finished event with the proper data', () => { const event = eventFactory.build({ action: 'linode_create', @@ -17,7 +17,7 @@ describe('RenderEventV2', () => { }); const { getByTestId, getByText } = renderWithTheme( - vi.fn()} /> + vi.fn()} /> ); expect( @@ -42,7 +42,7 @@ describe('RenderEventV2', () => { }); const { getByTestId, getByText } = renderWithTheme( - vi.fn()} /> + vi.fn()} /> ); expect( diff --git a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx new file mode 100644 index 00000000000..fd0a62cdb70 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx @@ -0,0 +1,91 @@ +import { useTheme } from '@mui/material'; +import * as React from 'react'; + +import { BarPercent } from 'src/components/BarPercent'; +import { Box } from 'src/components/Box'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; +import { Typography } from 'src/components/Typography'; +import { + formatProgressEvent, + getEventMessage, + getEventUsername, +} from 'src/features/Events/utils'; +import { useProfile } from 'src/queries/profile/profile'; + +import { + NotificationEventAvatar, + NotificationEventGravatar, + NotificationEventStyledBox, + notificationEventStyles, +} from '../NotificationCenter.styles'; + +import type { Event } from '@linode/api-v4/lib/account/types'; + +interface NotificationEventProps { + event: Event; + onClose: () => void; +} + +export const NotificationCenterEvent = React.memo( + (props: NotificationEventProps) => { + const { event } = props; + const theme = useTheme(); + const { classes, cx } = notificationEventStyles(); + const unseenEventClass = cx({ [classes.unseenEvent]: !event.seen }); + const message = getEventMessage(event); + const username = getEventUsername(event); + + const { data: profile } = useProfile(); + + /** + * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). + * Filter these out so we don't display blank messages to the user. + * We have Sentry events being logged for these cases, so we can always go back and add support for them as soon as we become aware. + */ + if (message === null) { + return null; + } + + const { progressEventDisplay, showProgress } = formatProgressEvent(event); + + return ( + + + } + gravatar={} + height={32} + width={32} + /> + + {message} + {showProgress && ( + + )} + + {progressEventDisplay} | {username} + + + + ); + } +); diff --git a/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts b/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts new file mode 100644 index 00000000000..620151a4769 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts @@ -0,0 +1,161 @@ +import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; +import { styled } from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +import { Avatar } from 'src/components/Avatar/Avatar'; +import { Box } from 'src/components/Box'; +import { GravatarByUsername } from 'src/components/GravatarByUsername'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { fadeIn } from 'src/styles/keyframes'; +import { omittedProps } from 'src/utilities/omittedProps'; + +import type { NotificationCenterNotificationMessageProps } from './types'; +import type { Theme } from '@mui/material/styles'; + +export const useStyles = makeStyles()((theme: Theme) => ({ + inverted: { + transform: 'rotate(180deg)', + }, + notificationSpacing: { + '& > div:not(:first-of-type)': { + margin: `${theme.spacing()} 0`, + padding: '0 20px', + }, + marginBottom: theme.spacing(2), + }, + showMore: { + '&:hover': { + textDecoration: 'none', + }, + alignItems: 'center', + display: 'flex', + fontFamily: theme.font.bold, + fontSize: 14, + paddingTop: theme.spacing(), + }, +})); + +export const StyledLink = styled(Link)< + Partial +>(({ theme, ...props }) => ({ + ...(props.notification?.severity === 'critical' && { + '&:hover': { + textDecoration: `${theme.color.red} underline`, + }, + color: `${theme.color.red} !important`, + }), +})); + +export const StyledRootContainer = styled('div', { + label: 'StyledRootContainer', +})(() => ({ + alignItems: 'flex-start', + display: 'flex', + flexWrap: 'nowrap', + justifyContent: 'flex-start', +})); + +export const StyledHeader = styled('div', { + label: 'StyledHeader', +})(({ theme }) => ({ + alignItems: 'center', + borderBottom: `solid 1px ${theme.borderColors.borderTable}`, + display: 'flex', + justifyContent: 'space-between', + padding: `0 20px ${theme.spacing()}`, +})); + +export const StyledLoadingContainer = styled('div', { + label: 'StyledLoadingContainer', +})(() => ({ + display: 'flex', + justifyContent: 'center', +})); + +export const StyledLToggleContainer = styled(Box, { + label: 'StyledLToggleButton', +})(({ theme }) => ({ + padding: `0 16px ${theme.spacing()}`, +})); + +export const StyledNotificationCenterItem = styled(Box, { + label: 'StyledNotificationCenterItem', + shouldForwardProp: omittedProps(['header']), +})<{ header: string }>(({ theme, ...props }) => ({ + '& p': { + lineHeight: '1.25rem', + }, + display: 'flex', + fontSize: '0.875rem', + justifyContent: 'space-between', + padding: props.header === 'Notifications' ? `${theme.spacing(1.5)} 20px` : 0, + width: '100%', +})); + +export const StyledCaret = styled(KeyboardArrowDown)(({ theme }) => ({ + color: theme.palette.primary.main, + marginLeft: theme.spacing(), +})); + +export const StyledEmptyMessage = styled(Typography)(({ theme }) => ({ + marginBottom: theme.spacing(2.5), + marginTop: theme.spacing(), + padding: `0 20px`, +})); + +export const NotificationEventStyledBox = styled(Box, { + label: 'StyledBox', +})(({ theme }) => ({ + '&:hover': { + backgroundColor: theme.bg.app, + }, + color: theme.textColors.tableHeader, + display: 'flex', + gap: 16, + paddingBottom: 12, + paddingLeft: '20px', + paddingRight: '20px', + paddingTop: 12, + width: '100%', +})); + +export const NotificationEventGravatar = styled(GravatarByUsername, { + label: 'StyledGravatarByUsername', +})(() => ({ + animation: `${fadeIn} .2s ease-in-out forwards`, + height: 32, + marginTop: 2, + minWidth: 32, + width: 32, +})); + +export const NotificationEventAvatar = styled(Avatar, { + label: 'StyledAvatar', +})(() => ({ + height: 32, + marginTop: 2, + minWidth: 32, + width: 32, +})); + +export const notificationEventStyles = makeStyles()((theme: Theme) => ({ + bar: { + marginTop: theme.spacing(), + }, + unseenEvent: { + '&:after': { + backgroundColor: theme.palette.primary.main, + content: '""', + display: 'block', + height: '100%', + left: 0, + position: 'absolute', + top: 0, + width: 4, + }, + backgroundColor: theme.bg.offWhite, + borderBottom: `1px solid ${theme.bg.main}`, + position: 'relative', + }, +})); diff --git a/packages/manager/src/features/NotificationCenter/NotificationContext.ts b/packages/manager/src/features/NotificationCenter/NotificationCenterContext.ts similarity index 82% rename from packages/manager/src/features/NotificationCenter/NotificationContext.ts rename to packages/manager/src/features/NotificationCenter/NotificationCenterContext.ts index ffc6573d45d..656ee4a12fd 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationContext.ts +++ b/packages/manager/src/features/NotificationCenter/NotificationCenterContext.ts @@ -1,7 +1,7 @@ /* eslint-disable no-unused-expressions */ import { createContext, useCallback, useState } from 'react'; -export interface NotificationContextProps { +export interface NotificationCenterContextProps { closeMenu: () => void; menuOpen: boolean; openMenu: () => void; @@ -13,14 +13,14 @@ const defaultContext = { openMenu: () => null, }; -export const notificationContext = createContext( +export const notificationCenterContext = createContext( defaultContext ); export const menuId = 'notification-events-menu'; export const menuButtonId = 'menu-button--notification-events-menu'; -export const useNotificationContext = (): NotificationContextProps => { +export const useNotificationContext = (): NotificationCenterContextProps => { const [menuOpen, setMenuOpen] = useState(false); const openMenu = useCallback(() => { diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts deleted file mode 100644 index 6a8b06c3856..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts +++ /dev/null @@ -1,65 +0,0 @@ -// TODO eventMessagesV2: cleanup unused non V2 components when flag is removed -import { styled } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; - -import { Box } from 'src/components/Box'; -import { GravatarByUsername } from 'src/components/GravatarByUsername'; - -import type { Theme } from '@mui/material/styles'; - -export const RenderEventStyledBox = styled(Box, { - label: 'StyledBox', -})(({ theme }) => ({ - '&:hover': { - backgroundColor: theme.bg.app, - }, - color: theme.textColors.tableHeader, - display: 'flex', - gap: 16, - paddingBottom: 12, - paddingLeft: '20px', - paddingRight: '20px', - paddingTop: 12, - width: '100%', -})); - -export const RenderEventGravatar = styled(GravatarByUsername, { - label: 'StyledGravatarByUsername', -})(() => ({ - height: 40, - minWidth: 40, -})); - -export const RenderEventGravatarV2 = styled(GravatarByUsername, { - label: 'StyledGravatarByUsername', -})(() => ({ - height: 32, - marginTop: 2, - minWidth: 32, - width: 32, -})); - -export const useRenderEventStyles = makeStyles()((theme: Theme) => ({ - bar: { - marginTop: theme.spacing(), - }, - unseenEvent: { - color: theme.textColors.headlineStatic, - textDecoration: 'none', - }, - unseenEventV2: { - '&:after': { - backgroundColor: theme.palette.primary.main, - content: '""', - display: 'block', - height: '100%', - left: 0, - position: 'absolute', - top: 0, - width: 4, - }, - backgroundColor: theme.bg.offWhite, - borderBottom: `1px solid ${theme.bg.main}`, - position: 'relative', - }, -})); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.test.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.test.tsx deleted file mode 100644 index 0eaf13f49e1..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { formatTimeRemaining } from './RenderProgressEvent'; - -describe('Pending Actions', () => { - describe('formatTimeRemaining helper', () => { - it('should return null for invalid input', () => { - expect(formatTimeRemaining(4959348 as any)).toBe(null); - expect(formatTimeRemaining('')).toBe(null); - expect(formatTimeRemaining('1 hour')).toBe(null); - expect(formatTimeRemaining('08:34')).toBe(null); - }); - - it('should return a short duration as minutes remaining', () => { - expect(formatTimeRemaining('00:15:00')).toMatch('15 minutes remaining'); - }); - - it('should round to the nearest minute', () => { - expect(formatTimeRemaining('00:08:28')).toMatch('8 minutes remaining'); - expect(formatTimeRemaining('00:08:31')).toMatch('9 minutes remaining'); - }); - - it('should return a long duration as hours remaining', () => { - expect(formatTimeRemaining('2:15:00')).toMatch('2 hours remaining'); - }); - - it('should round to the nearest hour', () => { - expect(formatTimeRemaining('7:40:28')).toMatch('8 hours remaining'); - expect(formatTimeRemaining('7:19:28')).toMatch('7 hours remaining'); - }); - }); -}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx deleted file mode 100644 index 695adbd95df..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// TODO eventMessagesV2: delete when flag is removed -import { Event } from '@linode/api-v4/lib/account/types'; -import * as React from 'react'; - -import { Box } from 'src/components/Box'; -import { Divider } from 'src/components/Divider'; -import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/HighlightedMarkdown'; -import { Typography } from 'src/components/Typography'; -import { getEventTimestamp } from 'src/utilities/eventUtils'; -import { getAllowedHTMLTags } from 'src/utilities/sanitizeHTML.utils'; - -import { - RenderEventGravatar, - RenderEventStyledBox, - useRenderEventStyles, -} from './RenderEvent.styles'; -import useEventInfo from './useEventInfo'; - -interface RenderEventProps { - event: Event; - onClose: () => void; -} - -export const RenderEvent = React.memo((props: RenderEventProps) => { - const { classes, cx } = useRenderEventStyles(); - const { event } = props; - const { message } = useEventInfo(event); - - const unseenEventClass = cx({ [classes.unseenEvent]: !event.seen }); - - if (message === null) { - return null; - } - - const eventMessage = ( -
    - -
    - ); - - return ( - <> - - - - {eventMessage} - - {getEventTimestamp(event).toRelative()} - - - - - - ); -}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx deleted file mode 100644 index 8bed9077216..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as React from 'react'; - -import { BarPercent } from 'src/components/BarPercent'; -import { Box } from 'src/components/Box'; -import { Typography } from 'src/components/Typography'; -import { - formatProgressEvent, - getEventMessage, - getEventUsername, -} from 'src/features/Events/utils'; - -import { - RenderEventGravatarV2, - RenderEventStyledBox, - useRenderEventStyles, -} from './RenderEvent.styles'; - -import type { Event } from '@linode/api-v4/lib/account/types'; - -interface RenderEventProps { - event: Event; - onClose: () => void; -} - -export const RenderEventV2 = React.memo((props: RenderEventProps) => { - const { event } = props; - const { classes, cx } = useRenderEventStyles(); - const unseenEventClass = cx({ [classes.unseenEventV2]: !event.seen }); - const message = getEventMessage(event); - const username = getEventUsername(event); - - /** - * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). - * Filter these out so we don't display blank messages to the user. - * We have Sentry events being logged for these cases, so we can always go back and add support for them as soon as we become aware. - */ - if (message === null) { - return null; - } - - const { progressEventDisplay, showProgress } = formatProgressEvent(event); - - return ( - - - - {message} - {showProgress && ( - - )} - - {progressEventDisplay} | {username} - - - - ); -}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx deleted file mode 100644 index 0eb115ada46..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// TODO eventMessagesV2: delete when flag is removed -import { Duration } from 'luxon'; -import * as React from 'react'; - -import { BarPercent } from 'src/components/BarPercent'; -import { Box } from 'src/components/Box'; -import { Divider } from 'src/components/Divider'; -import { Typography } from 'src/components/Typography'; -import { - eventLabelGenerator, - eventMessageGenerator, -} from 'src/features/Events/eventMessageGenerator_CMR'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useSpecificTypes } from 'src/queries/types'; -import { extendTypesQueryResult } from 'src/utilities/extendType'; -import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; - -import { - RenderEventGravatarV2, - RenderEventStyledBox, - useRenderEventStyles, -} from './RenderEvent.styles'; - -import type { Event } from '@linode/api-v4/lib/account/types'; - -interface Props { - event: Event; - onClose: () => void; -} - -export const RenderProgressEvent = (props: Props) => { - const { classes } = useRenderEventStyles(); - const { event } = props; - const { data: linodes } = useAllLinodesQuery(); - const typesQuery = useSpecificTypes( - (linodes ?? []).map((linode) => linode.type).filter(isNotNullOrUndefined) - ); - const types = extendTypesQueryResult(typesQuery); - const message = eventMessageGenerator(event, linodes, types); - - if (message === null) { - return null; - } - - const parsedTimeRemaining = formatTimeRemaining(event.time_remaining); - - const formattedTimeRemaining = parsedTimeRemaining - ? ` (~${parsedTimeRemaining})` - : null; - - const eventMessage = ( - - {event.action !== 'volume_migrate' ? eventLabelGenerator(event) : ''} - {` `} - {message} - {formattedTimeRemaining} - - ); - - return ( - <> - - - - {eventMessage} - - - - - - ); -}; - -export const formatTimeRemaining = (time: null | string) => { - if (!time) { - return null; - } - - try { - const [hours, minutes, seconds] = time.split(':').map(Number); - if ( - [hours, minutes, seconds].some( - (thisNumber) => typeof thisNumber === 'undefined' - ) || - [hours, minutes, seconds].some(isNaN) - ) { - // Bad input, don't display a duration - return null; - } - const duration = Duration.fromObject({ hours, minutes, seconds }); - return hours > 0 - ? `${Math.round(duration.as('hours'))} hours remaining` - : `${Math.round(duration.as('minutes'))} minutes remaining`; - } catch { - // Broken/unexpected input - return null; - } -}; - -export default React.memo(RenderProgressEvent); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/notificationUtils.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/notificationUtils.tsx deleted file mode 100644 index bf8c6962c96..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/notificationUtils.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { NotificationType } from '@linode/api-v4/lib/account'; - -export const maintenanceNotificationTypes = [ - 'maintenance', - 'maintenance_scheduled', -]; - -export const checkIfMaintenanceNotification = (type: NotificationType) => { - return maintenanceNotificationTypes.includes(type); -}; diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useEventInfo.ts b/packages/manager/src/features/NotificationCenter/NotificationData/useEventInfo.ts deleted file mode 100644 index 49c03b07414..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useEventInfo.ts +++ /dev/null @@ -1,33 +0,0 @@ -// TODO eventMessagesV2: delete when flag is removed -import { Event } from '@linode/api-v4/lib/account/types'; - -import { generateEventMessage } from 'src/features/Events/eventMessageGenerator'; -import { formatEventSeconds } from 'src/utilities/minute-conversion'; - -import type { EntityVariants } from 'src/components/EntityIcon/EntityIcon'; - -/** - * Shared helper logic for rendering events - * Used in RenderEvent and RenderProgress Event - */ - -export interface EventInfo { - duration: string; - message: null | string; - type: EntityVariants; -} - -export const useEventInfo = (event: Event): EventInfo => { - const message = generateEventMessage(event); - const type = (event.entity?.type ?? 'linode') as EntityVariants; - - const duration = formatEventSeconds(event.duration); - - return { - duration, - message, - type, - }; -}; - -export default useEventInfo; diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx deleted file mode 100644 index 1fba29828e2..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// TODO eventMessagesV2: delete when flag is removed -import { partition } from 'ramda'; -import * as React from 'react'; - -import { useIsTaxIdEnabled } from 'src/features/Account/utils'; -import { isInProgressEvent } from 'src/queries/events/event.helpers'; -import { useEventsInfiniteQuery } from 'src/queries/events/events'; -import { removeBlocklistedEvents } from 'src/utilities/eventUtils'; - -import { notificationContext as _notificationContext } from '../NotificationContext'; -import { RenderEvent } from './RenderEvent'; -import RenderProgressEvent from './RenderProgressEvent'; - -import type { NotificationItem } from '../NotificationSection'; -import type { Event, EventAction } from '@linode/api-v4/lib/account/types'; - -const defaultUnwantedEvents: EventAction[] = [ - 'account_update', - 'account_settings_update', - 'credit_card_updated', - 'profile_update', - 'ticket_attachment_upload', - 'volume_update', -]; - -export const useEventNotifications = (): NotificationItem[] => { - const { events: fetchedEvents } = useEventsInfiniteQuery(); - const relevantEvents = removeBlocklistedEvents(fetchedEvents); - const { isTaxIdEnabled } = useIsTaxIdEnabled(); - const notificationContext = React.useContext(_notificationContext); - - // TODO: TaxId - This entire function can be removed when we cleanup tax id feature flags - const unwantedEventTypes = React.useMemo(() => { - const eventTypes = [...defaultUnwantedEvents]; - if (!isTaxIdEnabled) { - eventTypes.push('tax_id_invalid'); - } - return eventTypes; - }, [isTaxIdEnabled]); - - const filteredEvents = relevantEvents.filter( - (event) => !unwantedEventTypes.includes(event.action) - ); - - const [inProgressEvents, completedEvents] = partition( - isInProgressEvent, - filteredEvents - ); - - const allNotificationItems = [ - ...inProgressEvents.map((event) => - formatProgressEventForDisplay(event, notificationContext.closeMenu) - ), - ...completedEvents.map((event) => - formatEventForDisplay(event, notificationContext.closeMenu) - ), - ]; - - return allNotificationItems.filter((notification) => - Boolean(notification.body) - ); -}; - -const formatEventForDisplay = ( - event: Event, - onClose: () => void -): NotificationItem => ({ - body: , - countInTotal: !event.seen, - eventId: event.id, - id: `event-${event.id}`, -}); - -const formatProgressEventForDisplay = ( - event: Event, - onClose: () => void -): NotificationItem => ({ - body: , - countInTotal: !event.seen, - eventId: event.id, - id: `progress-event-${event.id}`, -}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx deleted file mode 100644 index b32ebfbba2a..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; - -import { Accordion } from 'src/components/Accordion'; -import { Box } from 'src/components/Box'; -import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; -import { CircleProgress } from 'src/components/CircleProgress'; -import { Hidden } from 'src/components/Hidden'; -import { Link } from 'src/components/Link'; -import { Typography } from 'src/components/Typography'; -import { omittedProps } from 'src/utilities/omittedProps'; - -import type { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles()((theme: Theme) => ({ - inverted: { - transform: 'rotate(180deg)', - }, - notificationSpacing: { - '& > div:not(:first-of-type)': { - margin: `${theme.spacing()} 0`, - padding: '0 20px', - }, - marginBottom: theme.spacing(2), - }, - showMore: { - '&:hover': { - textDecoration: 'none', - }, - alignItems: 'center', - display: 'flex', - fontFamily: theme.font.bold, - fontSize: 14, - paddingTop: theme.spacing(), - }, -})); - -export interface NotificationItem { - body: JSX.Element | string; - countInTotal: boolean; - eventId: number; - id: string; - showProgress?: boolean; -} - -interface NotificationSectionProps { - content: NotificationItem[]; - count?: number; - emptyMessage?: string; - header: string; - loading?: boolean; - onCloseNotificationCenter?: () => void; - showMoreTarget?: string; - showMoreText?: string; -} - -export const NotificationSection = (props: NotificationSectionProps) => { - const { classes, cx } = useStyles(); - - const { - content, - count, - emptyMessage, - header, - loading, - onCloseNotificationCenter, - showMoreTarget, - showMoreText, - } = props; - - const _count = count ?? 5; - const _loading = Boolean(loading); // false if not provided - - const isActualNotificationContainer = header === 'Notifications'; - - return ( - // eslint-disable-next-line react/jsx-no-useless-fragment - <> - {isActualNotificationContainer && content.length === 0 ? null : ( - <> - - - - - {header} - {showMoreTarget && ( - - - {showMoreText ?? 'View history'} - - - )} - - - - - - - - 0 ? content.length : undefined - } - defaultExpanded={true} - heading={header} - > - - - - - )} - - ); -}; - -// ============================================================================= -// Body -// ============================================================================= -interface BodyProps { - content: NotificationItem[]; - count: number; - emptyMessage?: string; - header: string; - loading: boolean; -} - -const ContentBody = React.memo((props: BodyProps) => { - const { classes, cx } = useStyles(); - - const { content, count, emptyMessage, header, loading } = props; - - const [showAll, setShowAll] = React.useState(false); - - if (loading) { - return ( - - - - ); - } - - const _content = showAll ? content : content.slice(0, count); - - return _content.length > 0 ? ( - // eslint-disable-next-line - <> - {_content.map((thisItem) => ( - - {thisItem.body} - - ))} - {content.length > count ? ( - - ({ - color: 'primary.main', - fontFamily: theme.font.bold, - textDecoration: 'none !important', - })} - aria-label={`Display all ${content.length} items`} - data-test-id="showMoreButton" - onClick={() => setShowAll(!showAll)} - > - {showAll ? 'Collapse' : `${content.length - count} more`} - - - - ) : null} - - ) : header === 'Events' ? ( - - {emptyMessage - ? emptyMessage - : `You have no ${header.toLocaleLowerCase()}.`} - - ) : null; -}); - -const StyledRootContainer = styled('div', { - label: 'StyledRootContainer', -})(() => ({ - alignItems: 'flex-start', - display: 'flex', - flexWrap: 'nowrap', - justifyContent: 'flex-start', -})); - -const StyledHeader = styled('div', { - label: 'StyledHeader', -})(({ theme }) => ({ - alignItems: 'center', - borderBottom: `solid 1px ${theme.borderColors.borderTable}`, - display: 'flex', - justifyContent: 'space-between', - padding: `0 20px ${theme.spacing()}`, -})); - -const StyledLoadingContainer = styled('div', { - label: 'StyledLoadingContainer', -})(() => ({ - display: 'flex', - justifyContent: 'center', -})); - -const StyledLToggleContainer = styled(Box, { - label: 'StyledLToggleButton', -})(({ theme }) => ({ - padding: `0 16px ${theme.spacing()}`, -})); - -const StyledNotificationItem = styled(Box, { - label: 'StyledNotificationItem', - shouldForwardProp: omittedProps(['header']), -})<{ header: string }>(({ theme, ...props }) => ({ - '& p': { - lineHeight: '1.25rem', - }, - display: 'flex', - fontSize: '0.875rem', - justifyContent: 'space-between', - padding: props.header === 'Notifications' ? `${theme.spacing(1.5)} 20px` : 0, - width: '100%', -})); - -const StyledCaret = styled(KeyboardArrowDown)(({ theme }) => ({ - color: theme.palette.primary.main, - marginLeft: theme.spacing(), -})); - -const StyledEmptyMessage = styled(Typography)(({ theme }) => ({ - marginBottom: theme.spacing(2.5), - marginTop: theme.spacing(), - padding: `0 20px`, -})); diff --git a/packages/manager/src/features/NotificationCenter/Notifications.tsx b/packages/manager/src/features/NotificationCenter/Notifications.tsx deleted file mode 100644 index 41a5f0594ef..00000000000 --- a/packages/manager/src/features/NotificationCenter/Notifications.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; - -import { useFormattedNotifications } from './NotificationData/useFormattedNotifications'; -import { NotificationSection } from './NotificationSection'; - -export const Notifications = () => { - const notifications = useFormattedNotifications(); - - return ( - - ); -}; - -export default React.memo(Notifications); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderNotification.tsx b/packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotificationMessage.tsx similarity index 67% rename from packages/manager/src/features/NotificationCenter/NotificationData/RenderNotification.tsx rename to packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotificationMessage.tsx index c96be84ad34..30b9b3e01f2 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderNotification.tsx +++ b/packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotificationMessage.tsx @@ -1,25 +1,21 @@ -import { NotificationType } from '@linode/api-v4/lib/account'; import ErrorIcon from '@mui/icons-material/Error'; import WarningIcon from '@mui/icons-material/Warning'; -import { styled, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; -import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; -import { ExtendedNotification } from './useFormattedNotifications'; +import { StyledLink } from '../NotificationCenter.styles'; +import { getEntityLinks } from '../utils'; -interface Props { - notification: ExtendedNotification; - onClose: () => void; -} +import type { NotificationCenterNotificationMessageProps } from '../types'; -export type CombinedProps = Props; - -export const RenderNotification: React.FC = (props) => { +export const NotificationCenterNotificationMessage = ( + props: NotificationCenterNotificationMessageProps +) => { const theme = useTheme(); const { notification, onClose } = props; @@ -77,7 +73,7 @@ export const RenderNotification: React.FC = (props) => { sx={{ fill: theme.color.red, }} - data-test-id={severity + 'Icon'} + data-test-id={`${severity}Icon`} /> ) : null} {severity === 'major' ? ( @@ -107,28 +103,3 @@ export const RenderNotification: React.FC = (props) => { ); }; - -const StyledLink = styled(Link)>(({ theme, ...props }) => ({ - ...(props.notification?.severity === 'critical' && { - '&:hover': { - textDecoration: `${theme.color.red} underline`, - }, - color: `${theme.color.red} !important`, - }), -})); - -const getEntityLinks = ( - notificationType?: NotificationType, - entityType?: string, - id?: number -) => { - // Handle specific notification types - if (notificationType === 'ticket_abuse') { - return `/support/tickets/${id}`; - } - - // The only entity.type we currently expect and can handle for is "linode" - return entityType === 'linode' ? `/linodes/${id}` : null; -}; - -export default React.memo(RenderNotification); diff --git a/packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotifications.tsx b/packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotifications.tsx new file mode 100644 index 00000000000..8a32e960cf6 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotifications.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { CircleProgress } from 'src/components/CircleProgress'; + +import { useStyles } from '../NotificationCenter.styles'; +import { + StyledCaret, + StyledEmptyMessage, + StyledLToggleContainer, + StyledLoadingContainer, + StyledNotificationCenterItem, +} from '../NotificationCenter.styles'; + +import type { NotificationCenterNotificationsItem } from '../types'; + +interface NotificationCenterNotificationsProps { + content: NotificationCenterNotificationsItem[]; + count: number; + emptyMessage?: string; + header: 'Events' | 'Notifications'; + loading: boolean; +} + +export const NotificationCenterNotifications = React.memo( + (props: NotificationCenterNotificationsProps) => { + const { classes, cx } = useStyles(); + const { content, count, emptyMessage, header, loading } = props; + const [showAll, setShowAll] = React.useState(false); + + if (loading) { + return ( + + + + ); + } + + const _content = showAll ? content : content.slice(0, count); + + if (_content.length === 0 && header === 'Notifications') { + return null; + } + + if (_content.length === 0 && header === 'Events') { + return ( + + {emptyMessage + ? emptyMessage + : `You have no ${header.toLocaleLowerCase()}.`} + + ); + } + + return ( + <> + {_content.map((notificationCenterItem) => ( + + {notificationCenterItem.body} + + ))} + {content.length > count ? ( + + ({ + color: 'primary.main', + fontFamily: theme.font.bold, + textDecoration: 'none !important', + })} + aria-label={`Display all ${content.length} items`} + data-test-id="showMoreButton" + onClick={() => setShowAll(!showAll)} + > + {showAll ? 'Collapse' : `${content.length - count} more`} + + + + ) : null} + + ); + } +); diff --git a/packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotificationsContainer.tsx b/packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotificationsContainer.tsx new file mode 100644 index 00000000000..bc11526630e --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/Notifications/NotificationCenterNotificationsContainer.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; + +import { Accordion } from 'src/components/Accordion'; +import { Box } from 'src/components/Box'; +import { Hidden } from 'src/components/Hidden'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; + +import { useStyles } from '../NotificationCenter.styles'; +import { + StyledHeader, + StyledRootContainer, +} from '../NotificationCenter.styles'; +import { useFormattedNotifications } from '../useFormattedNotifications'; +import { NotificationCenterNotifications } from './NotificationCenterNotifications'; + +interface NotificationCenterNotificationsContainerProps { + count?: number; + loading?: boolean; + onCloseNotificationCenter?: () => void; + showMoreTarget?: string; + showMoreText?: string; +} + +export const NotificationCenterNotificationsContainer = ( + props: NotificationCenterNotificationsContainerProps +) => { + const { classes, cx } = useStyles(); + const notifications = useFormattedNotifications(); + const header = 'Notifications'; + const emptyMessage = 'No notifications to display.'; + + const { + count, + loading, + onCloseNotificationCenter, + showMoreTarget, + showMoreText, + } = props; + + const _count = count ?? 5; + const _loading = Boolean(loading); // false if not provided + const isActualNotificationContainer = header === 'Notifications'; + + if (isActualNotificationContainer && notifications.length === 0) { + return null; + } + + return ( + <> + + + + + {header} + {showMoreTarget && ( + + + {showMoreText ?? 'View history'} + + + )} + + + + + + + + 0 ? notifications.length : undefined + } + defaultExpanded={true} + heading={header} + > + + + + + ); +}; diff --git a/packages/manager/src/features/NotificationCenter/index.tsx b/packages/manager/src/features/NotificationCenter/index.tsx deleted file mode 100644 index 1d3e6a55490..00000000000 --- a/packages/manager/src/features/NotificationCenter/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as Events } from './Events'; diff --git a/packages/manager/src/features/NotificationCenter/types.ts b/packages/manager/src/features/NotificationCenter/types.ts new file mode 100644 index 00000000000..d4d0ea15320 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/types.ts @@ -0,0 +1,18 @@ +import type { Notification } from '@linode/api-v4'; + +export interface NotificationCenterNotificationsItem { + body: JSX.Element | string; + countInTotal: boolean; + eventId: number; + id: string; + showProgress?: boolean; +} + +export interface FormattedNotificationProps extends Notification { + jsx?: JSX.Element; +} + +export interface NotificationCenterNotificationMessageProps { + notification: FormattedNotificationProps; + onClose: () => void; +} diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.test.ts b/packages/manager/src/features/NotificationCenter/useFormattedNotifications.test.ts similarity index 95% rename from packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.test.ts rename to packages/manager/src/features/NotificationCenter/useFormattedNotifications.test.ts index 4fab046bde8..eb9ea549b56 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.test.ts +++ b/packages/manager/src/features/NotificationCenter/useFormattedNotifications.test.ts @@ -1,9 +1,6 @@ import { gdprComplianceNotification, notificationFactory } from 'src/factories'; -import { - adjustSeverity, - isEUModelContractNotification, -} from './useFormattedNotifications'; +import { adjustSeverity, isEUModelContractNotification } from './utils'; // Migration types are considered as types of maintenance notifications. const migrationScheduled = notificationFactory.build({ diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx b/packages/manager/src/features/NotificationCenter/useFormattedNotifications.tsx similarity index 87% rename from packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx rename to packages/manager/src/features/NotificationCenter/useFormattedNotifications.tsx index d67fdbb5807..cae8bd478bb 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/useFormattedNotifications.tsx @@ -1,10 +1,3 @@ -import { Profile } from '@linode/api-v4'; -import { - Notification, - NotificationSeverity, - NotificationType, -} from '@linode/api-v4/lib/account'; -import { Region } from '@linode/api-v4/lib/regions'; import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; import { path } from 'ramda'; @@ -21,16 +14,43 @@ import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { formatDate } from 'src/utilities/formatDate'; -import { notificationContext as _notificationContext } from '../NotificationContext'; -import { NotificationItem } from '../NotificationSection'; -import RenderNotification from './RenderNotification'; -import { checkIfMaintenanceNotification } from './notificationUtils'; +import { notificationCenterContext as _notificationContext } from './NotificationCenterContext'; +import { NotificationCenterNotificationMessage } from './Notifications/NotificationCenterNotificationMessage'; +import { + adjustSeverity, + checkIfMaintenanceNotification, + isEUModelContractNotification, +} from './utils'; + +import type { + FormattedNotificationProps, + NotificationCenterNotificationsItem, +} from './types'; +import type { + Notification, + NotificationType, + Profile, + Region, +} from '@linode/api-v4'; -export interface ExtendedNotification extends Notification { - jsx?: JSX.Element; -} +const formatNotificationForDisplay = ( + notification: Notification, + idx: number, + onClose: () => void, + shouldIncludeInCount: boolean = true +): NotificationCenterNotificationsItem => ({ + body: ( + + ), + countInTotal: shouldIncludeInCount, + eventId: -1, + id: `notification-${idx}`, +}); -export const useFormattedNotifications = (): NotificationItem[] => { +export const useFormattedNotifications = (): NotificationCenterNotificationsItem[] => { const notificationContext = React.useContext(_notificationContext); const { dismissNotifications, @@ -39,7 +59,6 @@ export const useFormattedNotifications = (): NotificationItem[] => { const { data: regions } = useRegionsQuery(); const { data: profile } = useProfile(); - const { data: notifications } = useNotificationsQuery(); const volumeMigrationScheduledIsPresent = notifications?.some( @@ -80,7 +99,7 @@ export const useFormattedNotifications = (): NotificationItem[] => { message: 'You have pending volume migrations. Check the maintenance page for more details.', severity: 'major', - type: 'volume_migration_scheduled' as NotificationType, + type: 'volume_migration_scheduled', until: null, when: null, }); @@ -108,7 +127,8 @@ export const useFormattedNotifications = (): NotificationItem[] => { * the contents of notification.message get changed, or JSX is generated and added to the notification object, etc. * * Specific types of notifications that are altered here: ticket_abuse, ticket_important, maintenance, maintenance_scheduled, - * migration_pending, outage + * migration_pending, outage. + * * @param notification * @param onClose */ @@ -117,7 +137,7 @@ const interceptNotification = ( onClose: () => void, regions: Region[], profile: Profile | undefined -): ExtendedNotification => { +): FormattedNotificationProps => { // Ticket interceptions if (notification.type === 'ticket_abuse') { return { @@ -358,31 +378,7 @@ const StyledLink = styled(Link)>( }) ); -const formatNotificationForDisplay = ( - notification: Notification, - idx: number, - onClose: () => void, - shouldIncludeInCount: boolean = true -): NotificationItem => ({ - body: , - countInTotal: shouldIncludeInCount, - eventId: -1, - id: `notification-${idx}`, -}); - -// For communicative purposes in the UI, in some cases we want to adjust the severity of certain notifications compared to what the API returns. If it is a maintenance notification of any sort, we display them as major instead of critical. Otherwise, we return the existing severity. -export const adjustSeverity = ({ - severity, - type, -}: Notification): NotificationSeverity => { - if (checkIfMaintenanceNotification(type)) { - return 'major'; - } - - return severity; -}; - -const ComplianceNotification: React.FC<{}> = () => { +const ComplianceNotification = () => { const complianceModelContext = React.useContext(complianceUpdateContext); return ( @@ -399,9 +395,3 @@ const ComplianceNotification: React.FC<{}> = () => { ); }; - -export const isEUModelContractNotification = (notification: Notification) => { - return ( - notification.type === 'notice' && /eu-model/gi.test(notification.message) - ); -}; diff --git a/packages/manager/src/features/NotificationCenter/utils.ts b/packages/manager/src/features/NotificationCenter/utils.ts new file mode 100644 index 00000000000..c16ab459263 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/utils.ts @@ -0,0 +1,49 @@ +import type { + Notification, + NotificationSeverity, + NotificationType, +} from '@linode/api-v4'; + +export const maintenanceNotificationTypes: Partial[] = [ + 'maintenance', + 'maintenance_scheduled', +] as const; + +export const checkIfMaintenanceNotification = (type: NotificationType) => { + return maintenanceNotificationTypes.includes(type); +}; + +export const isEUModelContractNotification = (notification: Notification) => { + return ( + notification.type === 'notice' && /eu-model/gi.test(notification.message) + ); +}; + +/** + * For communicative purposes in the UI, in some cases we want to adjust the severity of certain notifications compared to what the API returns. + * If it is a maintenance notification of any sort, we display them as major instead of critical. Otherwise, we return the existing severity. + */ +export const adjustSeverity = ({ + severity, + type, +}: Notification): NotificationSeverity => { + if (checkIfMaintenanceNotification(type)) { + return 'major'; + } + + return severity; +}; + +export const getEntityLinks = ( + notificationType?: NotificationType, + entityType?: string, + id?: number +) => { + // Handle specific notification types + if (notificationType === 'ticket_abuse') { + return `/support/tickets/${id}`; + } + + // The only entity.type we currently expect and can handle for is "linode" + return entityType === 'linode' ? `/linodes/${id}` : null; +}; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts index e44936ac4ba..96183503462 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts @@ -24,14 +24,6 @@ export const StyledRootContainer = styled(Paper, { padding: theme.spacing(3), })); -export const StyledHelperText = styled(Typography, { - label: 'StyledHelperText', -})(({ theme }) => ({ - lineHeight: 1.5, - paddingBottom: theme.spacing(), - paddingTop: theme.spacing(), -})); - export const StyledActionsPanel = styled(ActionsPanel, { label: 'StyledActionsPanel', })(() => ({ diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx index 019050b040c..6ab5971ae72 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx @@ -1,18 +1,12 @@ import * as React from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; import { useHistory, useLocation } from 'react-router-dom'; -import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; -import { SupportLink } from 'src/components/SupportLink'; -import { Typography } from 'src/components/Typography'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; import { BucketRateLimitTable } from '../BucketLanding/BucketRateLimitTable'; import { BucketBreadcrumb } from './BucketBreadcrumb'; import { StyledActionsPanel, - StyledHelperText, StyledRootContainer, StyledText, } from './BucketProperties.styles'; @@ -23,69 +17,36 @@ interface Props { bucket: ObjectStorageBucket; } -export interface UpdateBucketRateLimitPayload { - rateLimit: string; -} - export const BucketProperties = React.memo((props: Props) => { const { bucket } = props; const { endpoint_type, hostname, label } = bucket; - const form = useForm({ - defaultValues: { - rateLimit: '1', - }, - }); - - const { - formState: { errors, isDirty, isSubmitting }, - handleSubmit, - } = form; - const location = useLocation(); const history = useHistory(); const prefix = getQueryParamFromQueryString(location.search, 'prefix'); - const onSubmit = () => { - // TODO: OBJGen2 - Handle Bucket Rate Limit update logic once the endpoint for updating is available. - // The 'data' argument is expected -> data: UpdateBucketRateLimitPayload - }; - return ( - + <> - {hostname || 'Loading...'} + {hostname} - Bucket Rate Limits - - {errors.root?.message ? ( - - ) : null} - - {/* TODO: OBJGen2 - We need to handle link in upcoming PR */} - - Specifies the maximum Requests Per Second (RPS) for an Endpoint. To - increase it to High,{' '} - - . Understand bucket rate limits. - - - - - - + + {/* TODO: OBJGen2 - This will be handled once we receive API for bucket rates */} + - + ); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx index ce93bdbacfd..d05d46216b1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx @@ -1,6 +1,5 @@ -import { CreateObjectStorageBucketSSLPayload } from '@linode/api-v4/lib/object-storage'; -import Grid from '@mui/material/Unstable_Grid2'; import { useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -29,6 +28,8 @@ import { StyledKeyWrapper, } from './BucketSSL.styles'; +import type { CreateObjectStorageBucketSSLPayload } from '@linode/api-v4'; + interface Props { bucketName: string; clusterId: string; @@ -80,7 +81,7 @@ export const SSLBody = (props: Props) => { const AddCertForm = (props: Props) => { const { bucketName, clusterId } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useBucketSSLMutation( + const { error, isPending, mutateAsync } = useBucketSSLMutation( clusterId, bucketName ); @@ -145,7 +146,7 @@ const AddCertForm = (props: Props) => { @@ -160,7 +161,7 @@ const RemoveCertForm = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: deleteSSLCert, } = useBucketSSLDeleteMutation(clusterId, bucketName); @@ -177,11 +178,11 @@ const RemoveCertForm = (props: Props) => { setOpen(false), }} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx index 99bbdb38be7..34f8cbfaed5 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx @@ -25,7 +25,7 @@ export const CreateFolderDrawer = (props: Props) => { prefix, } = props; - const { error, isLoading, mutateAsync } = useCreateObjectUrlMutation( + const { error, isPending, mutateAsync } = useCreateObjectUrlMutation( clusterId, bucketName ); @@ -88,7 +88,7 @@ export const CreateFolderDrawer = (props: Props) => { { expect(queryByTestId('lastModified')).not.toBeInTheDocument(); }); }); + + it("doesn't show the ACL Switch for E2 and E3 buckets", async () => { + const { queryByLabelText } = renderWithTheme( + + ); + await waitFor(() => { + expect( + queryByLabelText('Access Control List (ACL)') + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx index 12fa33d91f1..63b2a7929b3 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx @@ -4,6 +4,7 @@ import { vi } from 'vitest'; import { objectStorageBucketFactory, + objectStorageBucketFactoryGen2, profileFactory, regionFactory, } from 'src/factories'; @@ -200,3 +201,100 @@ describe('BucketDetailsDrawer: Legacy UI', () => { }); }); }); + +describe('BucketDetailDrawer: Gen2 UI', () => { + const e3Bucket = objectStorageBucketFactoryGen2.build(); + const e1Bucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E1', + }); + + const region = regionFactory.build({ + id: e3Bucket.region, + }); + + it('renders correctly when open', () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + options: { + flags: { objMultiCluster: false, objectStorageGen2: { enabled: true } }, + }, + }); + + expect(screen.getByText(e3Bucket.label)).toBeInTheDocument(); + expect(screen.getByTestId('createdTime')).toHaveTextContent( + 'Created: 2019-12-12' + ); + expect(screen.getByTestId('endpointType')).toHaveTextContent( + `Endpoint Type: E3` + ); + expect(screen.getByTestId('cluster')).toHaveTextContent(region.id); + expect(screen.getByText(e3Bucket.hostname)).toBeInTheDocument(); + expect(screen.getByText('1 MB')).toBeInTheDocument(); + expect(screen.getByText('103 objects')).toBeInTheDocument(); + }); + + it("doesn't show the CORS switch for E2 and E3 buckets", async () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: ( + + ), + options: { + flags: { objMultiCluster: false, objectStorageGen2: { enabled: true } }, + }, + }); + + expect( + getByText( + /CORS \(Cross Origin Sharing\) is not available for endpoint types E2 and E3./ + ) + ).toBeInTheDocument(); + }); + + it('renders the Bucket Rate Limit Table for E2 and E3 buckets', async () => { + const { findByTestId } = renderWithThemeAndHookFormContext({ + component: ( + + ), + options: { + flags: { objMultiCluster: false, objectStorageGen2: { enabled: true } }, + }, + }); + + const rateLimitTable = await findByTestId('bucket-rate-limit-table'); + expect(rateLimitTable).toBeVisible(); + }); + + it('renders the Bucket Rate Limit Text for E0 and E1 buckets', async () => { + const { findByText } = renderWithThemeAndHookFormContext({ + component: ( + + ), + options: { + flags: { objMultiCluster: false, objectStorageGen2: { enabled: true } }, + }, + }); + expect( + await findByText( + /This endpoint type supports up to 750 Requests Per Second \(RPS\)./ + ) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 0b4109f8eca..b4ebf39c075 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -80,8 +80,6 @@ export const BucketDetailsDrawer = React.memo( ); let formattedCreated; - const showBucketRateLimitTable = - endpoint_type === 'E2' || endpoint_type === 'E3'; try { if (created) { @@ -149,17 +147,13 @@ export const BucketDetailsDrawer = React.memo( to getBucketAccess and updateBucketAccess. */} {isObjectStorageGen2Enabled && ( <> - - Bucket Rate Limits - - {showBucketRateLimitTable ? ( - - ) : ( - - This endpoint type supports up to 750 Requests Per Second(RPS).{' '} - Understand bucket rate limits. - - )} + )} @@ -187,6 +181,7 @@ export const BucketDetailsDrawer = React.memo( payload ); }} + endpointType={endpoint_type} name={label} variant="bucket" /> diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.test.tsx index 69110f0ccda..fe8ba2f1ce2 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.test.tsx @@ -6,7 +6,7 @@ import { BucketRateLimitTable } from './BucketRateLimitTable'; // recent bucket rate limit changes cause these tests to fail + bug when opening up Create Bucket drawer. // commenting out these tests for now + will investigate in a separate PR (need to investigate further) -describe.skip('BucketRateLimitTable', () => { +describe('BucketRateLimitTable', () => { it('should render a BucketRateLimitTable', () => { const { getAllByRole, getByText, queryByText } = renderWithTheme( diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx index 4cd414406dd..99bb79ac6c5 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx @@ -1,25 +1,30 @@ import React from 'react'; -import { useController, useFormContext } from 'react-hook-form'; +import { Box } from 'src/components/Box'; import { FormControlLabel } from 'src/components/FormControlLabel'; +import { FormLabel } from 'src/components/FormLabel'; +import { Link } from 'src/components/Link'; import { Radio } from 'src/components/Radio/Radio'; +import { SupportLink } from 'src/components/SupportLink'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; +import { Typography } from 'src/components/Typography'; -import type { UpdateBucketRateLimitPayload } from '../BucketDetail/BucketProperties'; import type { ObjectStorageEndpointTypes } from '@linode/api-v4'; +import type { TypographyProps } from 'src/components/Typography'; /** - * TODO: This component is currently using static data until + * TODO: [IMPORTANT NOTE]: This component is currently using static data until * and API endpoint is available to return rate limits for * each endpoint type. */ interface BucketRateLimitTableProps { - endpointType: ObjectStorageEndpointTypes | undefined; + endpointType?: ObjectStorageEndpointTypes; + typographyProps?: TypographyProps; } const tableHeaders = ['Limits', 'GET', 'PUT', 'LIST', 'DELETE', 'OTHER']; @@ -50,66 +55,93 @@ const tableData = ({ endpointType }: BucketRateLimitTableProps) => { export const BucketRateLimitTable = ({ endpointType, + typographyProps, }: BucketRateLimitTableProps) => { - const { control } = useFormContext(); - const { field } = useController({ - control, - name: 'rateLimit', - }); + const isGen2EndpointType = endpointType === 'E2' || endpointType === 'E3'; return ( -
    - - - {tableHeaders.map((header, index) => { - return ( - - {header} - - ); - })} - - - - {tableData({ endpointType }).map((row, rowIndex) => ( - - - field.onChange(row.id)} - value={row.id} + + + + Bucket Rate Limits + + + + {isGen2EndpointType ? ( + <> + Specifies the maximum Requests Per Second (RPS) for a bucket. To + increase it to High,{' '} + + .{' '} + + ) : ( + 'This endpoint type supports up to 750 Requests Per Second (RPS). ' + )} + Understand bucket rate limits. + + + {isGen2EndpointType && ( +
    + + + {tableHeaders.map((header, index) => { + return ( + + {header} + + ); + })} + + + + {tableData({ endpointType }).map((row, rowIndex) => ( + + + null} + value={row.id} + /> + } + label={row.label} /> - } - label={row.label} - /> - - {row.values.map((value, index) => { - return ( - - {value} - ); - })} - - ))} - -
    + {row.values.map((value, index) => { + return ( + + {value} + + ); + })} +
    + ))} + + + )} + ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.test.tsx index bbce7fa8900..c1f86d77f06 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.test.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; -import { objectStorageBucketFactory } from 'src/factories'; -import { renderWithTheme, mockMatchMedia } from 'src/utilities/testHelpers'; +import { + objectStorageBucketFactory, + objectStorageBucketFactoryGen2, +} from 'src/factories'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import { BucketTable } from './BucketTable'; @@ -43,4 +46,20 @@ describe('BucketTable', () => { expect(getByText(bucket.label)).toBeVisible(); } }); + + it('renders "Endpoint Type" column when Gen 2 is enabled', () => { + const bucket = objectStorageBucketFactoryGen2.buildList(1); + const { getByText } = renderWithTheme( + + ); + expect(getByText('Endpoint Type')).toBeVisible(); + expect(getByText('Standard (E3)')).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index d0af27ae9d9..3adc16b1863 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -63,7 +63,7 @@ export const CreateBucketDrawer = (props: Props) => { const isInvalidPrice = !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; - const { isLoading, mutateAsync: createBucket } = useCreateBucketMutation(); + const { isPending, mutateAsync: createBucket } = useCreateBucketMutation(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { data: accountSettings } = useAccountSettings(); @@ -200,7 +200,7 @@ export const CreateBucketDrawer = (props: Props) => { 'data-testid': 'create-bucket-button', disabled: (showGDPRCheckbox && !hasSignedAgreement) || isErrorTypes, label: 'Create Bucket', - loading: isLoading || Boolean(clusterRegion?.id && isLoadingTypes), + loading: isPending || Boolean(clusterRegion?.id && isLoadingTypes), tooltipText: !isLoadingTypes && isInvalidPrice ? PRICES_RELOAD_ERROR_NOTICE_TEXT diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx index a600017fb90..dd6e664daf6 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { objectStorageEndpointsFactory } from 'src/factories'; @@ -42,7 +42,7 @@ describe('OMC_CreateBucketDrawer', () => { }); it( - 'should display the endpoint selector if endpoints exist', + 'should not display the endpoint selector if regions is not selected', server.boundary(async () => { server.use( http.get('*/v4/object-storage/endpoints', () => { @@ -58,7 +58,7 @@ describe('OMC_CreateBucketDrawer', () => { }) ); - const { getByText, queryByText } = renderWithThemeAndHookFormContext({ + const { queryByText } = renderWithThemeAndHookFormContext({ component: , options: { flags: { @@ -71,19 +71,6 @@ describe('OMC_CreateBucketDrawer', () => { expect( queryByText('Object Storage Endpoint Type') ).not.toBeInTheDocument(); - - await waitFor( - () => - expect(getByText('Object Storage Endpoint Type')).toBeInTheDocument(), - { - timeout: 2000, - } - ); - - // Additional verification after waitFor - const endpointTypeElement = getByText('Object Storage Endpoint Type'); - expect(endpointTypeElement).toBeVisible(); - expect(endpointTypeElement.tagName).toBe('LABEL'); }) ); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 4cce7084f1a..8efb1000a6f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -6,7 +6,6 @@ import { Controller, useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Drawer } from 'src/components/Drawer'; -import { FormLabel } from 'src/components/FormLabel'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; @@ -95,7 +94,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { const isInvalidPrice = !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; - const { isLoading, mutateAsync: createBucket } = useCreateBucketMutation(); + const { isPending, mutateAsync: createBucket } = useCreateBucketMutation(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { data: accountSettings } = useAccountSettings(); @@ -236,11 +235,6 @@ export const OMC_CreateBucketDrawer = (props: Props) => { ); }, [filteredEndpointOptions, watch]); - const isGen2EndpointType = - selectedEndpointOption && - selectedEndpointOption.endpoint_type !== 'E0' && - selectedEndpointOption.endpoint_type !== 'E1'; - const { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, @@ -334,7 +328,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { name="region" /> {selectedRegion?.id && } - {Boolean(endpoints) && ( + {Boolean(endpoints) && selectedRegion && ( <> ( @@ -365,24 +359,13 @@ export const OMC_CreateBucketDrawer = (props: Props) => { control={control} name="endpoint_type" /> - {selectedEndpointOption && ( - <> - - - Bucket Rate Limits - - - - {isGen2EndpointType - ? 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. ' - : 'This endpoint type supports up to 750 Requests Per Second (RPS). '} - Understand bucket rate limits. - - - )} - {isGen2EndpointType && ( + {Boolean(endpoints) && selectedEndpointOption && ( )} @@ -404,7 +387,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { disabled: (showGDPRCheckbox && !state.hasSignedAgreement) || isErrorTypes, label: 'Create Bucket', - loading: isLoading || Boolean(selectedRegion?.id && isLoadingTypes), + loading: isPending || Boolean(selectedRegion?.id && isLoadingTypes), tooltipText: !isLoadingTypes && isInvalidPrice ? PRICES_RELOAD_ERROR_NOTICE_TEXT diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts index 9d2173337c7..4dbb5d87fc9 100644 --- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -9,11 +9,10 @@ import type { OCA } from './types'; * for it to be visible to users. */ export const oneClickApps: Record = { - 0: { - ...oneClickAppFactory.build({ - name: 'E2E Test App', - }), - }, + 0: oneClickAppFactory.build({ + isNew: true, + name: 'E2E Test App', + }), 401697: { alt_description: 'Popular website content management system.', alt_name: 'CMS: content management system', @@ -2591,4 +2590,27 @@ export const oneClickApps: Record = { summary: 'High-performance database for analytics, monitoring and IoT.', website: 'https://influxdata.com/', }, + 1403818: { + alt_description: + 'Distributed data processing engine for large-scale analytics.', + alt_name: 'Big Data processing framework.', + categories: ['Stacks'], + colors: { + end: 'E25A1B', + start: '1D678F', + }, + description: `Fast, open-source unified analytics engine for large-scale data processing.`, + isNew: true, + logo_url: 'apachespark.svg', + name: 'Apache Spark Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/marketplace-docs/guides/apache-spark/', + title: 'Deploy Apache Spark through the Linode Marketplace', + }, + ], + summary: 'Unified analytics engine for big data processing.', + website: 'https://spark.apache.org/', + }, }; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx index 476c0a826de..a7662421b52 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx @@ -56,7 +56,7 @@ export const PlacementGroupsAssignLinodesDrawer = ( region, }); const { - isLoading, + isPending, mutateAsync: assignLinodes, } = useAssignLinodesToPlacementGroup(selectedPlacementGroup?.id ?? -1); const [selectedLinode, setSelectedLinode] = React.useState( @@ -176,7 +176,7 @@ export const PlacementGroupsAssignLinodesDrawer = ( setSelectedLinode(value); }} checkIsOptionEqualToValue - disabled={hasReachedCapacity || isLoading} + disabled={hasReachedCapacity || isPending} label={linodeSelectLabel} options={getLinodeSelectOptions()} placeholder="Select Linode or type to search" diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx index 43b451c6cca..383bbc74093 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx @@ -43,7 +43,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error: deletePlacementError, - isLoading: deletePlacementLoading, + isPending: deletePlacementLoading, mutateAsync: deletePlacementGroup, reset: resetDeletePlacementGroup, } = useDeletePlacementGroup(selectedPlacementGroup?.id ?? -1); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx index 635f6f98052..7347ff8ca95 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx @@ -90,12 +90,11 @@ export const PlacementGroupsLinodes = (props: Props) => { { - setSearchText(value); - }} + clearable debounceTime={250} hideLabel label="Search Linodes" + onSearch={setSearchText} placeholder="Search Linodes" value={searchText} /> diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index 8b6dfbdf6ba..096c7885d10 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -1,15 +1,12 @@ -import CloseIcon from '@mui/icons-material/Close'; import { useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { useParams } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { CircleProgress } from 'src/components/CircleProgress'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; -import { IconButton } from 'src/components/IconButton'; -import { InputAdornment } from 'src/components/InputAdornment'; import { LandingHeader } from 'src/components/LandingHeader'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; @@ -174,26 +171,12 @@ export const PlacementGroupsLanding = React.memo(() => { title="Placement Groups" /> - {isFetching && } - - setQuery('')} - size="small" - sx={{ padding: 'unset' }} - > - - - - ), - }} + clearable debounceTime={250} hideLabel + isSearching={isFetching} label="Search" - onChange={(e) => setQuery(e.target.value)} + onSearch={setQuery} placeholder="Search Placement Groups" sx={{ mb: 4 }} value={query} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx index bc781ee0985..4db87ca1dfb 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx @@ -39,7 +39,7 @@ export const PlacementGroupsUnassignModal = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: unassignLinodes, } = useUnassignLinodesFromPlacementGroup(+placementGroupId ?? -1); @@ -78,9 +78,9 @@ export const PlacementGroupsUnassignModal = (props: Props) => { const actions = ( { }); describe('useIsPlacementGroupsEnabled', () => { - it('returns true if the feature flag is enabled and the account has the Placement Group capability', () => { - queryMocks.useFlags.mockReturnValue({ - placementGroups: { - enabled: true, - }, - }); + it('returns true if the account has the Placement Group capability', () => { queryMocks.useAccount.mockReturnValue({ data: { capabilities: ['Placement Group'], @@ -235,23 +230,6 @@ describe('useIsPlacementGroupsEnabled', () => { }); }); - it('returns false if the feature flag is disabled', () => { - queryMocks.useFlags.mockReturnValue({ - placementGroups: { - enabled: false, - }, - }); - queryMocks.useAccount.mockReturnValue({ - data: { - capabilities: ['Placement Group'], - }, - }); - - const { result } = renderHook(() => useIsPlacementGroupsEnabled()); - expect(result.current).toStrictEqual({ - isPlacementGroupsEnabled: false, - }); - }); it('returns false if the account does not have the Placement Group capability', () => { queryMocks.useFlags.mockReturnValue({ placementGroups: { diff --git a/packages/manager/src/features/PlacementGroups/utils.ts b/packages/manager/src/features/PlacementGroups/utils.ts index c466a0ff6a2..740a0c77685 100644 --- a/packages/manager/src/features/PlacementGroups/utils.ts +++ b/packages/manager/src/features/PlacementGroups/utils.ts @@ -132,12 +132,8 @@ export const useIsPlacementGroupsEnabled = (): { return { isPlacementGroupsEnabled: false }; } - const hasAccountCapability = account?.capabilities?.includes( - 'Placement Group' - ); - const isFeatureFlagEnabled = flags.placementGroups?.enabled; const isPlacementGroupsEnabled = Boolean( - hasAccountCapability && isFeatureFlagEnabled + account?.capabilities?.includes('Placement Group') ); return { isPlacementGroupsEnabled }; diff --git a/packages/manager/src/features/Profile/APITokens/APITokenTable.styles.ts b/packages/manager/src/features/Profile/APITokens/APITokenTable.styles.ts deleted file mode 100644 index f232e89ce8c..00000000000 --- a/packages/manager/src/features/Profile/APITokens/APITokenTable.styles.ts +++ /dev/null @@ -1,26 +0,0 @@ -import Grid from '@mui/material/Unstable_Grid2'; -import { styled } from '@mui/material/styles'; - -import { Typography } from 'src/components/Typography'; - -export const StyledRootContainer = styled(Grid, { - label: 'StyledRootContainer', -})(({ theme }) => ({ - background: theme.color.white, - margin: 0, - width: '100%', -})); - -export const StyledHeadline = styled(Typography, { - label: 'StyledHeadline', -})(() => ({ - marginLeft: 7, -})); - -export const StyledAddNewWrapper = styled(Grid, { - label: 'StyledAddNewWrapper', -})(() => ({ - '&.MuiGrid-item': { - padding: 5, - }, -})); diff --git a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx index 84cb4fc7f28..e8f84e4f94d 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx @@ -1,11 +1,10 @@ -import { Token } from '@linode/api-v4/lib/profile'; -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Paper } from 'src/components/Paper'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -28,17 +27,14 @@ import { } from 'src/queries/profile/tokens'; import { APITokenMenu } from './APITokenMenu'; -import { - StyledAddNewWrapper, - StyledHeadline, - StyledRootContainer, -} from './APITokenTable.styles'; import { CreateAPITokenDrawer } from './CreateAPITokenDrawer'; import { EditAPITokenDrawer } from './EditAPITokenDrawer'; import { RevokeTokenDialog } from './RevokeTokenDialog'; import { isWayInTheFuture } from './utils'; import { ViewAPITokenDrawer } from './ViewAPITokenDrawer'; +import type { Token } from '@linode/api-v4'; + export type APITokenType = 'OAuth Client Token' | 'Personal Access Token'; export type APITokenTitle = @@ -185,32 +181,34 @@ export const APITokenTable = (props: Props) => { return ( - - - - {title} - - - - {type === 'Personal Access Token' && ( - - )} - - + + {title} + + {type === 'Personal Access Token' && ( + + )} + diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index 8ce9ba24bd7..bf6eab61363 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -103,7 +103,7 @@ export const CreateAPITokenDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: createPersonalAccessToken, } = useCreatePersonalAccessTokenMutation(); @@ -367,7 +367,7 @@ export const CreateAPITokenDrawer = (props: Props) => { 'data-testid': 'create-button', disabled: !hasAccessBeenSelectedForAllScopes(form.values.scopes), label: 'Create Token', - loading: isLoading, + loading: isPending, onClick: () => form.handleSubmit(), }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx index c64ae31140a..3aba5fb979f 100644 --- a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx @@ -1,4 +1,3 @@ -import { Token, TokenRequest } from '@linode/api-v4/lib/profile/types'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -9,6 +8,8 @@ import { TextField } from 'src/components/TextField'; import { useUpdatePersonalAccessTokenMutation } from 'src/queries/profile/tokens'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { Token, TokenRequest } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -20,7 +21,7 @@ export const EditAPITokenDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updatePersonalAccessToken, } = useUpdatePersonalAccessTokenMutation(token?.id ?? -1); @@ -52,7 +53,7 @@ export const EditAPITokenDrawer = (props: Props) => { 'data-testid': 'save-button', disabled: !form.dirty, label: 'Save', - loading: isLoading, + loading: isPending, onClick: () => form.handleSubmit(), }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx b/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx index 039f2781ec4..3188d299a07 100644 --- a/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx +++ b/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx @@ -1,4 +1,3 @@ -import { Token } from '@linode/api-v4/lib/profile/types'; import { useSnackbar } from 'notistack'; import React from 'react'; @@ -10,7 +9,8 @@ import { useRevokePersonalAccessTokenMutation, } from 'src/queries/profile/tokens'; -import { APITokenType } from './APITokenTable'; +import type { APITokenType } from './APITokenTable'; +import type { Token } from '@linode/api-v4'; export interface Props { onClose: () => void; @@ -27,7 +27,7 @@ export const RevokeTokenDialog = ({ onClose, open, token, type }: Props) => { const useRevokeQuery = queryMap[type]; - const { error, isLoading, mutateAsync } = useRevokeQuery(token?.id ?? -1); + const { error, isPending, mutateAsync } = useRevokeQuery(token?.id ?? -1); const { enqueueSnackbar } = useSnackbar(); const onRevoke = () => { @@ -46,7 +46,7 @@ export const RevokeTokenDialog = ({ onClose, open, token, type }: Props) => { primaryButtonProps={{ 'data-testid': 'revoke-button', label: 'Revoke', - loading: isLoading, + loading: isPending, onClick: onRevoke, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx index 2e2894345c5..c22efc95617 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx @@ -62,7 +62,7 @@ export const PhoneVerification = ({ const { data, error: sendPhoneVerificationCodeError, - isLoading: isResending, + isPending: isResending, mutateAsync: resendPhoneVerificationCode, mutateAsync: sendPhoneVerificationCode, reset: resetSendCodeMutation, diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx index 7558603e818..2eae511187b 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx @@ -14,7 +14,7 @@ interface Props { export const RevokeTrustedDeviceDialog = (props: Props) => { const { deviceId, onClose, open } = props; - const { error, isLoading, mutateAsync } = useRevokeTrustedDeviceMutation( + const { error, isPending, mutateAsync } = useRevokeTrustedDeviceMutation( deviceId ); @@ -30,7 +30,7 @@ export const RevokeTrustedDeviceDialog = (props: Props) => { { const { data: profile } = useProfile(); const { error, - isLoading, + isPending, mutateAsync: optOut, reset, } = useSMSOptOutMutation(); @@ -80,7 +80,7 @@ export const SMSMessaging = () => { { const { data: securityQuestionsData, isLoading } = useSecurityQuestions(); const { - isLoading: isUpdating, + isPending: isUpdating, mutateAsync: updateSecurityQuestions, } = useMutateSecurityQuestions(); const { enqueueSnackbar } = useSnackbar(); diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx index f60e346f1ec..22068358d91 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx @@ -16,7 +16,7 @@ export const DisableTwoFactorDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: disableTwoFactor, reset, } = useDisableTwoFactorMutation(); @@ -38,7 +38,7 @@ export const DisableTwoFactorDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'submit', label: 'Disable Two-factor Authentication', - loading: isLoading, + loading: isPending, onClick: handleDisableTFA, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx new file mode 100644 index 00000000000..19b8557ac5d --- /dev/null +++ b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AvatarColorPickerDialog } from './AvatarColorPickerDialog'; + +import type { AvatarColorPickerDialogProps } from './AvatarColorPickerDialog'; + +const mockProps: AvatarColorPickerDialogProps = { + handleClose: vi.fn(), + open: true, +}; + +describe('AvatarColorPicker', () => { + it('should render a dialog with a title, color picker, and avatar components', () => { + const { getByLabelText, getByTestId, getByTitle } = renderWithTheme( + + ); + + expect(getByTitle('Change Avatar Color')).toBeVisible(); + expect(getByLabelText('Avatar color picker')).toBeVisible(); + expect(getByTestId('avatar')).toBeVisible(); + }); + + it('calls onClose when Close button is clicked', async () => { + const { getByText } = renderWithTheme( + + ); + + await fireEvent.click(getByText('Close')); + expect(mockProps.handleClose).toHaveBeenCalled(); + }); + + it('closes when Save button is clicked', async () => { + const { getByText } = renderWithTheme( + + ); + + await fireEvent.click(getByText('Save')); + expect(mockProps.handleClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx new file mode 100644 index 00000000000..dd0cca24ccf --- /dev/null +++ b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx @@ -0,0 +1,75 @@ +import { Typography } from '@mui/material'; +import React from 'react'; +import { useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Avatar } from 'src/components/Avatar/Avatar'; +import { ColorPicker } from 'src/components/ColorPicker/ColorPicker'; +import { Dialog } from 'src/components/Dialog/Dialog'; +import { Stack } from 'src/components/Stack'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; + +export interface AvatarColorPickerDialogProps { + handleClose: () => void; + open: boolean; +} + +export const AvatarColorPickerDialog = ( + props: AvatarColorPickerDialogProps +) => { + const { handleClose, open } = props; + + const [avatarColor, setAvatarColor] = useState(); + + const { data: preferences } = usePreferences(); + const { mutateAsync: updatePreferences } = useMutatePreferences(); + + return ( + + + + Select a background color for your avatar: + + setAvatarColor(color)} + /> + + + + + + { + if (avatarColor) { + updatePreferences({ + avatarColor, + }).catch(() => {}); + } + handleClose(); + }, + }} + secondaryButtonProps={{ + 'data-testid': 'close-button', + label: 'Close', + onClick: handleClose, + }} + sx={{ + display: 'flex', + }} + /> + + ); +}; diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 1a1ba9ae96b..6c003f183c2 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -5,21 +5,26 @@ import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { v4 } from 'uuid'; +import { Avatar } from 'src/components/Avatar/Avatar'; import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; -import { GravatarByEmail } from 'src/components/GravatarByEmail'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Link } from 'src/components/Link'; import { Paper } from 'src/components/Paper'; import { SingleTextFieldForm } from 'src/components/SingleTextFieldForm/SingleTextFieldForm'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; +import { useGravatar } from 'src/hooks/useGravatar'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; +import { AvatarColorPickerDialog } from './AvatarColorPickerDialog'; import { TimezoneForm } from './TimezoneForm'; import type { ApplicationState } from 'src/store'; +import { GravatarByEmail } from 'src/components/GravatarByEmail'; export const DisplaySettings = () => { const theme = useTheme(); @@ -34,6 +39,13 @@ export const DisplaySettings = () => { const isProxyUser = profile?.user_type === 'proxy'; + const { hasGravatar } = useGravatar(profile?.email); + + const [ + isColorPickerDialogOpen, + setAvatarColorPickerDialogOpen, + ] = React.useState(false); + React.useEffect(() => { if (location.state?.focusEmail && emailRef.current) { emailRef.current.focus(); @@ -89,31 +101,50 @@ export const DisplaySettings = () => { }} display="flex" > - + } + avatar={} height={88} width={88} />
    - Profile photo - + {hasGravatar ? 'Profile photo' : 'Avatar'} + {hasGravatar && ( + + )} - Create, upload, and manage your globally recognized avatar from - a single place with Gravatar. + {hasGravatar + ? 'Create, upload, and manage your globally recognized avatar from a single place with Gravatar.' + : 'Your avatar is automatically generated using the first character of your username.'} - - Manage photo - + {hasGravatar ? ( + + Manage photo + + ) : ( + + )}
    @@ -155,6 +186,10 @@ export const DisplaySettings = () => { /> + setAvatarColorPickerDialogOpen(false)} + open={isColorPickerDialogOpen} + /> ); }; diff --git a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx index 9a1cd6bb1d1..3e871764f68 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx @@ -7,10 +7,12 @@ import timezones from 'src/assets/timezones/timezones'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; import { Typography } from 'src/components/Typography'; import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + interface Props { loggedInAsCustomer: boolean; } @@ -48,7 +50,7 @@ export const TimezoneForm = (props: Props) => { const { loggedInAsCustomer } = props; const { enqueueSnackbar } = useSnackbar(); const { data: profile } = useProfile(); - const { error, isLoading, mutateAsync: updateProfile } = useMutateProfile(); + const { error, isPending, mutateAsync: updateProfile } = useMutateProfile(); const [value, setValue] = React.useState | null>(null); const timezone = profile?.timezone ?? ''; @@ -111,7 +113,7 @@ export const TimezoneForm = (props: Props) => { primaryButtonProps={{ disabled, label: 'Update Timezone', - loading: isLoading, + loading: isPending, onClick: onSubmit, sx: { margin: '0', diff --git a/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.tsx b/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.tsx index e7f93935a15..36a783ae129 100644 --- a/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.tsx @@ -1,4 +1,3 @@ -import { OAuthClientRequest } from '@linode/api-v4'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -12,6 +11,8 @@ import { TextField } from 'src/components/TextField'; import { useCreateOAuthClientMutation } from 'src/queries/account/oauth'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; +import type { OAuthClientRequest } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -23,7 +24,7 @@ export const CreateOAuthClientDrawer = ({ open, showSecret, }: Props) => { - const { error, isLoading, mutateAsync } = useCreateOAuthClientMutation(); + const { error, isPending, mutateAsync } = useCreateOAuthClientMutation(); const formik = useFormik({ initialValues: { @@ -86,7 +87,7 @@ export const CreateOAuthClientDrawer = ({ { - const { error, isLoading, mutateAsync } = useDeleteOAuthClientMutation(id); + const { error, isPending, mutateAsync } = useDeleteOAuthClientMutation(id); const onDelete = () => { mutateAsync().then(() => { @@ -33,7 +33,7 @@ export const DeleteOAuthClientDialog = ({ primaryButtonProps={{ 'data-testid': 'button-confirm', label: 'Delete', - loading: isLoading, + loading: isPending, onClick: onDelete, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx b/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx index be2f9ab8f97..67cf9149f7d 100644 --- a/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx @@ -1,17 +1,18 @@ -import { OAuthClient, OAuthClientRequest } from '@linode/api-v4'; import { useFormik } from 'formik'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Checkbox } from 'src/components/Checkbox'; import { Drawer } from 'src/components/Drawer'; -import { Notice } from 'src/components/Notice/Notice'; -import { TextField } from 'src/components/TextField'; import { FormControl } from 'src/components/FormControl'; import { FormControlLabel } from 'src/components/FormControlLabel'; +import { Notice } from 'src/components/Notice/Notice'; +import { TextField } from 'src/components/TextField'; import { useUpdateOAuthClientMutation } from 'src/queries/account/oauth'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; +import type { OAuthClient, OAuthClientRequest } from '@linode/api-v4'; + interface Props { client: OAuthClient | undefined; onClose: () => void; @@ -19,7 +20,7 @@ interface Props { } export const EditOAuthClientDrawer = ({ client, onClose, open }: Props) => { - const { error, isLoading, mutateAsync, reset } = useUpdateOAuthClientMutation( + const { error, isPending, mutateAsync, reset } = useUpdateOAuthClientMutation( client?.id ?? '' ); @@ -81,7 +82,7 @@ export const EditOAuthClientDrawer = ({ client, onClose, open }: Props) => { primaryButtonProps={{ disabled: !formik.dirty, label: 'Save Changes', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Profile/OAuthClients/ResetOAuthClientDialog.tsx b/packages/manager/src/features/Profile/OAuthClients/ResetOAuthClientDialog.tsx index 2779ca81c79..2e99357ffea 100644 --- a/packages/manager/src/features/Profile/OAuthClients/ResetOAuthClientDialog.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/ResetOAuthClientDialog.tsx @@ -20,7 +20,7 @@ export const ResetOAuthClientDialog = ({ open, showSecret, }: Props) => { - const { error, isLoading, mutateAsync } = useResetOAuthClientMutation(id); + const { error, isPending, mutateAsync } = useResetOAuthClientMutation(id); const onReset = () => { mutateAsync().then((data) => { @@ -35,7 +35,7 @@ export const ResetOAuthClientDialog = ({ { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: createSSHKey, } = useCreateSSHKeyMutation(); @@ -96,7 +96,7 @@ export const CreateSSHKeyDrawer = React.memo(({ onClose, open }: Props) => { primaryButtonProps={{ 'data-testid': 'submit', label: 'Add Key', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: handleClose }} diff --git a/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx b/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx index 7b72b3913d7..5edca5addf9 100644 --- a/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx @@ -13,7 +13,7 @@ interface Props { } const DeleteSSHKeyDialog = ({ id, label, onClose, open }: Props) => { - const { error, isLoading, mutateAsync } = useDeleteSSHKeyMutation(id); + const { error, isPending, mutateAsync } = useDeleteSSHKeyMutation(id); const onDelete = async () => { await mutateAsync(); @@ -27,7 +27,7 @@ const DeleteSSHKeyDialog = ({ id, label, onClose, open }: Props) => { primaryButtonProps={{ 'data-testid': 'confirm-delete', label: 'Delete', - loading: isLoading, + loading: isPending, onClick: onDelete, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx b/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx index a1c09874fae..05a0393ecc5 100644 --- a/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx @@ -1,8 +1,7 @@ -import { SSHKey } from '@linode/api-v4'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; -import { useEffect } from 'react'; import * as React from 'react'; +import { useEffect } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; @@ -11,6 +10,8 @@ import { TextField } from 'src/components/TextField'; import { useUpdateSSHKeyMutation } from 'src/queries/profile/profile'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; +import type { SSHKey } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -21,7 +22,7 @@ const EditSSHKeyDrawer = ({ onClose, open, sshKey }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: updateSSHKey, reset, } = useUpdateSSHKeyMutation(sshKey?.id ?? -1); @@ -75,7 +76,7 @@ const EditSSHKeyDrawer = ({ onClose, open, sshKey }: Props) => { 'data-testid': 'submit', disabled: !formik.dirty, label: 'Save', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx index d4468be8382..b5f225153c4 100644 --- a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx +++ b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx @@ -4,10 +4,10 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { CopyableAndDownloadableTextField } from 'src/components/CopyableAndDownloadableTextField'; +import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; import { Notice } from 'src/components/Notice/Notice'; -import { HostNamesList } from 'src/features/ObjectStorage/AccessKeyLanding/HostNamesList'; import { CopyAllHostnames } from 'src/features/ObjectStorage/AccessKeyLanding/CopyAllHostnames'; +import { HostNamesList } from 'src/features/ObjectStorage/AccessKeyLanding/HostNamesList'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -118,17 +118,19 @@ export const SecretTokenDialog = (props: Props) => { {objectStorageKey ? ( <> - - @@ -136,9 +138,10 @@ export const SecretTokenDialog = (props: Props) => { ) : value ? ( - diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index f383fdb5279..52eb8de3d9f 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -36,7 +36,7 @@ export const ProfileSettings = () => { }; const { data: profile } = useProfile(); - const { isLoading, mutateAsync: updateProfile } = useMutateProfile(); + const { isPending, mutateAsync: updateProfile } = useMutateProfile(); const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); @@ -70,7 +70,7 @@ export const ProfileSettings = () => { label={`Email alerts for account activity are ${ areEmailNotificationsEnabled ? 'enabled' : 'disabled' }`} - disabled={isLoading} + disabled={isPending} /> diff --git a/packages/manager/src/features/Search/SearchLanding.test.tsx b/packages/manager/src/features/Search/SearchLanding.test.tsx index d6509f04ce9..f2e660bd447 100644 --- a/packages/manager/src/features/Search/SearchLanding.test.tsx +++ b/packages/manager/src/features/Search/SearchLanding.test.tsx @@ -1,4 +1,4 @@ -import { render, waitForElementToBeRemoved } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { assocPath } from 'ramda'; import * as React from 'react'; @@ -9,9 +9,11 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; -import { SearchLandingProps as Props, SearchLanding } from './SearchLanding'; +import { SearchLanding } from './SearchLanding'; import { emptyResults } from './utils'; +import type { SearchLandingProps as Props } from './SearchLanding'; + const props: Props = { combinedResults: [], entities: [], @@ -50,10 +52,7 @@ describe('Component', () => { '?query=search', propsWithResults ); - const { getByTestId, getByText } = renderWithTheme( - - ); - await waitForElementToBeRemoved(getByTestId('loading')); + const { getByText } = renderWithTheme(); getByText(/search/i); expect(props.search).toHaveBeenCalled(); }); diff --git a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx index 71163057cff..e08a0db4129 100644 --- a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx @@ -219,6 +219,7 @@ const withStackScriptBase = (options: WithStackScriptBaseOptions) => ( label="Search by Label, Username, or Description" onSearch={this.handleSearch} placeholder="Search by Label, Username, or Description" + value={query ?? ''} /> ({ '@keyframes fadeIn': { from: { @@ -99,10 +104,14 @@ interface Data { export const ExpandableTicketPanel = React.memo((props: Props) => { const { classes } = useStyles(); + const theme = useTheme(); + const { open, parentTicket, reply, ticket, ticketUpdated } = props; const [data, setData] = React.useState(undefined); + const { data: profile } = useProfile(); + React.useEffect(() => { if (!ticket && !reply) { return; @@ -137,13 +146,28 @@ export const ExpandableTicketPanel = React.memo((props: Props) => { const renderAvatar = (id: string) => { return (
    - - - + + } + gravatar={ + + + + } + />
    ); }; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx b/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx index 705ea2ee9c2..948a57bf672 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx @@ -1,4 +1,3 @@ -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -7,6 +6,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Typography } from 'src/components/Typography'; import { useSupportTicketCloseMutation } from 'src/queries/support'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ closeLink: { ...theme.applyLinkStyles, @@ -24,7 +25,7 @@ export const CloseTicketLink = ({ ticketId }: Props) => { const { error, - isLoading, + isPending, mutateAsync: closeSupportTicket, } = useSupportTicketCloseMutation(ticketId); @@ -38,7 +39,7 @@ export const CloseTicketLink = ({ ticketId }: Props) => { primaryButtonProps={{ 'data-testid': 'dialog-submit', label: 'Confirm', - loading: isLoading, + loading: isPending, onClick: closeTicket, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.test.tsx similarity index 72% rename from packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx rename to packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.test.tsx index ebb7e326c85..34ad1de6536 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.test.tsx @@ -2,12 +2,12 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { NotificationMenuV2 } from './NotificationMenuV2'; +import { NotificationMenu } from './NotificationMenu'; describe('NotificationMenuV2', () => { // Very basic unit - the functionality is tested in the integration test it('should render', () => { - const { getByRole } = renderWithTheme(); + const { getByRole } = renderWithTheme(); expect(getByRole('button', { name: 'Notifications' })).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx index baa052b1dec..f3f8f2dfe22 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx @@ -1,59 +1,57 @@ -// TODO eventMessagesV2: delete when flag is removed +import AutorenewIcon from '@mui/icons-material/Autorenew'; import { IconButton } from '@mui/material'; import Popover from '@mui/material/Popover'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import Bell from 'src/assets/icons/notification.svg'; +import { Box } from 'src/components/Box'; import { Chip } from 'src/components/Chip'; -import Events from 'src/features/NotificationCenter/Events'; +import { Divider } from 'src/components/Divider'; +import { LinkButton } from 'src/components/LinkButton'; +import { Typography } from 'src/components/Typography'; +import { NotificationCenterEvent } from 'src/features/NotificationCenter/Events/NotificationCenterEvent'; import { - notificationContext as _notificationContext, + notificationCenterContext as _notificationContext, menuButtonId, -} from 'src/features/NotificationCenter/NotificationContext'; -import { useEventNotifications } from 'src/features/NotificationCenter/NotificationData/useEventNotifications'; -import { useFormattedNotifications } from 'src/features/NotificationCenter/NotificationData/useFormattedNotifications'; -import Notifications from 'src/features/NotificationCenter/Notifications'; +} from 'src/features/NotificationCenter/NotificationCenterContext'; +import { NotificationCenterNotificationsContainer } from 'src/features/NotificationCenter/Notifications/NotificationCenterNotificationsContainer'; +import { useFormattedNotifications } from 'src/features/NotificationCenter/useFormattedNotifications'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; import { usePrevious } from 'src/hooks/usePrevious'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useMarkEventsAsSeen } from 'src/queries/events/events'; -import { ThunkDispatch } from 'src/store/types'; +import { isInProgressEvent } from 'src/queries/events/event.helpers'; +import { + useInitialEventsQuery, + useMarkEventsAsSeen, +} from 'src/queries/events/events'; +import { rotate360 } from 'src/styles/keyframes'; import { TopMenuTooltip, topMenuIconButtonSx } from '../TopMenuTooltip'; -const StyledChip = styled(Chip)(() => ({ - '& .MuiChip-label': { - paddingLeft: 2, - paddingRight: 2, - }, - fontSize: '0.72rem', - height: '1rem', - justifyContent: 'center', - left: 20, - padding: 0, - position: 'absolute', - top: 4, -})); - export const NotificationMenu = () => { + const history = useHistory(); const { dismissNotifications } = useDismissibleNotifications(); const { data: notifications } = useNotificationsQuery(); const formattedNotifications = useFormattedNotifications(); - const eventNotifications = useEventNotifications(); const notificationContext = React.useContext(_notificationContext); + + const { data, events } = useInitialEventsQuery(); + const eventsData = data?.data ?? []; const { mutateAsync: markEventsAsSeen } = useMarkEventsAsSeen(); const numNotifications = - eventNotifications.filter((thisEvent) => thisEvent.countInTotal).length + - formattedNotifications.filter((thisEvent) => thisEvent.countInTotal).length; + (events?.filter((event) => !event.seen).length ?? 0) + + formattedNotifications.filter( + (notificationItem) => notificationItem.countInTotal + ).length; + + const showInProgressEventIcon = events?.some(isInProgressEvent); const anchorRef = React.useRef(null); const prevOpen = usePrevious(notificationContext.menuOpen); - const dispatch = useDispatch(); - const handleNotificationMenuToggle = () => { if (!notificationContext.menuOpen) { notificationContext.openMenu(); @@ -69,19 +67,18 @@ export const NotificationMenu = () => { React.useEffect(() => { if (prevOpen && !notificationContext.menuOpen) { // Dismiss seen notifications after the menu has closed. - if (eventNotifications.length > 0) { - markEventsAsSeen(eventNotifications[0].eventId); + if (events && events.length >= 1 && !events[0].seen) { + markEventsAsSeen(events[0].id); } dismissNotifications(notifications ?? [], { prefix: 'notificationMenu' }); } }, [ notificationContext.menuOpen, - dismissNotifications, - eventNotifications, + events, notifications, - dispatch, - prevOpen, markEventsAsSeen, + dismissNotifications, + prevOpen, ]); const id = notificationContext.menuOpen ? 'notifications-popover' : undefined; @@ -103,7 +100,16 @@ export const NotificationMenu = () => { > {numNotifications > 0 && ( - + 9 ? '9+' : numNotifications} + showPlus={numNotifications > 9} + size="small" + /> + )} + {showInProgressEventIcon && ( + )} @@ -127,13 +133,72 @@ export const NotificationMenu = () => { }, }} anchorEl={anchorRef.current} + data-qa-notification-menu id={id} onClose={handleClose} open={notificationContext.menuOpen} > - - + + + + Events + { + history.push('/events'); + handleClose(); + }} + > + View all events + + + + + {eventsData.length > 0 ? ( + eventsData + .slice(0, 20) + .map((event) => ( + + )) + ) : ( + + No recent events to display + + )} + ); }; + +const StyledChip = styled(Chip, { + label: 'StyledEventNotificationChip', + shouldForwardProp: (prop) => prop !== 'showPlus', +})<{ showPlus: boolean }>(({ theme, ...props }) => ({ + '& .MuiChip-label': { + paddingLeft: 2, + paddingRight: 2, + }, + borderRadius: props.showPlus ? 12 : '50%', + fontFamily: theme.font.bold, + fontSize: '0.72rem', + height: 18, + justifyContent: 'center', + left: 20, + padding: 0, + position: 'absolute', + top: 0, + width: props.showPlus ? 22 : 18, +})); + +export const StyledAutorenewIcon = styled(AutorenewIcon)(({ theme }) => ({ + animation: `${rotate360} 2s linear infinite`, + bottom: 4, + color: theme.palette.primary.main, + fontSize: 18, + position: 'absolute', + right: 2, +})); diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx deleted file mode 100644 index a83eb52ef4c..00000000000 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import AutorenewIcon from '@mui/icons-material/Autorenew'; -import { IconButton } from '@mui/material'; -import Popover from '@mui/material/Popover'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; -import { useHistory } from 'react-router-dom'; - -import Bell from 'src/assets/icons/notification.svg'; -import { Box } from 'src/components/Box'; -import { Chip } from 'src/components/Chip'; -import { Divider } from 'src/components/Divider'; -import { LinkButton } from 'src/components/LinkButton'; -import { Typography } from 'src/components/Typography'; -import { - notificationContext as _notificationContext, - menuButtonId, -} from 'src/features/NotificationCenter/NotificationContext'; -import { RenderEventV2 } from 'src/features/NotificationCenter/NotificationData/RenderEventV2'; -import { useFormattedNotifications } from 'src/features/NotificationCenter/NotificationData/useFormattedNotifications'; -import Notifications from 'src/features/NotificationCenter/Notifications'; -import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; -import { usePrevious } from 'src/hooks/usePrevious'; -import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { isInProgressEvent } from 'src/queries/events/event.helpers'; -import { - useEventsInfiniteQuery, - useMarkEventsAsSeen, -} from 'src/queries/events/events'; -import { rotate360 } from 'src/styles/keyframes'; - -import { TopMenuTooltip, topMenuIconButtonSx } from '../TopMenuTooltip'; - -export const NotificationMenuV2 = () => { - const history = useHistory(); - const { dismissNotifications } = useDismissibleNotifications(); - const { data: notifications } = useNotificationsQuery(); - const formattedNotifications = useFormattedNotifications(); - const notificationContext = React.useContext(_notificationContext); - - const { data, events } = useEventsInfiniteQuery(); - const { mutateAsync: markEventsAsSeen } = useMarkEventsAsSeen(); - - const numNotifications = - (events?.filter((event) => !event.seen).length ?? 0) + - formattedNotifications.filter( - (notificationItem) => notificationItem.countInTotal - ).length; - - const showInProgressEventIcon = events?.some(isInProgressEvent); - - const anchorRef = React.useRef(null); - const prevOpen = usePrevious(notificationContext.menuOpen); - - const handleNotificationMenuToggle = () => { - if (!notificationContext.menuOpen) { - notificationContext.openMenu(); - } else { - notificationContext.closeMenu(); - } - }; - - const handleClose = () => { - notificationContext.closeMenu(); - }; - - React.useEffect(() => { - if (prevOpen && !notificationContext.menuOpen) { - // Dismiss seen notifications after the menu has closed. - if (events && events.length >= 1 && !events[0].seen) { - markEventsAsSeen(events[0].id); - } - dismissNotifications(notifications ?? [], { prefix: 'notificationMenu' }); - } - }, [notificationContext.menuOpen]); - - const id = notificationContext.menuOpen ? 'notifications-popover' : undefined; - - return ( - <> - - ({ - ...topMenuIconButtonSx(theme), - color: notificationContext.menuOpen ? '#606469' : '#c9c7c7', - })} - aria-describedby={id} - aria-haspopup="true" - aria-label="Notifications" - id={menuButtonId} - onClick={handleNotificationMenuToggle} - ref={anchorRef} - > - - {numNotifications > 0 && ( - 9 ? '9+' : numNotifications} - showPlus={numNotifications > 9} - size="small" - /> - )} - {showInProgressEventIcon && ( - - )} - - - ({ - maxHeight: 'calc(100vh - 150px)', - maxWidth: 430, - py: 2, - [theme.breakpoints.down('sm')]: { - left: '0 !important', - minWidth: '100%', - right: '0 !important', - }, - }), - }, - }} - anchorEl={anchorRef.current} - id={id} - onClose={handleClose} - open={notificationContext.menuOpen} - > - - - - Events - { - history.push('/events'); - handleClose(); - }} - > - View all events - - - - {data?.pages[0].data.slice(0, 20).map((event) => ( - - ))} - - - - ); -}; - -const StyledChip = styled(Chip, { - label: 'StyledEventNotificationChip', - shouldForwardProp: (prop) => prop !== 'showPlus', -})<{ showPlus: boolean }>(({ theme, ...props }) => ({ - '& .MuiChip-label': { - paddingLeft: 2, - paddingRight: 2, - }, - borderRadius: props.showPlus ? 12 : '50%', - fontFamily: theme.font.bold, - fontSize: '0.72rem', - height: 18, - justifyContent: 'center', - left: 20, - padding: 0, - position: 'absolute', - top: 0, - width: props.showPlus ? 22 : 18, -})); - -const StyledAutorenewIcon = styled(AutorenewIcon)(({ theme }) => ({ - animation: `${rotate360} 2s linear infinite`, - bottom: 4, - color: theme.palette.primary.main, - fontSize: 18, - position: 'absolute', - right: 2, -})); diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx index c45414b1a5c..4276ca7695f 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -8,13 +8,11 @@ import { IconButton } from 'src/components/IconButton'; import { Toolbar } from 'src/components/Toolbar'; import { Typography } from 'src/components/Typography'; import { useAuthentication } from 'src/hooks/useAuthentication'; -import { useFlags } from 'src/hooks/useFlags'; import { AddNewMenu } from './AddNewMenu/AddNewMenu'; import { Community } from './Community'; import { Help } from './Help'; import { NotificationMenu } from './NotificationMenu/NotificationMenu'; -import { NotificationMenuV2 } from './NotificationMenu/NotificationMenuV2'; import SearchBar from './SearchBar/SearchBar'; import { TopMenuTooltip } from './TopMenuTooltip'; import { UserMenu } from './UserMenu'; @@ -32,8 +30,6 @@ export interface TopMenuProps { */ export const TopMenu = React.memo((props: TopMenuProps) => { const { desktopMenuToggle, isSideMenuOpen, openSideMenu, username } = props; - // TODO eventMessagesV2: delete when flag is removed - const flags = useFlags(); const { loggedInAsCustomer } = useAuthentication(); @@ -50,7 +46,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => {
    )} - + ({ '&.MuiToolbar-root': { @@ -89,11 +85,9 @@ export const TopMenu = React.memo((props: TopMenuProps) => { - {flags.eventMessagesV2 ? ( - - ) : ( - - )} + + + diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index f6d9ee4c6b2..c1470ebbaaf 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -1,17 +1,18 @@ -import { GlobalGrantTypes } from '@linode/api-v4/lib/account'; import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; -import { Theme, styled, useMediaQuery } from '@mui/material'; +import { styled, useMediaQuery } from '@mui/material'; import Popover from '@mui/material/Popover'; import Grid from '@mui/material/Unstable_Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { Avatar } from 'src/components/Avatar/Avatar'; +import { AvatarForProxy } from 'src/components/AvatarForProxy'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; import { GravatarByEmail } from 'src/components/GravatarByEmail'; -import { GravatarForProxy } from 'src/components/GravatarForProxy'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Hidden } from 'src/components/Hidden'; import { Link } from 'src/components/Link'; import { Stack } from 'src/components/Stack'; @@ -29,6 +30,9 @@ import { getStorage, setStorage } from 'src/utilities/storage'; import { getCompanyNameOrEmail } from './utils'; +import type { GlobalGrantTypes } from '@linode/api-v4/lib/account'; +import type { Theme } from '@mui/material'; + interface MenuLink { display: string; hide?: boolean; @@ -207,9 +211,12 @@ export const UserMenu = React.memo(() => { - - The user will be deleted permanently. - - - ); - }; - - return ( - // eslint-disable-next-line react/jsx-no-useless-fragment - <> - {username !== undefined ? ( - <> - - {renderProfileSection()} - {renderDeleteSection()} - { - push(`/account/users`); - }} - onClose={onDeleteCancel} - open={deleteConfirmDialogOpen} - username={username} - /> - - ) : ( - - )} - - ); -}; diff --git a/packages/manager/src/features/Users/UserProfile/DeleteUserPanel.tsx b/packages/manager/src/features/Users/UserProfile/DeleteUserPanel.tsx new file mode 100644 index 00000000000..4c9eac1481e --- /dev/null +++ b/packages/manager/src/features/Users/UserProfile/DeleteUserPanel.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { PARENT_USER } from 'src/features/Account/constants'; +import { useProfile } from 'src/queries/profile/profile'; + +import { UserDeleteConfirmationDialog } from '../UserDeleteConfirmationDialog'; + +import type { User } from '@linode/api-v4'; + +interface Props { + user: User; +} + +export const DeleteUserPanel = ({ user }: Props) => { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const history = useHistory(); + const { data: profile } = useProfile(); + + const isProxyUserProfile = user.user_type === 'proxy'; + + const tooltipText = + profile?.username === user.username + ? 'You can\u{2019}t delete the currently active user.' + : isProxyUserProfile + ? `You can\u{2019}t delete a ${PARENT_USER}.` + : undefined; + + return ( + + + Delete User + + + + + The user will be deleted permanently. + + setIsDeleteDialogOpen(false)} + onSuccess={() => history.push(`/account/users`)} + open={isDeleteDialogOpen} + username={user.username} + /> + + + ); +}; diff --git a/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.test.tsx b/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.test.tsx new file mode 100644 index 00000000000..901c31c1d62 --- /dev/null +++ b/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { accountUserFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UserDetailsPanel } from './UserDetailsPanel'; + +describe('UserDetailsPanel', () => { + it("renders the user's username and email", async () => { + const user = accountUserFactory.build(); + + const { getByText } = renderWithTheme(); + + expect(getByText('Username')).toBeVisible(); + expect(getByText(user.username)).toBeVisible(); + + expect(getByText('Email')).toBeVisible(); + expect(getByText(user.email)).toBeVisible(); + }); + it("renders 'limited' if the user is restricted", async () => { + const user = accountUserFactory.build({ restricted: true }); + + const { getByText } = renderWithTheme(); + + expect(getByText('Account Access')).toBeVisible(); + expect(getByText('Limited')).toBeVisible(); + }); + it("renders 'full' if the user is unrestricted", async () => { + const user = accountUserFactory.build({ restricted: false }); + + const { getByText } = renderWithTheme(); + + expect(getByText('Account Access')).toBeVisible(); + expect(getByText('Full')).toBeVisible(); + }); + it("renders the user's phone number", async () => { + const user = accountUserFactory.build({ + verified_phone_number: '+17040000000', + }); + + const { getByText } = renderWithTheme(); + + expect(getByText('Verified Phone Number')).toBeVisible(); + expect(getByText(user.verified_phone_number!)).toBeVisible(); + }); + it("renders the user's 2FA status", async () => { + const user = accountUserFactory.build({ tfa_enabled: true }); + + const { getByText } = renderWithTheme(); + + expect(getByText('2FA')).toBeVisible(); + expect(getByText('Enabled')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.tsx b/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.tsx new file mode 100644 index 00000000000..f49b7bee65d --- /dev/null +++ b/packages/manager/src/features/Users/UserProfile/UserDetailsPanel.tsx @@ -0,0 +1,105 @@ +import Grid from '@mui/material/Unstable_Grid2'; +import React from 'react'; + +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TextTooltip } from 'src/components/TextTooltip'; +import { Typography } from 'src/components/Typography'; + +import type { User } from '@linode/api-v4'; + +interface Props { + user: User; +} + +export const UserDetailsPanel = ({ user }: Props) => { + const items = [ + { + label: 'Username', + value: {user.username}, + }, + { + label: 'Email', + value: {user.email}, + }, + { + label: 'Account Access', + value: {user.restricted ? 'Limited' : 'Full'}, + }, + { + label: 'Last Login Status', + value: ( + + + {user.last_login?.status ?? 'N/A'} + + {user.last_login && ( + + )} + + ), + }, + { + label: 'Last Login', + value: user.last_login ? ( + + ) : ( + N/A + ), + }, + { + label: 'Password Created', + value: user.password_created ? ( + + ) : ( + N/A + ), + }, + { + label: '2FA', + value: ( + {user.tfa_enabled ? 'Enabled' : 'Disabled'} + ), + }, + { + label: 'Verified Phone Number', + value: {user.verified_phone_number ?? 'None'}, + }, + { + label: 'SSH Keys', + value: + user.ssh_keys.length > 0 ? ( + + ) : ( + 0 + ), + }, + ]; + + return ( + + + {items.map((item, index) => ( + + + theme.font.bold}> + {item.label} + + {item.value} + + + ))} + + + ); +}; diff --git a/packages/manager/src/features/Users/UserProfile/UserEmailPanel.test.tsx b/packages/manager/src/features/Users/UserProfile/UserEmailPanel.test.tsx new file mode 100644 index 00000000000..ec5f11e2915 --- /dev/null +++ b/packages/manager/src/features/Users/UserProfile/UserEmailPanel.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { accountUserFactory, profileFactory } from 'src/factories'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UserEmailPanel } from './UserEmailPanel'; + +describe('UserEmailPanel', () => { + it("initializes the form with the user's email", async () => { + const user = accountUserFactory.build(); + + const { getByLabelText } = renderWithTheme(); + + const emailTextField = getByLabelText('Email'); + + expect(emailTextField).toHaveDisplayValue(user.email); + }); + + it("does not allow the user to update another user's email", async () => { + const profile = profileFactory.build({ username: 'my-linode-user-1' }); + const user = accountUserFactory.build({ username: 'my-linode-user-2' }); + + server.use( + http.get('*/v4/profile', () => { + return HttpResponse.json(profile); + }) + ); + + const { findByLabelText, getByLabelText, getByText } = renderWithTheme( + + ); + + const warning = await findByLabelText( + 'You can’t change another user’s email address.' + ); + + // Verify there is a tooltip explaining that the user can't change + // another user's email. + expect(warning).toBeInTheDocument(); + + // Verify the input is disabled + expect(getByLabelText('Email')).toBeDisabled(); + + // Verify save button is disabled + expect(getByText('Save').closest('button')).toBeDisabled(); + }); + + it("does not allow the user to update a proxy user's email", async () => { + const user = accountUserFactory.build({ + user_type: 'proxy', + username: 'proxy-user-1', + }); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const warning = getByLabelText('This field can’t be modified.'); + + // Verify there is a tooltip explaining that the user can't change + // a proxy user's email. + expect(warning).toBeInTheDocument(); + + // Verify the input is disabled + expect(getByLabelText('Email')).toBeDisabled(); + + // Verify save button is disabled + expect(getByText('Save').closest('button')).toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/Users/UserProfile/UserEmailPanel.tsx b/packages/manager/src/features/Users/UserProfile/UserEmailPanel.tsx new file mode 100644 index 00000000000..a23049081bf --- /dev/null +++ b/packages/manager/src/features/Users/UserProfile/UserEmailPanel.tsx @@ -0,0 +1,88 @@ +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { Button } from 'src/components/Button/Button'; +import { Paper } from 'src/components/Paper'; +import { TextField } from 'src/components/TextField'; +import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; + +import type { User } from '@linode/api-v4'; + +interface Props { + user: User; +} + +export const UserEmailPanel = ({ user }: Props) => { + const { enqueueSnackbar } = useSnackbar(); + const { data: profile } = useProfile(); + + const isProxyUserProfile = user?.user_type === 'proxy'; + + const { mutateAsync: updateProfile } = useMutateProfile(); + + const { + control, + formState: { isDirty, isSubmitting }, + handleSubmit, + setError, + } = useForm({ + defaultValues: { email: user.email }, + values: { email: user.email }, + }); + + const onSubmit = async (values: { email: string }) => { + try { + await updateProfile(values); + + enqueueSnackbar('Email updated successfully', { variant: 'success' }); + } catch (error) { + setError('email', { message: error[0].reason }); + } + }; + + const disabledReason = isProxyUserProfile + ? RESTRICTED_FIELD_TOOLTIP + : profile?.username !== user.username + ? 'You can\u{2019}t change another user\u{2019}s email address.' + : undefined; + + // This should be disabled if this is NOT the current user or if the proxy user is viewing their own profile. + const disableEmailField = + profile?.username !== user.username || isProxyUserProfile; + + return ( + +
    + ( + + )} + control={control} + name="email" + /> + + +
    + ); +}; diff --git a/packages/manager/src/features/Users/UserProfile/UserProfile.tsx b/packages/manager/src/features/Users/UserProfile/UserProfile.tsx new file mode 100644 index 00000000000..d535ed50dcf --- /dev/null +++ b/packages/manager/src/features/Users/UserProfile/UserProfile.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { CircleProgress } from 'src/components/CircleProgress'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { NotFound } from 'src/components/NotFound'; +import { Stack } from 'src/components/Stack'; +import { useAccountUser } from 'src/queries/account/users'; + +import { DeleteUserPanel } from './DeleteUserPanel'; +import { UserDetailsPanel } from './UserDetailsPanel'; +import { UserEmailPanel } from './UserEmailPanel'; +import { UsernamePanel } from './UsernamePanel'; + +export const UserProfile = () => { + const { username } = useParams<{ username: string }>(); + + const { data: user, error, isLoading } = useAccountUser(username ?? ''); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!user) { + return ; + } + + return ( + <> + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Users/UserProfile/UsernamePanel.test.tsx b/packages/manager/src/features/Users/UserProfile/UsernamePanel.test.tsx new file mode 100644 index 00000000000..db683423a19 --- /dev/null +++ b/packages/manager/src/features/Users/UserProfile/UsernamePanel.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { accountUserFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UsernamePanel } from './UsernamePanel'; + +describe('UsernamePanel', () => { + it("initializes the form with the user's username", async () => { + const user = accountUserFactory.build(); + + const { getByLabelText } = renderWithTheme(); + + const usernameTextField = getByLabelText('Username'); + + expect(usernameTextField).toHaveDisplayValue(user.username); + }); + + it("does not allow the user to update a proxy user's username", async () => { + const user = accountUserFactory.build({ + user_type: 'proxy', + username: 'proxy-user-1', + }); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const warning = getByLabelText('This field can’t be modified.'); + + // Verify there is a tooltip explaining that the user can't change + // a proxy user's username. + expect(warning).toBeInTheDocument(); + + // Verify the input is disabled + expect(getByLabelText('Username')).toBeDisabled(); + + // Verify save button is disabled + expect(getByText('Save').closest('button')).toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/Users/UserProfile/UsernamePanel.tsx b/packages/manager/src/features/Users/UserProfile/UsernamePanel.tsx new file mode 100644 index 00000000000..ac4f7b7a51a --- /dev/null +++ b/packages/manager/src/features/Users/UserProfile/UsernamePanel.tsx @@ -0,0 +1,85 @@ +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; + +import { Button } from 'src/components/Button/Button'; +import { Paper } from 'src/components/Paper'; +import { TextField } from 'src/components/TextField'; +import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; +import { useUpdateUserMutation } from 'src/queries/account/users'; + +import type { User } from '@linode/api-v4'; + +interface Props { + user: User; +} + +export const UsernamePanel = ({ user }: Props) => { + const history = useHistory(); + const { enqueueSnackbar } = useSnackbar(); + + const isProxyUserProfile = user?.user_type === 'proxy'; + + const { mutateAsync } = useUpdateUserMutation(user.username); + + const { + control, + formState: { isDirty, isSubmitting }, + handleSubmit, + setError, + } = useForm({ + defaultValues: { username: user.username }, + values: { username: user.username }, + }); + + const onSubmit = async (values: Partial) => { + try { + const user = await mutateAsync(values); + + // Because the username changed, we need to update the username in the URL + history.replace(`/account/users/${user.username}`); + + enqueueSnackbar('Username updated successfully', { variant: 'success' }); + } catch (error) { + setError('username', { message: error[0].reason }); + } + }; + + const tooltipForDisabledUsernameField = isProxyUserProfile + ? RESTRICTED_FIELD_TOOLTIP + : undefined; + + return ( + +
    + ( + + )} + control={control} + name="username" + /> + + +
    + ); +}; diff --git a/packages/manager/src/features/Users/UserRow.tsx b/packages/manager/src/features/Users/UserRow.tsx index 51745389d36..abe6616ddde 100644 --- a/packages/manager/src/features/Users/UserRow.tsx +++ b/packages/manager/src/features/Users/UserRow.tsx @@ -1,9 +1,12 @@ +import { useTheme } from '@mui/material/styles'; import React from 'react'; +import { Avatar } from 'src/components/Avatar/Avatar'; import { Box } from 'src/components/Box'; import { Chip } from 'src/components/Chip'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { GravatarByEmail } from 'src/components/GravatarByEmail'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Hidden } from 'src/components/Hidden'; import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; @@ -24,6 +27,7 @@ interface Props { } export const UserRow = ({ onDelete, user }: Props) => { + const theme = useTheme(); const { data: grants } = useAccountUserGrants(user.username); const { data: profile } = useProfile(); @@ -34,7 +38,19 @@ export const UserRow = ({ onDelete, user }: Props) => { - + + } + gravatar={} + /> {user.username} {user.tfa_enabled && } diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx index f4b3a9527cb..0eaf06dca68 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx @@ -10,12 +10,13 @@ import { useCreateSubnetMutation, useVPCQuery } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; import { DEFAULT_SUBNET_IPV4_VALUE, - SubnetFieldState, getRecommendedSubnetIPv4, } from 'src/utilities/subnets'; import { SubnetNode } from '../VPCCreate/SubnetNode'; +import type { SubnetFieldState } from 'src/utilities/subnets'; + interface Props { onClose: () => void; open: boolean; @@ -41,7 +42,7 @@ export const SubnetCreateDrawer = (props: Props) => { >({}); const { - isLoading, + isPending, mutateAsync: createSubnet, reset, } = useCreateSubnetMutation(vpcId); @@ -114,7 +115,7 @@ export const SubnetCreateDrawer = (props: Props) => { 'data-testid': 'create-subnet-drawer-button', disabled: !dirty || userCannotAddSubnet, label: 'Create Subnet', - loading: isLoading, + loading: isPending, onClick: onCreateSubnet, type: 'submit', }} diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx index fe1873b20f0..12d87de7eea 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx @@ -1,10 +1,11 @@ -import { Subnet } from '@linode/api-v4'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { useDeleteSubnetMutation } from 'src/queries/vpcs/vpcs'; +import type { Subnet } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -17,7 +18,7 @@ export const SubnetDeleteDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: deleteSubnet, reset, } = useDeleteSubnetMutation(vpcId, subnet?.id ?? -1); @@ -46,7 +47,7 @@ export const SubnetDeleteDialog = (props: Props) => { }} errors={error} label="Subnet Label" - loading={isLoading} + loading={isPending} onClick={onDeleteSubnet} onClose={onClose} open={open} diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx index e5b5279fbf4..28b079c9bb2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx @@ -1,5 +1,3 @@ -import { ModifySubnetPayload } from '@linode/api-v4/lib/vpcs/types'; -import { Subnet } from '@linode/api-v4/lib/vpcs/types'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -11,6 +9,8 @@ import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useUpdateSubnetMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { ModifySubnetPayload, Subnet } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -26,7 +26,7 @@ export const SubnetEditDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateSubnet, reset, } = useUpdateSubnetMutation(vpcId, subnet?.id ?? -1); @@ -92,7 +92,7 @@ export const SubnetEditDrawer = (props: Props) => { 'data-testid': 'save-button', disabled: !form.dirty, label: 'Save', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index 45c814fa7ac..2ecd40d4080 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx @@ -285,6 +285,7 @@ export const VPCSubnetsTable = (props: Props) => { label="Filter Subnets by label or id" onSearch={handleSearch} placeholder="Filter Subnets by label or id" + value={subnetsFilterText} />