diff --git a/README.md b/README.md index 1d2c50db13e..942c886ab92 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ This repository is home to the Akamai Connected **[Cloud Manager](https://cloud. ## Developing Locally -To get started running Cloud Manager locally, please see the [_Getting Started_ guide](docs/GETTING_STARTED.md). +To get started running Cloud Manager locally, please see the [Getting Started guide](https://linode.github.io/manager/GETTING_STARTED.html). ## Contributing -If you already have your development environment set up, please read the [contributing guidelines](docs/CONTRIBUTING.md) to get help in creating your first Pull Request. +If you already have your development environment set up, please read the [Contributing Guidelines](https://linode.github.io/manager/CONTRIBUTING.html) to get help in creating your first Pull Request. To report a bug or request a feature in Cloud Manager, please [open a GitHub Issue](https://github.com/linode/manager/issues/new). For general feedback, use [linode.com/feedback](https://www.linode.com/feedback/). diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1d3cf06f7aa..e4197539522 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -17,25 +17,26 @@ Feel free to open an issue to report a bug or request a feature. 5. Commit message format standard: `: [JIRA-ticket-number] - ` **Commit Types:** - `feat`: New feature for the user (not a part of the code, or ci, ...). - `fix`: Bugfix for the user (not a fix to build something, ...). - `change`: Modifying an existing visual UI instance. Such as a component or a feature. - `refactor`: Restructuring existing code without changing its external behavior or visual UI. Typically to improve readability, maintainability, and performance. - `test`: New tests or changes to existing tests. Does not change the production code. - `upcoming`: A new feature that is in progress, not visible to users yet, and usually behind a feature flag. + - `feat`: New feature for the user (not a part of the code, or ci, ...). + - `fix`: Bugfix for the user (not a fix to build something, ...). + - `change`: Modifying an existing visual UI instance. Such as a component or a feature. + - `refactor`: Restructuring existing code without changing its external behavior or visual UI. Typically to improve readability, maintainability, and performance. + - `test`: New tests or changes to existing tests. Does not change the production code. + - `upcoming`: A new feature that is in progress, not visible to users yet, and usually behind a feature flag. **Example:** `feat: [M3-1234] - Allow user to view their login history` 6. Open a pull request against `develop` and make sure the title follows the same format as the commit message. 7. If needed, create a changeset to populate our changelog - - If you don't have the Github CLI installed or need to update it (you need GH CLI 2.21.0 or greater), + - If you don't have the Github CLI installed or need to update it (you need GH CLI 2.21.0 or greater), - install it via `brew`: https://cli.github.com/manual/installation or upgrade with `brew upgrade gh` - Once installed, run `gh repo set-default` and pick `linode/manager` (only > 2.21.0) - You can also just create the changeset manually, in this case make sure to use the proper formatting for it. - Run `yarn changeset`from the root, choose the package to create a changeset for, and provide a description for the change. You can either have it committed automatically or do it manually if you need to edit it. - - A changeset is optional, it merely depends if it falls in one of the following categories: + - A changeset is optional, but should be included if the PR falls in one of the following categories:
`Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, `Tests`, `Upcoming Features` + - Select the changeset category that matches the commit type in your PR title. (Where this isn't a 1:1 match: generally, a `feat` commit type falls under an `Added` change and `refactor` falls under `Tech Stories`.) Two reviews from members of the Cloud Manager team are required before merge. After approval, all pull requests are squash merged. diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index caf87e86060..febe5b53519 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -186,9 +186,10 @@ These environment variables are specific to Cloud Manager UI tests. They can be ###### General Environment variables related to the general operation of the Cloud Manager Cypress tests. -| Environment Variable | Description | Example | Default | -|----------------------|-------------------------------------------------------------------------------------------------------|----------|---------------------------------| -| `CY_TEST_SUITE` | Name of the Cloud Manager UI test suite to run. Possible values are `core`, `region`, or `synthetic`. | `region` | Unset; defaults to `core` suite | +| Environment Variable | Description | Example | Default | +|----------------------|-------------------------------------------------------------------------------------------------------|--------------|---------------------------------| +| `CY_TEST_SUITE` | Name of the Cloud Manager UI test suite to run. Possible values are `core`, `region`, or `synthetic`. | `region` | Unset; defaults to `core` suite | +| `CY_TEST_TAGS` | Query identifying tests that should run by specifying allowed and disallowed tags. | `method:e2e` | Unset; all tests run by default | ###### Regions These environment variables are used by Cloud Manager's UI tests to override region selection behavior. This can be useful for testing Cloud Manager functionality against a specific region. diff --git a/docs/development-guide/15-api-events.md b/docs/development-guide/15-api-events.md index 8e6e2532c97..7a7f9f3cc0f 100644 --- a/docs/development-guide/15-api-events.md +++ b/docs/development-guide/15-api-events.md @@ -7,7 +7,7 @@ In order to display these messages in the application (Notification Center, /eve ## Adding a new Action and Composing Messages In order to add a new Action, one must add a new key to the read-only `EventActionKeys` constant array in the api-v4 package. -Once that's done, a related entry must be added to the `eventMessages` Event Map. In order to do so, the entry can either be added to an existing Event Factory or a new one. `eventMessages` is strictly typed, so the decision where to add the new Action will be clear. ex: +Once that's done, a related entry must be added to the `eventMessages` Event Map. In order to do so, the entry can either be added to an existing Event Factory or a new one. `eventMessages` is strictly typed, so the action needs to be added to an existing factory or a new one, depending on its key (in this example the action starts with `linode_` so it belongs in the `linode.tsx` factory): ```Typescript import { EventLink } from '../EventLink'; @@ -32,6 +32,41 @@ The convention to compose the message is as follows: - the primary action: (ex: `created`) - its correlated negation for negative actions (ex: `could not be created.`) - The `message` should be also handled via the `` in order to handle potential formatting from the API string (ticks to indicate code blocks). +- The message composition can be enhanced by using custom components. For instance, if we need to fetch extra data based on an event entity, we can simply write a new component to include in the message: + +```Typescript +export const linode: PartialEventMap<'linode'> = { + linode_migrate_datacenter: { + started: (e) => , + }, +}; + +const LinodeMigrateDataCenterMessage = ({ event }: { event: Event }) => { + const { data: linode } = useLinodeQuery(event.entity?.id ?? -1); + const { data: regions } = useRegionsQuery(); + const region = regions?.find((r) => r.id === linode?.region); + + return ( + <> + Linode is being{' '} + migrated + {region && ( + <> + {' '} + to {region.label} + + )} + . + + ); +}; +``` + +## In Progress Events + +Some event messages are meant to be displayed alongside a progress bar to show the user the percentage of the action's completion. When an action is in progress, the polling interval switches to every two seconds to provide real-time feedback. + +Despite receiving a `percent_complete` value from the API, not all actions are suitable for displaying visual progress, often because they're too short, or we only receive 0% and 100% from the endpoint. To allow only certain events to feature the progress bar, their action keys must be added to the `ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS` constant. ## Displaying Events in snackbars diff --git a/package.json b/package.json index 4211ec10959..2804917ccba 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "npm-run-all": "^4.1.5", "patch-package": "^7.0.0", "postinstall": "^0.6.0", - "typescript": "^4.9.5" + "typescript": "^5.4.5" }, "husky": { "hooks": { diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 002c7d2f8d3..75ab194a0c6 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,5 +1,12 @@ -## [2024-06-10] - v0.119.0 +## [2024-06-24] - v0.120.0 + +### Added: +- New endpoint for LKE HA types used in pricing ([#10505](https://github.com/linode/manager/pull/10505)) +- UpdateImagePayload type ([#10514](https://github.com/linode/manager/pull/10514)) +- New endpoint for `network-transfer/prices` ([#10566](https://github.com/linode/manager/pull/10566)) + +## [2024-06-10] - v0.119.0 ### Added: @@ -19,7 +26,6 @@ ## [2024-05-28] - v0.118.0 - ### Added: - New LKE events in `EventAction` type ([#10443](https://github.com/linode/manager/pull/10443)) @@ -28,10 +34,8 @@ - Add Disk Encryption to AccountCapability type and region Capabilities type ([#10462](https://github.com/linode/manager/pull/10462)) - ## [2024-05-13] - v0.117.0 - ### Added: - 'edge' Linode type class ([#10415](https://github.com/linode/manager/pull/10415)) @@ -46,17 +50,14 @@ - Update Placement Group event types ([#10420](https://github.com/linode/manager/pull/10420)) - ## [2024-05-06] - v0.116.0 - ### Added: - 'edge' Linode type class ([#10441](https://github.com/linode/manager/pull/10441)) ## [2024-04-29] - v0.115.0 - ### Added: - New endpoint for `volumes/types` ([#10376](https://github.com/linode/manager/pull/10376)) diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 71217ceb004..99d23eaffec 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.119.0", + "version": "0.120.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -67,7 +67,7 @@ "lint-staged": "^13.2.2", "prettier": "~2.2.1", "tsup": "^7.2.0", - "vitest": "^1.3.1" + "vitest": "^1.6.0" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index c625a158a85..b012fe396fa 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -17,6 +17,7 @@ import type { CreateImagePayload, Image, ImageUploadPayload, + UpdateImagePayload, UploadImageResponse, } from './types'; @@ -58,21 +59,9 @@ export const createImage = (data: CreateImagePayload) => { * Updates a private Image that you have permission to read_write. * * @param imageId { string } ID of the Image to look up. - * @param label { string } A short description of the Image. Labels cannot contain special characters. - * @param description { string } A detailed description of this Image. + * @param data { UpdateImagePayload } the updated image details */ -export const updateImage = ( - imageId: string, - label?: string, - description?: string, - tags?: string[] -) => { - const data = { - ...(label && { label }), - ...(description && { description }), - ...(tags && { tags }), - }; - +export const updateImage = (imageId: string, data: UpdateImagePayload) => { return Request( setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}`), setMethod('PUT'), diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index dd034eda126..e25fb28f9a2 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,7 +4,7 @@ export type ImageStatus = | 'deleted' | 'pending_upload'; -type ImageCapabilities = 'cloud-init' | 'distributed-images'; +export type ImageCapabilities = 'cloud-init' | 'distributed-images'; type ImageType = 'manual' | 'automatic'; @@ -148,6 +148,8 @@ export interface CreateImagePayload extends BaseImagePayload { disk_id: number; } +export type UpdateImagePayload = Omit; + export interface ImageUploadPayload extends BaseImagePayload { label: string; region: string; diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index b62ab50d817..8de3cbfcf6f 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -22,6 +22,8 @@ export * from './managed'; export * from './networking'; +export * from './network-transfer'; + export * from './nodebalancers'; export * from './object-storage'; diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 8d62051c137..b80c011d4cf 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -7,8 +7,8 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { +import type { Filter, Params, ResourcePage as Page, PriceType } from '../types'; +import type { CreateKubeClusterPayload, KubeConfigResponse, KubernetesCluster, @@ -180,3 +180,15 @@ export const recycleClusterNodes = (clusterID: number) => setMethod('POST'), setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/recycle`) ); + +/** + * getKubernetesTypes + * + * Returns a paginated list of available Kubernetes types; used for dynamic pricing. + */ +export const getKubernetesTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/lke/types`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/api-v4/src/network-transfer/index.ts b/packages/api-v4/src/network-transfer/index.ts new file mode 100644 index 00000000000..19729308f7c --- /dev/null +++ b/packages/api-v4/src/network-transfer/index.ts @@ -0,0 +1 @@ +export * from './prices'; diff --git a/packages/api-v4/src/network-transfer/prices.ts b/packages/api-v4/src/network-transfer/prices.ts new file mode 100644 index 00000000000..ccb0233bb75 --- /dev/null +++ b/packages/api-v4/src/network-transfer/prices.ts @@ -0,0 +1,10 @@ +import { API_ROOT } from 'src/constants'; +import Request, { setMethod, setURL, setParams } from 'src/request'; +import { Params, PriceType, ResourcePage } from 'src/types'; + +export const getNetworkTransferPrices = (params?: Params) => + Request>( + setURL(`${API_ROOT}/network-transfer/prices`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 30329f4e8f8..5df1994d2d9 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -47,6 +47,6 @@ export interface RegionAvailability { region: string; } -type ContinentCode = keyof typeof COUNTRY_CODE_TO_CONTINENT_CODE; +type CountryCode = keyof typeof COUNTRY_CODE_TO_CONTINENT_CODE; -export type Country = Lowercase; +export type Country = Lowercase; diff --git a/packages/manager/.changeset/pr-10555-upcoming-features-1717778847157.md b/packages/manager/.changeset/pr-10555-upcoming-features-1717778847157.md deleted file mode 100644 index 607765079fb..00000000000 --- a/packages/manager/.changeset/pr-10555-upcoming-features-1717778847157.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Obj fix for crashing accesskey page when relevant customer tags are not added ([#10555](https://github.com/linode/manager/pull/10555)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index ac4dd7d6d8a..220f3ccf276 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,66 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-06-24] - v1.122.0 + +### Added: + +- Informational notice about capturing an image from a Linode in a distributed compute region ([#10544](https://github.com/linode/manager/pull/10544)) +- Volume & Images landing pages search and filtering ([#10570](https://github.com/linode/manager/pull/10570)) +- Standard Tax Rate for JP ([#10606](https://github.com/linode/manager/pull/10606)) +- B2B Tax ID for EU ([#10606](https://github.com/linode/manager/pull/10606)) + +### Changed: + +- Rename to 'Choose a Distribution' to 'Choose an OS' in Linode Create flow ([#10554](https://github.com/linode/manager/pull/10554)) +- Use dynamic outbound transfer pricing with `network-transfer/prices` endpoint ([#10566](https://github.com/linode/manager/pull/10566)) +- Link Cloud Manager README to new documentation pages ([#10582](https://github.com/linode/manager/pull/10582)) +- Use dynamic HA pricing with `lke/types` endpoint ([#10505](https://github.com/linode/manager/pull/10505)) + +### Fixed: + +- Marketplace docs urls for Apache Kafka Cluster and Couchbase Cluster ([#10569](https://github.com/linode/manager/pull/10569)) +- Users must be an unrestricted User in order to add or modify tags on Linodes ([#10583](https://github.com/linode/manager/pull/10583)) +- CONTRIBUTING doc page commit type list markup ([#10587](https://github.com/linode/manager/pull/10587)) +- React Query Events `seen` behavior and other optimizations ([#10588](https://github.com/linode/manager/pull/10588)) +- Accessibility: Add tabindex to TextTooltip ([#10590](https://github.com/linode/manager/pull/10590)) +- Fix parsing issue causing in Kubernetes Version field ([#10597](https://github.com/linode/manager/pull/10597)) + +### Tech Stories: + +- Refactor and clean up ImagesDrawer ([#10514](https://github.com/linode/manager/pull/10514)) +- Event Messages Refactor: progress events ([#10550](https://github.com/linode/manager/pull/10550)) +- NodeBalancer Query Key Factory ([#10556](https://github.com/linode/manager/pull/10556)) +- Query Key Factory for Domains ([#10559](https://github.com/linode/manager/pull/10559)) +- Upgrade Vitest and related dependencies to 1.6.0 ([#10561](https://github.com/linode/manager/pull/10561)) +- Query Key Factory for Firewalls ([#10568](https://github.com/linode/manager/pull/10568)) +- Update TypeScript to latest ([#10573](https://github.com/linode/manager/pull/10573)) + +### Tests: + +- Cypress integration test to add SSH key via Profile page ([#10477](https://github.com/linode/manager/pull/10477)) +- Add assertions regarding Disk Encryption info banner to lke-landing-page.spec.ts ([#10546](https://github.com/linode/manager/pull/10546)) +- Add Placement Group navigation integration tests ([#10552](https://github.com/linode/manager/pull/10552)) +- Improve Cypress test suite compatibility against alternative environments ([#10562](https://github.com/linode/manager/pull/10562)) +- Improve stability of StackScripts pagination test ([#10574](https://github.com/linode/manager/pull/10574)) +- Fix Linode/Firewall related E2E test flake ([#10581](https://github.com/linode/manager/pull/10581)) +- Mock profile request to improve security questions test stability ([#10585](https://github.com/linode/manager/pull/10585)) +- Fix hanging unit tests ([#10591](https://github.com/linode/manager/pull/10591)) +- Unit test coverage - HostNameTableCell ([#10596](https://github.com/linode/manager/pull/10596)) + +### Upcoming Features: + +- Resources MultiSelect component in cloudpulse global filters view ([#10539](https://github.com/linode/manager/pull/10539)) +- Add Disk Encryption info banner to Kubernetes landing page ([#10546](https://github.com/linode/manager/pull/10546)) +- Add Disk Encryption section to Linode Rebuild modal ([#10549](https://github.com/linode/manager/pull/10549)) +- Obj fix for crashing accesskey page when relevant customer tags are not added ([#10555](https://github.com/linode/manager/pull/10555)) +- Linode Create v2 - Handle side-effects when changing the Region ([#10564](https://github.com/linode/manager/pull/10564)) +- Revise LDE copy in Linode Create flow when Distributed region is selected ([#10576](https://github.com/linode/manager/pull/10576)) +- Update description for Add Node Pools section in LKE Create flow ([#10578](https://github.com/linode/manager/pull/10578)) +- Linode Create v2 - Add Marketplace Searching / Filtering ([#10586](https://github.com/linode/manager/pull/10586)) +- Add Distributed Icon to ImageSelects for distributed compatible images ([#10592](https://github.com/linode/manager/pull/10592) +- Update Images Landing table ([#10545](https://github.com/linode/manager/pull/10545)) + ## [2024-06-21] - v1.121.2 ### Fixed: @@ -75,22 +135,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Refactor Event Messages ([#10517](https://github.com/linode/manager/pull/10517)) - Fix regions length check in HostNameTableCell ([#10519](https://github.com/linode/manager/pull/10519)) - Linode Create Refactor: - - Marketplace App Sections ([#10520](https://github.com/linode/manager/pull/10520)) - - Disk Encryption ([#10535](https://github.com/linode/manager/pull/10535) + - Marketplace App Sections ([#10520](https://github.com/linode/manager/pull/10520)) + - Disk Encryption ([#10535](https://github.com/linode/manager/pull/10535) - Add warning notices regarding non-encryption when creating Images and enabling Backups ([#10521](https://github.com/linode/manager/pull/10521)) - Add Encrypted / Not Encrypted status to Linode Detail header ([#10537](https://github.com/linode/manager/pull/10537)) - ## [2024-05-29] - v1.120.1 - ### Fixed: - Tooltip not closing when unhovered ([#10523](https://github.com/linode/manager/pull/10523)) ## [2024-05-28] - v1.120.0 - ### Added: - Event message handling for new LKE event types ([#10443](https://github.com/linode/manager/pull/10443)) @@ -141,10 +198,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add Disk Encryption section to Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) - Reset errors in PlacementGroupDeleteModal ([#10486](https://github.com/linode/manager/pull/10486)) - ## [2024-05-13] - v1.119.0 - ### Changed: - Update Account Closure Dialog Wording ([#10406](https://github.com/linode/manager/pull/10406)) @@ -181,10 +236,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add dialog to refresh proxy tokens as time expires ([#10361](https://github.com/linode/manager/pull/10361)) - Update Placement Groups text copy ([#10399](https://github.com/linode/manager/pull/10399)) - Linode Create Refactor: - - Marketplace - Part 1 ([#10401](https://github.com/linode/manager/pull/10401)) - - Backups (#10404) - - Marketplace - Part 2 (#10419) - - Cloning ([#10421](https://github.com/linode/manager/pull/10421)) + - Marketplace - Part 1 ([#10401](https://github.com/linode/manager/pull/10401)) + - Backups (#10404) + - Marketplace - Part 2 (#10419) + - Cloning ([#10421](https://github.com/linode/manager/pull/10421)) - Update Placement Group Table Row linodes tooltip and SelectPlacementGroup option label ([#10408](https://github.com/linode/manager/pull/10408)) - Add content to the ResourcesSection of the PG landing page in empty state ([#10411](https://github.com/linode/manager/pull/10411)) - Use 'edge'-class plans in edge regions ([#10415](https://github.com/linode/manager/pull/10415)) @@ -197,17 +252,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Update Placement Groups maximum_pgs_per_customer UI (#10433) - Add DiskEncryption component ([#10439](https://github.com/linode/manager/pull/10439)) - ## [2024-05-06] - v1.118.1 - ### Upcoming Features: - Use 'edge'-class plans in edge regions ([#10441](https://github.com/linode/manager/pull/10441)) ## [2024-04-29] - v1.118.0 - ### Added: - April Marketplace apps and SVGs ([#10382](https://github.com/linode/manager/pull/10382)) @@ -247,7 +299,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add tests for Parent/Child Users & Grants page ([#10240](https://github.com/linode/manager/pull/10240)) - Add new Cypress tests for Longview landing page ([#10321](https://github.com/linode/manager/pull/10321)) - Add VM Placement Group landing page empty state UI test ([#10350](https://github.com/linode/manager/pull/10350)) -- Fix `machine-image-upload.spec.ts` e2e test flake ([#10370](https://github.com/linode/manager/pull/10370)) +- Fix `machine-image-upload.spec.ts` e2e test flake ([#10370](https://github.com/linode/manager/pull/10370)) - Update latest kernel version to fix `linode-config.spec.ts` ([#10391](https://github.com/linode/manager/pull/10391)) - Fix hanging account switching test ([#10396](https://github.com/linode/manager/pull/10396)) @@ -259,10 +311,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Update the Placement Groups SVG icon ([#10379](https://github.com/linode/manager/pull/10379)) - Fix & Improve Placement Groups feature restriction ([#10372](https://github.com/linode/manager/pull/10372)) - Linode Create Refactor: - - VPC (#10354) - - StackScripts (#10367) - - Validation (#10374) - - User Defined Fields ([#10395](https://github.com/linode/manager/pull/10395)) + - VPC (#10354) + - StackScripts (#10367) + - Validation (#10374) + - User Defined Fields ([#10395](https://github.com/linode/manager/pull/10395)) - Update gecko feature flag to object ([#10363](https://github.com/linode/manager/pull/10363)) - Show the selected regions as chips in the AccessKeyDrawer ([#10375](https://github.com/linode/manager/pull/10375)) - Add feature flag for Linode Disk Encryption (LDE) ([#10402](https://github.com/linode/manager/pull/10402)) @@ -308,12 +360,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Refactor account switching utils for reusability and automatic token refreshing ([#10323](https://github.com/linode/manager/pull/10323)) - Update Placement Groups detail and summaries ([#10325](https://github.com/linode/manager/pull/10325)) - Update and clean up Placement Group assign/unassign features (#10328) -- Update navigation and add new menu items for Placement Groups ([#10340](https://github.com/linode/manager/pull/10340)) +- Update navigation and add new menu items for Placement Groups ([#10340](https://github.com/linode/manager/pull/10340)) - Update UI for Region Placement Groups Limits type changes ([#10343](https://github.com/linode/manager/pull/10343)) - Linode Create Refactor: - User Data ([#10331](https://github.com/linode/manager/pull/10331)) - Summary ([#10334](https://github.com/linode/manager/pull/10334)) - - VLANs ([#10342](https://github.com/linode/manager/pull/10342)) + - VLANs ([#10342](https://github.com/linode/manager/pull/10342)) - Include powered-off status in Clone Linode event ([#10337](https://github.com/linode/manager/pull/10337)) ## [2024-04-08] - v1.116.1 @@ -374,7 +426,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2024-03-18] - v1.115.0 - ### Added: - Invoice byline for powered down instances ([#10208](https://github.com/linode/manager/pull/10208)) @@ -431,7 +482,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add Parent/Child Account copy and account management improvements ([#10270](https://github.com/linode/manager/pull/10270)) - Improve Proxy Account Visibility with Distinct Visual Indicators ([#10277](https://github.com/linode/manager/pull/10277)) - ## [2024-03-04] - v1.114.0 ### Added: diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index ff201ae504e..1c7efb41af8 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -14,6 +14,7 @@ import { fetchAccount } from './cypress/support/plugins/fetch-account'; import { fetchLinodeRegions } from './cypress/support/plugins/fetch-linode-regions'; import { splitCypressRun } from './cypress/support/plugins/split-run'; import { enableJunitReport } from './cypress/support/plugins/junit-report'; +import { logTestTagInfo } from './cypress/support/plugins/test-tagging-info'; /** * Exports a Cypress configuration object. @@ -66,6 +67,7 @@ export default defineConfig({ fetchAccount, fetchLinodeRegions, regionOverrideCheck, + logTestTagInfo, splitCypressRun, enableJunitReport, ]); diff --git a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts index c11b1121398..aab84a220d3 100644 --- a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts @@ -2,8 +2,10 @@ * @file Integration tests for account security questions. */ +import { profileFactory } from 'src/factories/profile'; import { securityQuestionsFactory } from 'src/factories/profile'; import { + mockGetProfile, mockGetSecurityQuestions, mockUpdateSecurityQuestions, } from 'support/intercepts/profile'; @@ -117,6 +119,10 @@ describe('Account security questions', () => { const securityQuestions = securityQuestionsFactory.build(); const securityQuestionAnswers = ['Answer 1', 'Answer 2', 'Answer 3']; + const mockProfile = profileFactory.build({ + two_factor_auth: false, + }); + const securityQuestionsPayload = { security_questions: [ { question_id: 1, response: securityQuestionAnswers[0] }, @@ -128,6 +134,7 @@ describe('Account security questions', () => { const tfaSecurityQuestionsWarning = 'To use two-factor authentication you must set up your security questions listed below.'; + mockGetProfile(mockProfile); mockGetSecurityQuestions(securityQuestions).as('getSecurityQuestions'); mockUpdateSecurityQuestions(securityQuestionsPayload).as( 'setSecurityQuestions' diff --git a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts index 9268d097c88..71428f889ea 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -175,7 +175,7 @@ describe('Account service transfers', () => { cy.wait(['@getTransfers', '@getTransfers', '@getTransfers']); // Confirm that pending transfers are displayed in "Pending Service Transfers" panel. - cy.defer(getProfile(), 'getting profile').then((profile: Profile) => { + cy.defer(() => getProfile(), 'getting profile').then((profile: Profile) => { const dateFormatOptions = { timezone: profile.timezone }; cy.get('[data-qa-panel="Pending Service Transfers"]') .should('be.visible') @@ -262,7 +262,7 @@ describe('Account service transfers', () => { return linode; }; - cy.defer(setupLinode(), 'creating and booting Linode').then( + cy.defer(() => setupLinode(), 'creating and booting Linode').then( (linode: Linode) => { interceptInitiateEntityTransfer().as('initiateTransfer'); diff --git a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts new file mode 100644 index 00000000000..59b34a59101 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts @@ -0,0 +1,172 @@ +import { sshKeyFactory } from 'src/factories'; +import { + mockCreateSSHKey, + mockCreateSSHKeyError, + mockGetSSHKeys, +} from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { randomLabel, randomString } from 'support/util/random'; +import { sshFormatErrorMessage } from 'support/constants/account'; + +describe('SSH keys', () => { + /* + * - Vaildates SSH key creation flow using mock data. + * - Confirms that the drawer opens when clicking. + * - Confirms that a form validation error appears when the label or public key is not present. + * - Confirms UI flow when user enters incorrect public key. + * - Confirms UI flow when user clicks "Cancel". + * - Confirms UI flow when user creates a new SSH key. + */ + it('adds an SSH key via Profile page as expected', () => { + const randomKey = randomString(400, { + uppercase: true, + lowercase: true, + numbers: true, + spaces: false, + symbols: false, + }); + const mockSSHKey = sshKeyFactory.build({ + label: randomLabel(), + ssh_key: `ssh-rsa e2etestkey${randomKey} e2etest@linode`, + }); + + mockGetSSHKeys([]).as('getSSHKeys'); + + // Navigate to SSH key landing page, click the "Add an SSH Key" button. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + // When a user clicks "Add an SSH Key" button on SSH key landing page (/profile/keys), the "Add an SSH Key" drawer opens + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + // When a user tries to create an SSH key without a label, a form validation error appears + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Label is required.'); + + // When a user tries to create an SSH key without the SSH Public Key, a form validation error appears + cy.get('[id="label"]').clear().type(mockSSHKey.label); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findAllByText(sshFormatErrorMessage).should('be.visible'); + + // An alert displays when the format of SSH key is incorrect + cy.get('[id="ssh-public-key"]').clear().type('WrongFormatSshKey'); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findAllByText(sshFormatErrorMessage).should('be.visible'); + + cy.get('[id="ssh-public-key"]').clear().type(mockSSHKey.ssh_key); + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // No new key is added when cancelling. + cy.findAllByText(mockSSHKey.label).should('not.exist'); + + mockGetSSHKeys([mockSSHKey]).as('getSSHKeys'); + mockCreateSSHKey(mockSSHKey).as('createSSHKey'); + + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + // When a user clicks "Cancel" or the drawer's close button, and then clicks "Add an SSH Key" again, the content they previously entered into the form is erased + cy.get('[id="label"]').should('be.empty'); + cy.get('[id="ssh-public-key"]').should('be.empty'); + + // Create a new ssh key + cy.get('[id="label"]').clear().type(mockSSHKey.label); + cy.get('[id="ssh-public-key"]').clear().type(mockSSHKey.ssh_key); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@getSSHKeys'); + + // When a user creates an SSH key, a toast notification appears that says "Successfully created SSH key." + ui.toast.assertMessage('Successfully created SSH key.'); + + // When a user creates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user + cy.findAllByText(mockSSHKey.label).should('be.visible'); + }); + + /* + * - Vaildates SSH key creation error flow using mock data. + * - Confirms that a useful error message is displayed on the form when receiving an API response error. + */ + it('shows an error message when fail to add an SSH key', () => { + const errorMessage = 'failed to add an SSH key.'; + const sshKeyLabel = randomLabel(); + const randomKey = randomString(400, { + uppercase: true, + lowercase: true, + numbers: true, + spaces: false, + symbols: false, + }); + const sshPublicKey = `ssh-rsa e2etestkey${randomKey} e2etest@linode`; + + mockCreateSSHKeyError(errorMessage).as('createSSHKeyError'); + mockGetSSHKeys([]).as('getSSHKeys'); + + // Navigate to SSH key landing page, click the "Add an SSH Key" button. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + // When a user clicks "Add an SSH Key" button on SSH key landing page (/profile/keys), the "Add an SSH Key" drawer opens + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + // When a user clicks "Cancel" or the drawer's close button, and then clicks "Add an SSH Key" again, the content they previously entered into the form is erased + cy.get('[id="label"]').should('be.empty'); + cy.get('[id="ssh-public-key"]').should('be.empty'); + + // Create a new ssh key + cy.get('[id="label"]').clear().type(sshKeyLabel); + cy.get('[id="ssh-public-key"]').clear().type(sshPublicKey); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createSSHKeyError'); + + // When the API responds with an error (e.g. a 400 response), the API response error message is displayed on the form + cy.findByText(errorMessage); + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts index 153f8c85458..9e918bf9dcd 100644 --- a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts @@ -38,7 +38,7 @@ describe('Third party access tokens', () => { .closest('tr') .within(() => { cy.findByText(token.label).should('be.visible'); - cy.defer(getProfile()).then((profile: Profile) => { + cy.defer(() => getProfile()).then((profile: Profile) => { const dateFormatOptions = { timezone: profile.timezone }; cy.findByText(formatDate(token.created, dateFormatOptions)).should( 'be.visible' diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index f130dae258c..1cdcbcb1b6c 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -162,7 +162,7 @@ describe('Billing Activity Feed', () => { mockGetPayments(paymentMocks6Months).as('getPayments'); mockGetPaymentMethods([]); - cy.defer(getProfile()).then((profile: Profile) => { + cy.defer(() => getProfile()).then((profile: Profile) => { const timezone = profile.timezone; cy.visitWithLogin('/account/billing'); cy.wait(['@getInvoices', '@getPayments']); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts index f85218804e7..d45a2bf886d 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts @@ -34,7 +34,7 @@ describe('Clone a Domain', () => { const domainRecords = createDomainRecords(); - cy.defer(createDomain(domainRequest), 'creating domain').then( + cy.defer(() => createDomain(domainRequest), 'creating domain').then( (domain: Domain) => { // Add records to the domain. cy.visitWithLogin(`/domains/${domain.id}`); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts index 6a992e26b70..80d9b632aa2 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts @@ -20,7 +20,7 @@ describe('Delete a Domain', () => { group: 'test-group', }); - cy.defer(createDomain(domainRequest), 'creating domain').then( + cy.defer(() => createDomain(domainRequest), 'creating domain').then( (domain: Domain) => { cy.visitWithLogin('/domains'); diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts index 298417a1a72..4da8d8c2dab 100644 --- a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts @@ -75,7 +75,7 @@ describe('create firewall', () => { }; cy.defer( - createTestLinode(linodeRequest, { securityMethod: 'powered_off' }), + () => createTestLinode(linodeRequest, { securityMethod: 'powered_off' }), 'creating Linode' ).then((linode) => { interceptCreateFirewall().as('createFirewall'); diff --git a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts index 323a42cd398..2cbedb29e5f 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -23,7 +23,7 @@ describe('delete firewall', () => { label: randomLabel(), }); - cy.defer(createFirewall(firewallRequest), 'creating firewalls').then( + cy.defer(() => createFirewall(firewallRequest), 'creating firewalls').then( (firewall: Firewall) => { cy.visitWithLogin('/firewalls'); diff --git a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts index 2ff4bd31f67..67b940c3b1c 100644 --- a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts @@ -144,7 +144,7 @@ describe('Migrate Linode With Firewall', () => { interceptGetFirewalls().as('getFirewalls'); // Create a Linode, then navigate to the Firewalls landing page. - cy.defer( + cy.defer(() => createTestLinode(linodePayload, { securityMethod: 'powered_off' }) ).then((linode: Linode) => { interceptMigrateLinode(linode.id).as('migrateLinode'); diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index dbbde69165f..9ca48ad7fe0 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -196,7 +196,7 @@ describe('update firewall', () => { }); cy.defer( - createLinodeAndFirewall(linodeRequest, firewallRequest), + () => createLinodeAndFirewall(linodeRequest, firewallRequest), 'creating Linode and firewall' ).then(([linode, firewall]) => { cy.visitWithLogin('/firewalls'); @@ -324,7 +324,7 @@ describe('update firewall', () => { }); cy.defer( - createLinodeAndFirewall(linodeRequest, firewallRequest), + () => createLinodeAndFirewall(linodeRequest, firewallRequest), 'creating Linode and firewall' ).then(([_linode, firewall]) => { cy.visitWithLogin('/firewalls'); @@ -420,7 +420,7 @@ describe('update firewall', () => { const newFirewallLabel = randomLabel(); cy.defer( - createLinodeAndFirewall(linodeRequest, firewallRequest), + () => createLinodeAndFirewall(linodeRequest, firewallRequest), 'creating Linode and firewall' ).then(([_linode, firewall]) => { cy.visitWithLogin('/firewalls'); diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index fdffbf581d4..15782fb004e 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -61,7 +61,7 @@ describe('create image (e2e)', () => { const disk = 'Alpine 3.19 Disk'; cy.defer( - createTestLinode({ image }, { waitForDisks: true }), + () => createTestLinode({ image }, { waitForDisks: true }), 'create linode' ).then((linode: Linode) => { cy.visitWithLogin('/images/create'); diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts new file mode 100644 index 00000000000..9620a2312e7 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -0,0 +1,79 @@ +import { createImage } from '@linode/api-v4/lib/images'; +import { createTestLinode } from 'support/util/linodes'; +import { ui } from 'support/ui'; + +import { authenticate } from 'support/api/authentication'; +import { randomLabel } from 'support/util/random'; +import { cleanUp } from 'support/util/cleanup'; +import type { Image, Linode } from '@linode/api-v4'; +import { interceptGetLinodeDisks } from 'support/intercepts/linodes'; + +authenticate(); +describe('Search Images', () => { + before(() => { + cleanUp(['linodes', 'images']); + }); + + /* + * - Confirm that images are API searchable and filtered in the UI. + */ + it('creates two images and make sure they show up in the table and are searchable', () => { + cy.defer( + () => + createTestLinode( + { image: 'linode/debian10', region: 'us-east' }, + { waitForDisks: true } + ), + 'create linode' + ).then((linode: Linode) => { + interceptGetLinodeDisks(linode.id).as('getLinodeDisks'); + + cy.visitWithLogin(`/linodes/${linode.id}/storage`); + cy.wait('@getLinodeDisks').then((xhr) => { + const disks = xhr.response?.body.data; + const disk_id = disks[0].id; + + const createTwoImages = async (): Promise<[Image, Image]> => { + return Promise.all([ + createImage({ + disk_id, + label: randomLabel(), + }), + createImage({ + disk_id, + label: randomLabel(), + }), + ]); + }; + + cy.defer(() => createTwoImages(), 'creating images').then( + ([image1, image2]) => { + cy.visitWithLogin('/images'); + + // Confirm that both images are listed on the landing page. + cy.contains(image1.label).should('be.visible'); + cy.contains(image2.label).should('be.visible'); + + // Search for the first image by label, confirm it's the only one shown. + cy.findByPlaceholderText('Search Images').type(image1.label); + expect(cy.contains(image1.label).should('be.visible')); + expect(cy.contains(image2.label).should('not.exist')); + + // Clear search, confirm both images are shown. + cy.findByTestId('clear-images-search').click(); + cy.contains(image1.label).should('be.visible'); + cy.contains(image2.label).should('be.visible'); + + // Use the main search bar to search and filter images + cy.get('[id="main-search"').type(image2.label); + ui.autocompletePopper.findByTitle(image2.label).click(); + + // Confirm that only the second image is shown. + cy.contains(image1.label).should('not.exist'); + cy.contains(image2.label).should('be.visible'); + } + ); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index 7ca34e9d429..fd495ecc0c0 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -266,7 +266,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { ui.regionSelect.find().type(`${dcSpecificPricingRegion.label}{enter}`); // Confirm that HA price updates dynamically once region selection is made. - cy.contains(/\(\$.*\/month\)/).should('be.visible'); + cy.contains(/\$.*\/month/).should('be.visible'); cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index a45cb165cb3..24c7fcbfeb0 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -4,12 +4,76 @@ import { mockGetClusterPools, mockGetKubeconfig, } from 'support/intercepts/lke'; -import { kubernetesClusterFactory, nodePoolFactory } from 'src/factories'; +import { + accountFactory, + kubernetesClusterFactory, + nodePoolFactory, +} from 'src/factories'; import { getRegionById } from 'support/util/regions'; import { readDownload } from 'support/util/downloads'; import { ui } from 'support/ui'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; describe('LKE landing page', () => { + it('does not display a Disk Encryption info banner if the LDE feature is disabled', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock responses + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockCluster = kubernetesClusterFactory.build(); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetClusters([mockCluster]).as('getClusters'); + + // Intercept request + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getClusters', '@getAccount']); + + // Wait for page to load before confirming that banner is not present. + cy.findByText(mockCluster.label).should('be.visible'); + cy.findByText('Disk encryption is now standard on Linodes.').should( + 'not.exist' + ); + }); + + it('displays a Disk Encryption info banner if the LDE feature is enabled', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock responses + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + const mockClusters = kubernetesClusterFactory.buildList(3); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetClusters(mockClusters).as('getClusters'); + + // Intercept request + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getClusters', '@getAccount']); + + // Check if banner is visible + cy.contains('Disk encryption is now standard on Linodes.').should( + 'be.visible' + ); + }); + /* * - Confirms that LKE clusters are listed on landing page. */ diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index 4fd976daa48..f973a37651d 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -53,52 +53,53 @@ describe('linode backups', () => { booted: false, }); - cy.defer(createTestLinode(createLinodeRequest), 'creating Linode').then( - (linode: Linode) => { - interceptGetLinode(linode.id).as('getLinode'); - interceptEnableLinodeBackups(linode.id).as('enableBackups'); - - // Navigate to Linode details page "Backups" tab. - cy.visitWithLogin(`linodes/${linode.id}/backup`); - cy.wait('@getLinode'); - - // Wait for Linode to finish provisioning. - cy.findByText('OFFLINE').should('be.visible'); - - // Confirm that enable backups prompt is shown. - cy.contains( - 'Three backup slots are executed and rotated automatically' - ).should('be.visible'); - - ui.button - .findByTitle('Enable Backups') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Enable backups?') - .should('be.visible') - .within(() => { - // Confirm that user is warned of additional backup charges. - cy.contains(/.* This will add .* to your monthly bill\./).should( - 'be.visible' - ); - ui.button - .findByTitle('Enable Backups') - .should('be.visible') - .should('be.enabled') - .click(); - }); + cy.defer( + () => createTestLinode(createLinodeRequest), + 'creating Linode' + ).then((linode: Linode) => { + interceptGetLinode(linode.id).as('getLinode'); + interceptEnableLinodeBackups(linode.id).as('enableBackups'); + + // Navigate to Linode details page "Backups" tab. + cy.visitWithLogin(`linodes/${linode.id}/backup`); + cy.wait('@getLinode'); + + // Wait for Linode to finish provisioning. + cy.findByText('OFFLINE').should('be.visible'); + + // Confirm that enable backups prompt is shown. + cy.contains( + 'Three backup slots are executed and rotated automatically' + ).should('be.visible'); + + ui.button + .findByTitle('Enable Backups') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Enable backups?') + .should('be.visible') + .within(() => { + // Confirm that user is warned of additional backup charges. + cy.contains(/.* This will add .* to your monthly bill\./).should( + 'be.visible' + ); + ui.button + .findByTitle('Enable Backups') + .should('be.visible') + .should('be.enabled') + .click(); + }); - // Confirm that toast notification appears and UI updates to reflect enabled backups. - cy.wait('@enableBackups'); - ui.toast.assertMessage('Backups are being enabled for this Linode.'); - cy.findByText( - 'Automatic and manual backups will be listed here' - ).should('be.visible'); - } - ); + // Confirm that toast notification appears and UI updates to reflect enabled backups. + cy.wait('@enableBackups'); + ui.toast.assertMessage('Backups are being enabled for this Linode.'); + cy.findByText('Automatic and manual backups will be listed here').should( + 'be.visible' + ); + }); }); /* @@ -116,71 +117,70 @@ describe('linode backups', () => { const snapshotName = randomLabel(); - cy.defer(createTestLinode(createLinodeRequest), 'creating Linode').then( - (linode: Linode) => { - interceptGetLinode(linode.id).as('getLinode'); - interceptCreateLinodeSnapshot(linode.id).as('createSnapshot'); - - // Navigate to Linode details page "Backups" tab. - cy.visitWithLogin(`/linodes/${linode.id}/backup`); - cy.wait('@getLinode'); + cy.defer( + () => createTestLinode(createLinodeRequest), + 'creating Linode' + ).then((linode: Linode) => { + interceptGetLinode(linode.id).as('getLinode'); + interceptCreateLinodeSnapshot(linode.id).as('createSnapshot'); + + // Navigate to Linode details page "Backups" tab. + cy.visitWithLogin(`/linodes/${linode.id}/backup`); + cy.wait('@getLinode'); + + // Wait for the Linode to finish provisioning. + cy.findByText('OFFLINE').should('be.visible'); + + cy.findByText('Manual Snapshot') + .should('be.visible') + .parent() + .within(() => { + // Confirm that "Take Snapshot" button is disabled until a name is entered. + ui.button + .findByTitle('Take Snapshot') + .should('be.visible') + .should('be.disabled'); - // Wait for the Linode to finish provisioning. - cy.findByText('OFFLINE').should('be.visible'); + // Enter a snapshot name, click "Take Snapshot". + cy.findByLabelText('Name Snapshot') + .should('be.visible') + .clear() + .type(snapshotName); - cy.findByText('Manual Snapshot') - .should('be.visible') - .parent() - .within(() => { - // Confirm that "Take Snapshot" button is disabled until a name is entered. - ui.button - .findByTitle('Take Snapshot') - .should('be.visible') - .should('be.disabled'); - - // Enter a snapshot name, click "Take Snapshot". - cy.findByLabelText('Name Snapshot') - .should('be.visible') - .clear() - .type(snapshotName); - - ui.button - .findByTitle('Take Snapshot') - .should('be.visible') - .should('be.enabled') - .click(); - }); + ui.button + .findByTitle('Take Snapshot') + .should('be.visible') + .should('be.enabled') + .click(); + }); - // Submit confirmation, confirm that toast message appears. - ui.dialog - .findByTitle('Take a snapshot?') - .should('be.visible') - .within(() => { - // Confirm user is warned that previous snapshot will be replaced. - cy.contains('overriding your previous snapshot').should( - 'be.visible' - ); - cy.contains('Are you sure?').should('be.visible'); - - ui.button - .findByTitle('Take Snapshot') - .should('be.visible') - .should('be.enabled') - .click(); - }); + // Submit confirmation, confirm that toast message appears. + ui.dialog + .findByTitle('Take a snapshot?') + .should('be.visible') + .within(() => { + // Confirm user is warned that previous snapshot will be replaced. + cy.contains('overriding your previous snapshot').should('be.visible'); + cy.contains('Are you sure?').should('be.visible'); + + ui.button + .findByTitle('Take Snapshot') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.wait('@createSnapshot'); - ui.toast.assertMessage('Starting to capture snapshot'); + cy.wait('@createSnapshot'); + ui.toast.assertMessage('Starting to capture snapshot'); - // Confirm that new snapshot is listed in backups table. - cy.findByText(snapshotName) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Pending').should('be.visible'); - }); - } - ); + // Confirm that new snapshot is listed in backups table. + cy.findByText(snapshotName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Pending').should('be.visible'); + }); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index afc1c505ac4..29273aab7f3 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -33,6 +33,9 @@ const getLinodeCloneUrl = (linode: Linode): string => { return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; +/* Timeout after 3 minutes while waiting for clone. */ +const CLONE_TIMEOUT = 180_000; + authenticate(); describe('clone linode', () => { before(() => { @@ -48,15 +51,19 @@ describe('clone linode', () => { const linodePayload = createLinodeRequestFactory.build({ label: randomLabel(), region: linodeRegion.id, - // Specifying no image allows the Linode to provision and clone faster. - image: undefined, booted: false, type: 'g6-nanode-1', }); const newLinodeLabel = `${linodePayload.label}-clone`; - cy.defer(createTestLinode(linodePayload)).then((linode: Linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode(linodePayload, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { const linodeRegion = getRegionById(linodePayload.region!); interceptCloneLinode(linode.id).as('cloneLinode'); @@ -101,7 +108,8 @@ describe('clone linode', () => { ui.toast.assertMessage(`Your Linode ${newLinodeLabel} is being created.`); ui.toast.assertMessage( - `Linode ${linode.label} successfully cloned to ${newLinodeLabel}.` + `Linode ${linode.label} successfully cloned to ${newLinodeLabel}.`, + { timeout: CLONE_TIMEOUT } ); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 81ec20da2d5..e95ca4252d7 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -80,7 +80,7 @@ describe('Linode Config management', () => { // Fetch Linode kernel data from the API. // We'll use this data in the tests to confirm that config labels are rendered correctly. - cy.defer(fetchAllKernels(), 'Fetching Linode kernels...').then( + cy.defer(() => fetchAllKernels(), 'Fetching Linode kernels...').then( (fetchedKernels) => { kernels = fetchedKernels; } @@ -95,61 +95,69 @@ describe('Linode Config management', () => { */ it('Creates a config', () => { // Wait for Linode to be created for kernel data to be retrieved. - cy.defer(createTestLinode(), 'Creating Linode').then((linode: Linode) => { - interceptCreateLinodeConfigs(linode.id).as('postLinodeConfigs'); - interceptGetLinodeConfigs(linode.id).as('getLinodeConfigs'); - - cy.visitWithLogin(`/linodes/${linode.id}/configurations`); - - // Confirm that initial config is listed in Linode configurations table. - cy.wait('@getLinodeConfigs'); - cy.defer(fetchLinodeConfigs(linode.id)).then((configs: Config[]) => { - cy.findByLabelText('List of Configurations').within(() => { - configs.forEach((config) => { - const kernel = findKernelById(kernels, config.kernel); - cy.findByText(`${config.label} – ${kernel.label}`).should( - 'be.visible' - ); + cy.defer(() => createTestLinode(), 'Creating Linode').then( + (linode: Linode) => { + interceptCreateLinodeConfigs(linode.id).as('postLinodeConfigs'); + interceptGetLinodeConfigs(linode.id).as('getLinodeConfigs'); + + cy.visitWithLogin(`/linodes/${linode.id}/configurations`); + + // Confirm that initial config is listed in Linode configurations table. + cy.wait('@getLinodeConfigs'); + cy.defer(() => fetchLinodeConfigs(linode.id)).then( + (configs: Config[]) => { + cy.findByLabelText('List of Configurations').within(() => { + configs.forEach((config) => { + const kernel = findKernelById(kernels, config.kernel); + cy.findByText(`${config.label} – ${kernel.label}`).should( + 'be.visible' + ); + }); + }); + } + ); + + // Add new configuration. + cy.findByText('Add Configuration').click(); + ui.dialog + .findByTitle('Add Configuration') + .should('be.visible') + .within(() => { + cy.get('#label').type(`${linode.id}-test-config`); + ui.buttonGroup + .findButtonByTitle('Add Configuration') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); }); - }); - }); - // Add new configuration. - cy.findByText('Add Configuration').click(); - ui.dialog - .findByTitle('Add Configuration') - .should('be.visible') - .within(() => { - cy.get('#label').type(`${linode.id}-test-config`); - ui.buttonGroup - .findButtonByTitle('Add Configuration') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm that config creation request was successful. - cy.wait('@postLinodeConfigs') - .its('response.statusCode') - .should('eq', 200); - - // Confirm that new config and existing config are both listed. - cy.wait('@getLinodeConfigs'); - cy.defer(fetchLinodeConfigs(linode.id)).then((configs: Config[]) => { - cy.findByLabelText('List of Configurations').within(() => { - configs.forEach((config) => { - const kernel = findKernelById(kernels, config.kernel); - cy.findByText(`${config.label} – ${kernel.label}`) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('eth0 – Public Internet').should('be.visible'); + // Confirm that config creation request was successful. + cy.wait('@postLinodeConfigs') + .its('response.statusCode') + .should('eq', 200); + + // Confirm that new config and existing config are both listed. + cy.wait('@getLinodeConfigs'); + cy.defer(() => fetchLinodeConfigs(linode.id)).then( + (configs: Config[]) => { + cy.findByLabelText('List of Configurations').within(() => { + configs.forEach((config) => { + const kernel = findKernelById(kernels, config.kernel); + cy.findByText(`${config.label} – ${kernel.label}`) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('eth0 – Public Internet').should( + 'be.visible' + ); + }); }); - }); - }); - }); - }); + }); + } + ); + } + ); }); /** @@ -174,7 +182,7 @@ describe('Linode Config management', () => { // Create a Linode and wait for its Config to be fetched before proceeding. cy.defer( - createLinodeAndGetConfig({ interfaces }, { waitForDisks: true }), + () => createLinodeAndGetConfig({ interfaces }, { waitForDisks: true }), 'creating a linode and getting its config' ).then(([linode, config]: [Linode, Config]) => { // Get kernel info for config. @@ -234,10 +242,11 @@ describe('Linode Config management', () => { */ it('Boots a config', () => { cy.defer( - createLinodeAndGetConfig( - { booted: true }, - { waitForBoot: true, securityMethod: 'vlan_no_internet' } - ), + () => + createLinodeAndGetConfig( + { booted: true }, + { waitForBoot: true, securityMethod: 'vlan_no_internet' } + ), 'Creating and booting test Linode' ).then(([linode, config]: [Linode, Config]) => { const kernel = findKernelById(kernels, config.kernel); @@ -282,16 +291,26 @@ describe('Linode Config management', () => { */ it('Clones a config', () => { // Create clone source and destination Linodes. + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. const createCloneTestLinodes = async () => { return Promise.all([ - createTestLinode({ booted: true }, { waitForBoot: true }), - createTestLinode({ booted: true }), + createTestLinode( + { booted: true }, + { securityMethod: 'vlan_no_internet', waitForBoot: true } + ), + createTestLinode( + { booted: true }, + { securityMethod: 'vlan_no_internet' } + ), ]); }; // Create clone and source destination Linodes, then proceed with clone flow. cy.defer( - createCloneTestLinodes(), + () => createCloneTestLinodes(), 'Waiting for 2 Linodes to be created' ).then(([sourceLinode, destLinode]: [Linode, Linode]) => { const kernel = findKernelById(kernels, 'linode/latest-64bit'); @@ -370,7 +389,7 @@ describe('Linode Config management', () => { */ it('Deletes a config', () => { cy.defer( - createLinodeAndGetConfig(), + () => createLinodeAndGetConfig(), 'creating a linode and getting its config' ).then(([linode, config]: [Linode, Config]) => { // Get kernel info for config to be deleted. diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index de3d7603a40..6cd87caaf14 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -104,7 +104,7 @@ describe('linode storage tab', () => { it('try to delete in use disk', () => { const diskName = 'Debian 11 Disk'; - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { cy.intercept( 'DELETE', apiMatcher(`linode/instances/${linode.id}/disks/*`) @@ -127,7 +127,7 @@ describe('linode storage tab', () => { it('delete disk', () => { const diskName = 'cy-test-disk'; - cy.defer(createTestLinode({ image: null })).then((linode) => { + cy.defer(() => createTestLinode({ image: null })).then((linode) => { cy.intercept( 'DELETE', apiMatcher(`linode/instances/${linode.id}/disks/*`) @@ -157,7 +157,7 @@ describe('linode storage tab', () => { it('add a disk', () => { const diskName = 'cy-test-disk'; - cy.defer(createTestLinode({ image: null })).then((linode: Linode) => { + cy.defer(() => createTestLinode({ image: null })).then((linode: Linode) => { cy.intercept( 'POST', apiMatcher(`/linode/instances/${linode.id}/disks`) @@ -171,7 +171,7 @@ describe('linode storage tab', () => { it('resize disk', () => { const diskName = 'Debian 10 Disk'; - cy.defer(createTestLinode({ image: null })).then((linode: Linode) => { + cy.defer(() => createTestLinode({ image: null })).then((linode: Linode) => { cy.intercept( 'POST', apiMatcher(`linode/instances/${linode.id}/disks`) diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index 0e6f00e2ffa..2c2c98ce89b 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -118,45 +118,46 @@ describe('rebuild linode', () => { region: chooseRegion().id, }); - cy.defer(createTestLinode(linodeCreatePayload), 'creating Linode').then( - (linode: Linode) => { - interceptRebuildLinode(linode.id).as('linodeRebuild'); - - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.findByText('RUNNING').should('be.visible'); - - openRebuildDialog(linode.label); - findRebuildDialog(linode.label).within(() => { - // "From Image" should be selected by default; no need to change the value. - ui.select.findByText('From Image').should('be.visible'); - - ui.select - .findByText('Choose an image') - .should('be.visible') - .click() - .type(`${image}{enter}`); - - // Type to confirm. - cy.findByLabelText('Linode Label').type(linode.label); - - // checkPasswordComplexity(rootPassword); - assertPasswordComplexity(weakPassword, 'Weak'); - submitRebuild(); - cy.findByText(passwordComplexityError).should('be.visible'); - - assertPasswordComplexity(fairPassword, 'Fair'); - submitRebuild(); - cy.findByText(passwordComplexityError).should('be.visible'); - - assertPasswordComplexity(rootPassword, 'Good'); - submitRebuild(); - cy.findByText(passwordComplexityError).should('not.exist'); - }); + cy.defer( + () => createTestLinode(linodeCreatePayload), + 'creating Linode' + ).then((linode: Linode) => { + interceptRebuildLinode(linode.id).as('linodeRebuild'); + + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('RUNNING').should('be.visible'); + + openRebuildDialog(linode.label); + findRebuildDialog(linode.label).within(() => { + // "From Image" should be selected by default; no need to change the value. + ui.select.findByText('From Image').should('be.visible'); + + ui.select + .findByText('Choose an image') + .should('be.visible') + .click() + .type(`${image}{enter}`); + + // Type to confirm. + cy.findByLabelText('Linode Label').type(linode.label); + + // checkPasswordComplexity(rootPassword); + assertPasswordComplexity(weakPassword, 'Weak'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('be.visible'); + + assertPasswordComplexity(fairPassword, 'Fair'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('be.visible'); + + assertPasswordComplexity(rootPassword, 'Good'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('not.exist'); + }); - cy.wait('@linodeRebuild'); - cy.contains('REBUILDING').should('be.visible'); - } - ); + cy.wait('@linodeRebuild'); + cy.contains('REBUILDING').should('be.visible'); + }); }); /* @@ -172,52 +173,53 @@ describe('rebuild linode', () => { region: chooseRegion().id, }); - cy.defer(createTestLinode(linodeCreatePayload), 'creating Linode').then( - (linode: Linode) => { - interceptRebuildLinode(linode.id).as('linodeRebuild'); - interceptGetStackScripts().as('getStackScripts'); - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.findByText('RUNNING').should('be.visible'); - - openRebuildDialog(linode.label); - findRebuildDialog(linode.label).within(() => { - ui.select.findByText('From Image').click(); - - ui.select - .findItemByText('From Community StackScript') - .should('be.visible') - .click(); - - cy.wait('@getStackScripts'); - cy.findByLabelText('Search by Label, Username, or Description') - .should('be.visible') - .type(`${stackScriptName}`); - - cy.wait('@getStackScripts'); - cy.findByLabelText('List of StackScripts').within(() => { - cy.get(`[id="${stackScriptId}"][type="radio"]`).click(); - }); - - ui.select - .findByText('Choose an image') - .scrollIntoView() - .should('be.visible') - .click(); - - ui.select.findItemByText(image).should('be.visible').click(); - - cy.findByLabelText('Linode Label') - .should('be.visible') - .type(linode.label); - - assertPasswordComplexity(rootPassword, 'Good'); - submitRebuild(); + cy.defer( + () => createTestLinode(linodeCreatePayload), + 'creating Linode' + ).then((linode: Linode) => { + interceptRebuildLinode(linode.id).as('linodeRebuild'); + interceptGetStackScripts().as('getStackScripts'); + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('RUNNING').should('be.visible'); + + openRebuildDialog(linode.label); + findRebuildDialog(linode.label).within(() => { + ui.select.findByText('From Image').click(); + + ui.select + .findItemByText('From Community StackScript') + .should('be.visible') + .click(); + + cy.wait('@getStackScripts'); + cy.findByLabelText('Search by Label, Username, or Description') + .should('be.visible') + .type(`${stackScriptName}`); + + cy.wait('@getStackScripts'); + cy.findByLabelText('List of StackScripts').within(() => { + cy.get(`[id="${stackScriptId}"][type="radio"]`).click(); }); - cy.wait('@linodeRebuild'); - cy.contains('REBUILDING').should('be.visible'); - } - ); + ui.select + .findByText('Choose an image') + .scrollIntoView() + .should('be.visible') + .click(); + + ui.select.findItemByText(image).should('be.visible').click(); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .type(linode.label); + + assertPasswordComplexity(rootPassword, 'Good'); + submitRebuild(); + }); + + cy.wait('@linodeRebuild'); + cy.contains('REBUILDING').should('be.visible'); + }); }); /* @@ -251,7 +253,7 @@ describe('rebuild linode', () => { }; cy.defer( - createStackScriptAndLinode(stackScriptRequest, linodeRequest), + () => createStackScriptAndLinode(stackScriptRequest, linodeRequest), 'creating stackScript and linode' ).then(([stackScript, linode]) => { interceptRebuildLinode(linode.id).as('linodeRebuild'); diff --git a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts index 658de76da84..533eac2535f 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -43,44 +43,50 @@ describe('Rescue Linodes', () => { region: chooseRegion().id, }); - cy.defer(createTestLinode(linodePayload), 'creating Linode').then( - (linode: Linode) => { - interceptGetLinodeDetails(linode.id).as('getLinode'); - interceptRebootLinodeIntoRescueMode(linode.id).as( - 'rebootLinodeRescueMode' - ); + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer( + () => + createTestLinode(linodePayload, { securityMethod: 'vlan_no_internet' }), + 'creating Linode' + ).then((linode: Linode) => { + interceptGetLinodeDetails(linode.id).as('getLinode'); + interceptRebootLinodeIntoRescueMode(linode.id).as( + 'rebootLinodeRescueMode' + ); - const rescueUrl = `/linodes/${linode.id}`; - cy.visitWithLogin(rescueUrl); - cy.wait('@getLinode'); + const rescueUrl = `/linodes/${linode.id}`; + cy.visitWithLogin(rescueUrl); + cy.wait('@getLinode'); - // Wait for Linode to boot. - cy.findByText('RUNNING').should('be.visible'); + // Wait for Linode to boot. + cy.findByText('RUNNING').should('be.visible'); - // Open rescue dialog using action menu.. - ui.actionMenu - .findByTitle(`Action menu for Linode ${linode.label}`) - .should('be.visible') - .click(); + // Open rescue dialog using action menu.. + ui.actionMenu + .findByTitle(`Action menu for Linode ${linode.label}`) + .should('be.visible') + .click(); - ui.actionMenuItem.findByTitle('Rescue').should('be.visible').click(); + ui.actionMenuItem.findByTitle('Rescue').should('be.visible').click(); - ui.dialog - .findByTitle(`Rescue Linode ${linode.label}`) - .should('be.visible') - .within(() => { - rebootInRescueMode(); - }); + ui.dialog + .findByTitle(`Rescue Linode ${linode.label}`) + .should('be.visible') + .within(() => { + rebootInRescueMode(); + }); - // Check intercepted response and make sure UI responded correctly. - cy.wait('@rebootLinodeRescueMode') - .its('response.statusCode') - .should('eq', 200); + // Check intercepted response and make sure UI responded correctly. + cy.wait('@rebootLinodeRescueMode') + .its('response.statusCode') + .should('eq', 200); - ui.toast.assertMessage('Linode rescue started.'); - cy.findByText('REBOOTING').should('be.visible'); - } - ); + ui.toast.assertMessage('Linode rescue started.'); + cy.findByText('REBOOTING').should('be.visible'); + }); }); /* diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index 69e0f3980b0..0d003ddd864 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -15,7 +15,13 @@ describe('resize linode', () => { it('resizes a linode by increasing size: warm migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(createTestLinode({ booted: true })).then((linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); cy.findByText('Shared CPU').click({ scrollBehavior: false }); @@ -35,7 +41,13 @@ describe('resize linode', () => { it('resizes a linode by increasing size: cold migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(createTestLinode({ booted: true })).then((linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); cy.findByText('Shared CPU').click({ scrollBehavior: false }); @@ -56,7 +68,13 @@ describe('resize linode', () => { it('resizes a linode by increasing size when offline: cold migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(createTestLinode({ booted: true })).then((linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); // Turn off the linode to resize the disk @@ -97,87 +115,90 @@ describe('resize linode', () => { }); }); - it.only('resizes a linode by decreasing size', () => { - cy.defer(createTestLinode({ booted: true, type: 'g6-standard-2' })).then( - (linode) => { - const diskName = 'Debian 11 Disk'; - const size = '50000'; // 50 GB - - // Error flow when attempting to resize a linode to a smaller size without - // resizing the disk to the requested size first. - interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 2 GB'); - getClick('[id="g6-standard-1"]'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); - // Failed to reduce the size of the linode - cy.contains( - 'The current disk size of your Linode is too large for the new service plan. Please resize your disk to accommodate the new plan. You can read our Resize Your Linode guide for more detailed instructions.' - ) - .scrollIntoView() - .should('be.visible'); - - // Normal flow when resizing a linode to a smaller size after first resizing - // its disk. - cy.visitWithLogin(`/linodes/${linode.id}`); - - // Turn off the linode to resize the disk - ui.button.findByTitle('Power Off').should('be.visible').click(); - - ui.dialog - .findByTitle(`Power Off Linode ${linode.label}?`) - .should('be.visible') - .then(() => { - ui.button - .findByTitle(`Power Off Linode`) - .should('be.visible') - .click(); - }); - - containsVisible('OFFLINE'); - - cy.visitWithLogin(`linodes/${linode.id}/storage`); - fbtVisible(diskName); - - cy.get(`[data-qa-disk="${diskName}"]`).within(() => { - cy.contains('Resize').should('be.enabled').click(); + it('resizes a linode by decreasing size', () => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode( + { booted: true, type: 'g6-standard-2' }, + { securityMethod: 'vlan_no_internet' } + ) + ).then((linode) => { + const diskName = 'Debian 11 Disk'; + const size = '50000'; // 50 GB + + // Error flow when attempting to resize a linode to a smaller size without + // resizing the disk to the requested size first. + interceptLinodeResize(linode.id).as('linodeResize'); + cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.findByText('Shared CPU').click({ scrollBehavior: false }); + containsVisible('Linode 2 GB'); + getClick('[id="g6-standard-1"]'); + cy.get('[data-testid="textfield-input"]').type(linode.label); + cy.get('[data-qa-resize="true"]').should('be.enabled').click(); + cy.wait('@linodeResize'); + // Failed to reduce the size of the linode + cy.contains( + 'The current disk size of your Linode is too large for the new service plan. Please resize your disk to accommodate the new plan. You can read our Resize Your Linode guide for more detailed instructions.' + ) + .scrollIntoView() + .should('be.visible'); + + // Normal flow when resizing a linode to a smaller size after first resizing + // its disk. + cy.visitWithLogin(`/linodes/${linode.id}`); + + // Turn off the linode to resize the disk + ui.button.findByTitle('Power Off').should('be.visible').click(); + + ui.dialog + .findByTitle(`Power Off Linode ${linode.label}?`) + .should('be.visible') + .then(() => { + ui.button + .findByTitle(`Power Off Linode`) + .should('be.visible') + .click(); }); - ui.drawer - .findByTitle(`Resize ${diskName}`) - .should('be.visible') - .within(() => { - cy.get('[id="size"]') - .should('be.visible') - .click() - .clear() - .type(size); - - ui.buttonGroup - .findButtonByTitle('Resize') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Wait until the disk resize is done. - ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); - - interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 2 GB'); - getClick('[id="g6-standard-1"]'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); - cy.contains( - 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' - ).should('be.visible'); - } - ); + containsVisible('OFFLINE'); + + cy.visitWithLogin(`linodes/${linode.id}/storage`); + fbtVisible(diskName); + + cy.get(`[data-qa-disk="${diskName}"]`).within(() => { + cy.contains('Resize').should('be.enabled').click(); + }); + + ui.drawer + .findByTitle(`Resize ${diskName}`) + .should('be.visible') + .within(() => { + cy.get('[id="size"]').should('be.visible').click().clear().type(size); + + ui.buttonGroup + .findButtonByTitle('Resize') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait until the disk resize is done. + ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); + + interceptLinodeResize(linode.id).as('linodeResize'); + cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.findByText('Shared CPU').click({ scrollBehavior: false }); + containsVisible('Linode 2 GB'); + getClick('[id="g6-standard-1"]'); + cy.get('[data-testid="textfield-input"]').type(linode.label); + cy.get('[data-qa-resize="true"]').should('be.enabled').click(); + cy.wait('@linodeResize'); + cy.contains( + 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' + ).should('be.visible'); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index cac96713e76..d2841b747b6 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts @@ -73,7 +73,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); @@ -120,7 +120,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); @@ -171,7 +171,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes`); @@ -230,7 +230,7 @@ describe('delete linode', () => { mockGetAccountSettings(mockAccountSettings).as('getAccountSettings'); - cy.defer(createTwoLinodes()).then(([linodeA, linodeB]) => { + cy.defer(() => createTwoLinodes()).then(([linodeA, linodeB]) => { interceptDeleteLinode(linodeA.id).as('deleteLinode'); interceptDeleteLinode(linodeB.id).as('deleteLinode'); cy.visitWithLogin('/linodes', { preferenceOverrides }); diff --git a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts index ec8cbeab797..7375e3e1e27 100644 --- a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts @@ -21,7 +21,7 @@ describe('switch linode state', () => { // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is // attached to a Cloud Firewall. - cy.defer( + cy.defer(() => createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); @@ -68,7 +68,7 @@ describe('switch linode state', () => { // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is // attached to a Cloud Firewall. - cy.defer( + cy.defer(() => createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); @@ -98,39 +98,41 @@ describe('switch linode state', () => { * - Waits for Linode to finish booting up before succeeding. */ it('powers on a linode from landing page', () => { - cy.defer(createTestLinode({ booted: false })).then((linode: Linode) => { - cy.visitWithLogin('/linodes'); - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.contains('Offline').should('be.visible'); - }); - - ui.actionMenu - .findByTitle(`Action menu for Linode ${linode.label}`) - .should('be.visible') - .click(); - - ui.actionMenuItem.findByTitle('Power On').should('be.visible').click(); - - ui.dialog - .findByTitle(`Power On Linode ${linode.label}?`) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Power On Linode') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.contains('Booting').should('be.visible'); - cy.contains('Running', { timeout: 300000 }).should('be.visible'); - }); - }); + cy.defer(() => createTestLinode({ booted: false })).then( + (linode: Linode) => { + cy.visitWithLogin('/linodes'); + cy.get(`[data-qa-linode="${linode.label}"]`) + .should('be.visible') + .within(() => { + cy.contains('Offline').should('be.visible'); + }); + + ui.actionMenu + .findByTitle(`Action menu for Linode ${linode.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Power On').should('be.visible').click(); + + ui.dialog + .findByTitle(`Power On Linode ${linode.label}?`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Power On Linode') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get(`[data-qa-linode="${linode.label}"]`) + .should('be.visible') + .within(() => { + cy.contains('Booting').should('be.visible'); + cy.contains('Running', { timeout: 300000 }).should('be.visible'); + }); + } + ); }); /* @@ -140,25 +142,27 @@ describe('switch linode state', () => { * - Does not wait for Linode to finish booting up before succeeding. */ it('powers on a linode from details page', () => { - cy.defer(createTestLinode({ booted: false })).then((linode: Linode) => { - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.contains('OFFLINE').should('be.visible'); - cy.findByText(linode.label).should('be.visible'); - - cy.findByText('Power On').should('be.visible').click(); - ui.dialog - .findByTitle(`Power On Linode ${linode.label}?`) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Power On Linode') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.contains('BOOTING').should('be.visible'); - }); + cy.defer(() => createTestLinode({ booted: false })).then( + (linode: Linode) => { + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.contains('OFFLINE').should('be.visible'); + cy.findByText(linode.label).should('be.visible'); + + cy.findByText('Power On').should('be.visible').click(); + ui.dialog + .findByTitle(`Power On Linode ${linode.label}?`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Power On Linode') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.contains('BOOTING').should('be.visible'); + } + ); }); /* @@ -172,7 +176,7 @@ describe('switch linode state', () => { // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is // attached to a Cloud Firewall. - cy.defer( + cy.defer(() => createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); @@ -219,7 +223,7 @@ describe('switch linode state', () => { // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is // attached to a Cloud Firewall. - cy.defer( + cy.defer(() => createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); diff --git a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts index 31e0ed477b6..4e87aa948a2 100644 --- a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts @@ -11,7 +11,7 @@ describe('update linode label', () => { }); it('updates a linode label from details page', () => { - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); cy.contains('RUNNING').should('be.visible'); @@ -28,7 +28,7 @@ describe('update linode label', () => { }); it('updates a linode label from the "Settings" tab', () => { - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); cy.contains('RUNNING').should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index ebccb894f55..4ecfed6ca39 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -133,7 +133,7 @@ describe('longview', () => { }; // Create Linode and Longview Client before loading Longview landing page. - cy.defer(createLinodeAndClient(), { + cy.defer(createLinodeAndClient, { label: 'Creating Linode and Longview Client...', timeout: linodeCreateTimeout, }).then(([linode, client]: [Linode, LongviewClient]) => { diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index 3cd493d1c1b..57db284984c 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -89,7 +89,7 @@ describe('create NodeBalancer', () => { private_ip: true, }; - cy.defer(createTestLinode(linodePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: randomLabel(), region: region.id, @@ -116,7 +116,7 @@ describe('create NodeBalancer', () => { // NodeBalancers require Linodes with private IPs. private_ip: true, }; - cy.defer(createTestLinode(linodePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: `${randomLabel()}-^`, ipv4: linode.ipv4[1], @@ -165,7 +165,7 @@ describe('create NodeBalancer', () => { // NodeBalancers require Linodes with private IPs. private_ip: true, }; - cy.defer(createTestLinode(linodePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: randomLabel(), region: initialRegion.id, diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 3594b8d7eab..3806fb0ebb0 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -17,6 +17,8 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { mockGetAccount } from 'support/intercepts/account'; +import { accountFactory } from 'src/factories'; authenticate(); describe('object storage access key end-to-end tests', () => { @@ -37,6 +39,7 @@ describe('object storage access key end-to-end tests', () => { interceptGetAccessKeys().as('getKeys'); interceptCreateAccessKey().as('createKey'); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -126,11 +129,12 @@ describe('object storage access key end-to-end tests', () => { // Create a bucket before creating access key. cy.defer( - createBucket(bucketRequest), + () => createBucket(bucketRequest), 'creating Object Storage bucket' ).then(() => { const keyLabel = randomLabel(); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 84d67db2cb4..f3972f56cbc 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -25,10 +25,11 @@ import { randomString, } from 'support/util/random'; import { ui } from 'support/ui'; -import { regionFactory } from 'src/factories'; +import { accountFactory, regionFactory } from 'src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; import { buildArray } from 'support/util/arrays'; import { Scope } from '@linode/api-v4'; +import { mockGetAccount } from 'support/intercepts/account'; describe('object storage access keys smoke tests', () => { /* @@ -44,6 +45,7 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -115,6 +117,7 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -164,6 +167,11 @@ describe('object storage access keys smoke tests', () => { const mockRegions = [...mockRegionsObj, ...mockRegionsNoObj]; beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(true), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 76459bd7b5b..08e52e042a4 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -14,8 +14,12 @@ import { profileFactory, regionFactory, objectStorageKeyFactory, + accountFactory, } from '@src/factories'; -import { mockGetAccountSettings } from 'support/intercepts/account'; +import { + mockGetAccount, + mockGetAccountSettings, +} from 'support/intercepts/account'; import { mockCancelObjectStorage, mockCreateAccessKey, @@ -56,6 +60,7 @@ describe('Object Storage enrollment', () => { * - Confirms that consistent pricing information is shown for all regions in the enable modal. */ it('can enroll in Object Storage', () => { + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index cff27fd1f4f..be2705f89e3 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -4,9 +4,12 @@ import 'cypress-file-upload'; import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { objectStorageBucketFactory } from 'src/factories'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; import { authenticate } from 'support/api/authentication'; -import { interceptGetNetworkUtilization } from 'support/intercepts/account'; +import { + interceptGetNetworkUtilization, + mockGetAccount, +} from 'support/intercepts/account'; import { interceptCreateBucket, interceptDeleteBucket, @@ -132,6 +135,7 @@ describe('object storage end-to-end tests', () => { interceptDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); interceptGetNetworkUtilization().as('getNetworkUtilization'); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }).as('getFeatureFlags'); @@ -216,7 +220,7 @@ describe('object storage end-to-end tests', () => { ]; cy.defer( - setUpBucket(bucketLabel, bucketCluster), + () => setUpBucket(bucketLabel, bucketCluster), 'creating Object Storage bucket' ).then(() => { interceptUploadBucketObjectS3( @@ -409,7 +413,7 @@ describe('object storage end-to-end tests', () => { const bucketAccessPage = `/object-storage/buckets/${bucketCluster}/${bucketLabel}/access`; cy.defer( - setUpBucket(bucketLabel, bucketCluster), + () => setUpBucket(bucketLabel, bucketCluster), 'creating Object Storage bucket' ).then(() => { interceptGetBucketAccess(bucketLabel, bucketCluster).as( diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 261c4a10491..505ba19b880 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -23,7 +23,8 @@ import { import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { regionFactory } from 'src/factories'; +import { accountFactory, regionFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; describe('object storage smoke tests', () => { /* @@ -56,6 +57,11 @@ describe('object storage smoke tests', () => { objects: 0, }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(true), }).as('getFeatureFlags'); @@ -160,6 +166,7 @@ describe('object storage smoke tests', () => { hostname: bucketHostname, }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }).as('getFeatureFlags'); @@ -286,7 +293,7 @@ describe('object storage smoke tests', () => { * - Mocks existing buckets. * - Deletes mocked bucket, confirms that landing page reflects deletion. */ - it('can delete object storage bucket - smoke', () => { + it('can delete object storage bucket - smoke - Multi Cluster Disabled', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ @@ -296,6 +303,12 @@ describe('object storage smoke tests', () => { objects: 0, }); + mockGetAccount(accountFactory.build({ capabilities: [] })); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + mockGetBuckets([bucketMock]).as('getBuckets'); mockDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); @@ -324,4 +337,58 @@ describe('object storage smoke tests', () => { cy.wait('@deleteBucket'); cy.findByText('S3-compatible storage solution').should('be.visible'); }); + + /* + * - Tests core object storage bucket deletion flow using mocked API responses. + * - Mocks existing buckets. + * - Deletes mocked bucket, confirms that landing page reflects deletion. + */ + it('can delete object storage bucket - smoke - Multi Cluster Enabled', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + label: bucketLabel, + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + objects: 0, + }); + + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + + mockGetBuckets([bucketMock]).as('getBuckets'); + mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); + + cy.visitWithLogin('/object-storage'); + cy.wait('@getBuckets'); + + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + + cy.wait('@deleteBucket'); + cy.findByText('S3-compatible storage solution').should('be.visible'); + }); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts new file mode 100644 index 00000000000..4e1d4b0a686 --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts @@ -0,0 +1,82 @@ +/** + * @file Integration tests for Placement Groups navigation. + */ + +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { accountFactory } from 'src/factories'; +import { ui } from 'support/ui'; + +import type { Flags } from 'src/featureFlags'; + +const mockAccount = accountFactory.build(); + +describe('Placement Groups navigation', () => { + // Mock User Account to include Placement Group capability + beforeEach(() => { + mockGetAccount(mockAccount).as('getAccount'); + }); + + /* + * - Confirms that Placement Groups navigation item is shown when feature flag is enabled. + * - Confirms that clicking Placement Groups navigation item directs user to Placement Groups landing page. + */ + it('can navigate to Placement Groups landing page', () => { + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + cy.visitWithLogin('/linodes'); + cy.wait(['@getFeatureFlags', '@getClientStream']); + + ui.nav.findItemByTitle('Placement Groups').should('be.visible').click(); + + cy.url().should('endWith', '/placement-groups'); + }); + + /* + * - Confirms that Placement Groups navigation item is not shown when feature flag is disabled. + */ + it('does not show Placement Groups navigation item when feature is disabled', () => { + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: false, + }), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + cy.visitWithLogin('/linodes'); + cy.wait(['@getFeatureFlags', '@getClientStream']); + + ui.nav.find().within(() => { + cy.findByText('Placement Groups').should('not.exist'); + }); + }); + + /* + * - Confirms that manual navigation to Placement Groups landing page with feature is disabled displays Not Found to user. + */ + it('displays Not Found when manually navigating to /placement-groups with feature flag disabled', () => { + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: false, + }), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + cy.visitWithLogin('/placement-groups'); + cy.wait(['@getFeatureFlags', '@getClientStream']); + + cy.findByText('Not Found').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 23587b19b95..9afa75c6ce6 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -309,7 +309,7 @@ describe('Create stackscripts', () => { interceptGetStackScripts().as('getStackScripts'); interceptCreateLinode().as('createLinode'); - cy.defer(createLinodeAndImage(), { + cy.defer(createLinodeAndImage, { label: 'creating Linode and Image', timeout: 360000, }).then((privateImage) => { diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index 7d827e662ad..e96645f3116 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -12,9 +12,11 @@ import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; import { interceptCreateLinode } from 'support/intercepts/linodes'; import { getProfile } from '@linode/api-v4'; -import { Profile, StackScript } from '@linode/api-v4'; +import { Profile } from '@linode/api-v4'; import { formatDate } from '@src/utilities/formatDate'; +import type { StackScript } from '@linode/api-v4'; + const mockStackScripts: StackScript[] = [ stackScriptFactory.build({ id: 443929, @@ -103,7 +105,7 @@ describe('Community Stackscripts integration tests', () => { cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist'); cy.findByText('Automate deployment scripts').should('not.exist'); - cy.defer(getProfile(), 'getting profile').then((profile: Profile) => { + cy.defer(getProfile, 'getting profile').then((profile: Profile) => { const dateFormatOptionsLanding = { timezone: profile.timezone, displayTime: false, @@ -188,30 +190,34 @@ describe('Community Stackscripts integration tests', () => { cy.visitWithLogin('/stackscripts/community'); cy.wait('@getStackScripts'); + // Confirm that empty state is not shown. cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist'); cy.findByText('Automate deployment scripts').should('not.exist'); - cy.get('tr').then((value) => { - const rowCount = Cypress.$(value).length - 1; // Remove the table title row - - interceptGetStackScripts().as('getStackScripts1'); - cy.scrollTo(0, 500); - cy.wait('@getStackScripts1'); - - cy.get('tr').its('length').should('be.gt', rowCount); - - cy.get('tr').then((value) => { - const rowCount = Cypress.$(value).length - 1; - - interceptGetStackScripts().as('getStackScripts2'); - cy.get('tr') - .eq(rowCount) - .scrollIntoView({ offset: { top: 150, left: 0 } }); - cy.wait('@getStackScripts2'); - - cy.get('tr').its('length').should('be.gt', rowCount); - }); - }); + // Confirm that scrolling to the bottom of the StackScripts list causes + // pagination to occur automatically. Perform this check 3 times. + for (let i = 0; i < 3; i += 1) { + cy.findByLabelText('List of StackScripts') + .should('be.visible') + .within(() => { + // Scroll to the bottom of the StackScripts list, confirm Cloud fetches StackScripts, + // then confirm that list updates with the new StackScripts shown. + cy.get('tr').last().scrollIntoView(); + cy.wait('@getStackScripts').then((xhr) => { + const stackScripts = xhr.response?.body['data'] as + | StackScript[] + | undefined; + if (!stackScripts) { + throw new Error( + 'Unexpected response received when fetching StackScripts' + ); + } + cy.contains( + `${stackScripts[0].username} / ${stackScripts[0].label}` + ).should('be.visible'); + }); + }); + } }); /* diff --git a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts index 647a53a42cd..6e2835aa20b 100644 --- a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts @@ -74,7 +74,7 @@ describe('volume attach and detach flows', () => { createTestLinode(linodeRequest), ]); - cy.defer(entityPromise, 'creating Volume and Linode').then( + cy.defer(() => entityPromise, 'creating Volume and Linode').then( ([volume, linode]: [Volume, Linode]) => { interceptAttachVolume(volume.id).as('attachVolume'); interceptGetLinodeConfigs(linode.id).as('getLinodeConfigs'); diff --git a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts index 80918644c3c..f589c9b979b 100644 --- a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts @@ -48,7 +48,7 @@ describe('volume clone flow', () => { const cloneVolumeLabel = randomLabel(); - cy.defer(createActiveVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createActiveVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { interceptCloneVolume(volume.id).as('cloneVolume'); cy.visitWithLogin('/volumes', { diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index 6131cd87cde..3041575a061 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -86,7 +86,7 @@ describe('volume create flow', () => { regionLabel: region.label, }; - cy.defer(createTestLinode(linodeRequest), 'creating Linode').then( + cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( (linode) => { interceptCreateVolume().as('createVolume'); @@ -151,7 +151,7 @@ describe('volume create flow', () => { booted: false, }); - cy.defer(createTestLinode(linodeRequest), 'creating Linode').then( + cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( (linode: Linode) => { const volume = { label: randomLabel(), diff --git a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts index 919eb54e383..fe441dcf865 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -34,7 +34,7 @@ describe('volume delete flow', () => { region: chooseRegion().id, }); - cy.defer(createVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { interceptDeleteVolume(volume.id).as('deleteVolume'); cy.visitWithLogin('/volumes', { diff --git a/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts index c755e7d5d5e..f1ef8a5ddb7 100644 --- a/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts @@ -51,7 +51,7 @@ describe('volume resize flow', () => { size: oldSize, }); - cy.defer(createActiveVolume(volumeRequest), 'creating Volume').then( + cy.defer(() => createActiveVolume(volumeRequest), 'creating Volume').then( (volume: Volume) => { interceptResizeVolume(volume.id).as('resizeVolume'); cy.visitWithLogin('/volumes', { diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts new file mode 100644 index 00000000000..e6fc05b38b0 --- /dev/null +++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts @@ -0,0 +1,62 @@ +import { createVolume } from '@linode/api-v4/lib/volumes'; +import { Volume } from '@linode/api-v4'; +import { ui } from 'support/ui'; + +import { authenticate } from 'support/api/authentication'; +import { randomLabel } from 'support/util/random'; +import { cleanUp } from 'support/util/cleanup'; + +authenticate(); +describe('Search Volumes', () => { + before(() => { + cleanUp(['volumes']); + }); + + /* + * - Confirm that volumes are API searchable and filtered in the UI. + */ + it('creates two volumes and make sure they show up in the table and are searchable', () => { + const createTwoVolumes = async (): Promise<[Volume, Volume]> => { + return Promise.all([ + createVolume({ + label: randomLabel(), + region: 'us-east', + size: 10, + }), + createVolume({ + label: randomLabel(), + region: 'us-east', + size: 10, + }), + ]); + }; + + cy.defer(() => createTwoVolumes(), 'creating volumes').then( + ([volume1, volume2]) => { + cy.visitWithLogin('/volumes'); + + // Confirm that both volumes are listed on the landing page. + cy.findByText(volume1.label).should('be.visible'); + cy.findByText(volume2.label).should('be.visible'); + + // Search for the first volume by label, confirm it's the only one shown. + cy.findByPlaceholderText('Search Volumes').type(volume1.label); + expect(cy.findByText(volume1.label).should('be.visible')); + expect(cy.findByText(volume2.label).should('not.exist')); + + // Clear search, confirm both volumes are shown. + cy.findByTestId('clear-volumes-search').click(); + cy.findByText(volume1.label).should('be.visible'); + cy.findByText(volume2.label).should('be.visible'); + + // Use the main search bar to search and filter volumes + cy.get('[id="main-search"').type(volume2.label); + ui.autocompletePopper.findByTitle(volume2.label).click(); + + // Confirm that only the second volume is shown. + cy.findByText(volume1.label).should('not.exist'); + cy.findByText(volume2.label).should('be.visible'); + } + ); + }); +}); diff --git a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts index c2cfe7283f3..2980b3e6a22 100644 --- a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts @@ -24,7 +24,7 @@ describe('volume update flow', () => { const newLabel = randomLabel(); const newTags = [randomLabel(5), randomLabel(5), randomLabel(5)]; - cy.defer(createVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { cy.visitWithLogin('/volumes', { // Temporarily force volume table to show up to 100 results per page. diff --git a/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts b/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts index dacc00ad54c..6ec7628ce88 100644 --- a/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts @@ -32,7 +32,7 @@ describe('Capture Machine Images', () => { }); cy.defer( - createTestLinode(linodePayload, { waitForBoot: true }), + () => createTestLinode(linodePayload, { waitForBoot: true }), 'creating and booting Linode' ).then(([linode, disk]: [Linode, Disk]) => { cy.visitWithLogin('/images/create/disk'); diff --git a/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts b/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts index f786773eaa2..e2f6dc2e65f 100644 --- a/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts +++ b/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts @@ -73,7 +73,7 @@ describe('Delete Machine Images', () => { // Wait for machine image to become ready, then begin test. cy.fixture('machine-images/test-image.gz', null).then( (imageFileContents) => { - cy.defer(uploadMachineImage(region, imageFileContents), { + cy.defer(() => uploadMachineImage(region, imageFileContents), { label: 'uploading Machine Image', timeout: imageUploadProcessingTimeout, }).then((image: Image) => { diff --git a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts index dec88cbe6cc..c0ccb540084 100644 --- a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts @@ -33,7 +33,7 @@ describeRegions('Delete Linodes', (region: Region) => { // Create a Linode before navigating to its details page to delete it. cy.defer( - createTestLinode(linodeCreatePayload), + () => createTestLinode(linodeCreatePayload), `creating Linode in ${region.label}` ).then((linode: Linode) => { interceptGetLinodeDetails(linode.id).as('getLinode'); diff --git a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts index 6850c680e42..5bfdfb78130 100644 --- a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts @@ -35,7 +35,7 @@ describeRegions('Can update Linodes', (region) => { */ it('can update a Linode label', () => { cy.defer( - createTestLinode(makeLinodePayload(region.id, true)), + () => createTestLinode(makeLinodePayload(region.id, true)), 'creating Linode' ).then((linode: Linode) => { const newLabel = randomLabel(); @@ -104,7 +104,7 @@ describeRegions('Can update Linodes', (region) => { return [linode, disks[0]]; }; - cy.defer(createLinodeAndGetDisk(), 'creating Linode').then( + cy.defer(() => createLinodeAndGetDisk(), 'creating Linode').then( ([linode, disk]: [Linode, Disk]) => { // Navigate to Linode details page. interceptGetLinodeDetails(linode.id).as('getLinode'); diff --git a/packages/manager/cypress/support/constants/account.ts b/packages/manager/cypress/support/constants/account.ts index 7ca6940fbf1..46396b7d895 100644 --- a/packages/manager/cypress/support/constants/account.ts +++ b/packages/manager/cypress/support/constants/account.ts @@ -12,3 +12,9 @@ may not be able be restored.'; export const cancellationPaymentErrorMessage = 'We were unable to charge your credit card for services rendered. \ We cannot cancel this account until the balance has been paid.'; + +/** + * Error message that appears when typing an error SSH key. + */ +export const sshFormatErrorMessage = + 'SSH Key key-type must be ssh-dss, ssh-rsa, ecdsa-sha2-nistp, ssh-ed25519, or sk-ecdsa-sha2-nistp256.'; diff --git a/packages/manager/cypress/support/e2e.ts b/packages/manager/cypress/support/e2e.ts index dc2dfc94cdf..36169150553 100644 --- a/packages/manager/cypress/support/e2e.ts +++ b/packages/manager/cypress/support/e2e.ts @@ -22,6 +22,8 @@ import 'cypress-real-events/support'; import './setup/defer-command'; import './setup/login-command'; import './setup/page-visit-tracking-commands'; +import './setup/test-tagging'; + chai.use(chaiString); chai.use(function (chai, utils) { diff --git a/packages/manager/cypress/support/index.d.ts b/packages/manager/cypress/support/index.d.ts index 1439322a740..b6c7a2e19e1 100644 --- a/packages/manager/cypress/support/index.d.ts +++ b/packages/manager/cypress/support/index.d.ts @@ -1,9 +1,14 @@ import { Labelable } from './commands'; import type { LinodeVisitOptions } from './login.ts'; +import type { TestTag } from 'support/util/tag'; declare global { namespace Cypress { + interface Cypress { + mocha: Mocha; + } + interface Chainable { /** * Custom command to select DOM element by data-cy attribute. @@ -17,7 +22,7 @@ declare global { * @example cy.defer(new Promise('value')).then((val) => {...}) */ defer( - promise: Promise, + promiseGenerator: () => Promise, labelOrOptions?: | Partial | string @@ -63,12 +68,33 @@ declare global { */ expectNewPageVisit(alias: string): Chainable<>; + /** + * Sets tags for the current runnable. + * + * Alias for `tag()` in `support/util/tag.ts`. + * + * @param tags - Tags to set for test or runnable. + */ + tag(...tags: TestTag[]): void; + + /** + * Adds tags for the given runnable. + * + * If tags have already been set (e.g. using a hook), this method will add + * the given tags in addition the tags that have already been set. + * + * Alias for `addTag()` in `support/util/tag.ts`. + * + * @param tags - Test tags. + */ + addTag(...tags: TestTag[]): void; + /** * Internal Cypress command to retrieve test state. * * @param state - Cypress internal state to retrieve. */ - state(state: string): any; + state(state?: string): any; } } } diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 932e1bc0bec..2a4a898068c 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -2,12 +2,12 @@ * @file Cypress intercepts and mocks for Cloud Manager Linode operations. */ +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Disk, Linode, LinodeType, Kernel, Volume } from '@linode/api-v4'; -import { makeErrorResponse } from 'support/util/errors'; +import type { Disk, Kernel, Linode, LinodeType, Volume } from '@linode/api-v4'; /** * Intercepts POST request to create a Linode. @@ -210,6 +210,19 @@ export const mockRebootLinodeIntoRescueModeError = ( ); }; +/** + * Intercepts GET request to retrieve a Linode's Disks + * + * @param linodeId - ID of Linode for intercepted request. + * + * @returns Cypress chainable. + */ +export const interceptGetLinodeDisks = ( + linodeId: number +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`linode/instances/${linodeId}/disks*`)); +}; + /** * Intercepts GET request to retrieve a Linode's Disks and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index f9053602749..11aee4b2c76 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -13,9 +13,9 @@ import type { Profile, SecurityQuestionsData, SecurityQuestionsPayload, - SSHKey, Token, UserPreferences, + SSHKey, } from '@linode/api-v4'; /** @@ -421,16 +421,40 @@ export const mockGetSSHKey = (sshKey: SSHKey): Cypress.Chainable => { }; /** - * Intercepts POST request to create an SSH key and mocks the response. + * Intercepts POST request to create an SSH key. * - * @param sshKey - SSH key object with which to mock response. + * @returns Cypress chainable. + */ +export const interceptCreateSSHKey = (): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher(`profile/sshkeys*`)); +}; + +/** + * Intercepts POST request to create an SSH key and mocks response. + * + * @param sshKey - An SSH key with which to create. * * @returns Cypress chainable. */ export const mockCreateSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher(`profile/sshkeys`), sshKey); +}; + +/** + * Intercepts POST request to create an SSH key and mocks an API error response. + * + * @param errorMessage - Error message to include in mock error response. + * @param status - HTTP status for mock error response. + * + * @returns Cypress chainable. + */ +export const mockCreateSSHKeyError = ( + errorMessage: string, + status: number = 400 +): Cypress.Chainable => { return cy.intercept( 'POST', - apiMatcher('/profile/sshkeys'), - makeResponse(sshKey) + apiMatcher('profile/sshkeys'), + makeErrorResponse(errorMessage, status) ); }; diff --git a/packages/manager/cypress/support/plugins/test-tagging-info.ts b/packages/manager/cypress/support/plugins/test-tagging-info.ts new file mode 100644 index 00000000000..47aa97cd462 --- /dev/null +++ b/packages/manager/cypress/support/plugins/test-tagging-info.ts @@ -0,0 +1,35 @@ +import { CypressPlugin } from './plugin'; +import { + validateQuery, + getHumanReadableQueryRules, + getQueryRules, +} from '../util/tag'; + +const envVarName = 'CY_TEST_TAGS'; +export const logTestTagInfo: CypressPlugin = (_on, config) => { + if (config.env[envVarName]) { + const query = config.env[envVarName]; + + if (!validateQuery(query)) { + throw `Failed to validate tag query '${query}'. Please double check the syntax of your query.`; + } + + const rules = getQueryRules(query); + + if (rules.length) { + console.info( + `Running tests that satisfy all of the following tag rules for query '${query}':` + ); + + console.table( + getHumanReadableQueryRules(query).reduce( + (acc: {}, cur: string, index: number) => { + acc[index] = cur; + return acc; + }, + {} + ) + ); + } + } +}; diff --git a/packages/manager/cypress/support/setup/defer-command.ts b/packages/manager/cypress/support/setup/defer-command.ts index f5f34068674..a667d505030 100644 --- a/packages/manager/cypress/support/setup/defer-command.ts +++ b/packages/manager/cypress/support/setup/defer-command.ts @@ -162,7 +162,7 @@ Cypress.Commands.add( 'defer', { prevSubject: false }, ( - promise: Promise, + promiseGenerator: () => Promise, labelOrOptions?: | Partial | string @@ -205,7 +205,7 @@ Cypress.Commands.add( const wrapPromise = async (): Promise => { let result: T; try { - result = await promise; + result = await promiseGenerator(); } catch (e: any) { commandLog.error(e); // If we're getting rate limited, timeout for 15 seconds so that diff --git a/packages/manager/cypress/support/setup/login-command.ts b/packages/manager/cypress/support/setup/login-command.ts index a3ed2289b37..35b6a151eb4 100644 --- a/packages/manager/cypress/support/setup/login-command.ts +++ b/packages/manager/cypress/support/setup/login-command.ts @@ -75,6 +75,7 @@ Cypress.Commands.add( ); } }, + failOnStatusCode: false, }; if (resolvedLinodeOptions.preferenceOverrides) { diff --git a/packages/manager/cypress/support/setup/test-tagging.ts b/packages/manager/cypress/support/setup/test-tagging.ts new file mode 100644 index 00000000000..5dbd48c8405 --- /dev/null +++ b/packages/manager/cypress/support/setup/test-tagging.ts @@ -0,0 +1,41 @@ +/** + * @file Exposes the `tag` util from the `cy` object. + */ + +import { Runnable, Test } from 'mocha'; +import { tag, addTag } from 'support/util/tag'; +import { evaluateQuery } from 'support/util/tag'; + +// Expose tag utils from the `cy` object. +// Similar to `cy.state`, and unlike other functions exposed in `cy`, these do not +// queue Cypress commands. Instead, they modify the test tag map upon execution. +cy.tag = tag; +cy.addTag = addTag; + +const query = Cypress.env('CY_TEST_TAGS') ?? ''; + +/** + * + */ +Cypress.on('test:before:run', (_test: Test, _runnable: Runnable) => { + /* + * Looks for the first command that does not belong in a hook and evalutes tags. + * + * Waiting for the first command to begin executing ensures that test context + * is set up and that tags have been assigned to the test. + */ + const commandHandler = () => { + const context = cy.state('ctx'); + if (context && context.test?.type !== 'hook') { + const tags = context?.tags ?? []; + + if (!evaluateQuery(query, tags)) { + context.skip(); + } + + Cypress.removeListener('command:start', commandHandler); + } + }; + + Cypress.on('command:start', commandHandler); +}); diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index 9e791402da7..f067604ea1d 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -41,8 +41,7 @@ export interface Page { // List of Routes and validator of the route export const pages: Page[] = [ { - assertIsLoaded: () => - cy.findByText('Choose a Distribution').should('be.visible'), + assertIsLoaded: () => cy.findByText('Choose an OS').should('be.visible'), goWithUI: [ { go: () => { diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts index 1f24a0899ca..ddd46b2702c 100644 --- a/packages/manager/cypress/support/ui/pages/linode-create-page.ts +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -8,33 +8,13 @@ import { ui } from 'support/ui'; * Page utilities for interacting with the Linode create page. */ export const linodeCreatePage = { - /** - * Sets the Linode's label. - * - * @param linodeLabel - Linode label to set. - */ - setLabel: (linodeLabel: string) => { - cy.findByLabelText('Linode Label').type(`{selectall}{del}${linodeLabel}`); - }, - - /** - * Sets the Linode's root password. - * - * @param linodePassword - Root password to set. - */ - setRootPassword: (linodePassword: string) => { - cy.findByLabelText('Root Password').as('rootPasswordField').click(); - - cy.get('@rootPasswordField').type(linodePassword, { log: false }); - }, - /** * Selects the Image with the given name. * * @param imageName - Name of Image to select. */ selectImage: (imageName: string) => { - cy.findByText('Choose a Distribution') + cy.findByText('Choose an OS') .closest('[data-qa-paper]') .within(() => { ui.autocomplete.find().click(); @@ -46,15 +26,6 @@ export const linodeCreatePage = { }); }, - /** - * Select the Region with the given ID. - * - * @param regionId - ID of Region to select. - */ - selectRegionById: (regionId: string) => { - ui.regionSelect.find().click().type(`${regionId}{enter}`); - }, - /** * Select the given Linode plan. * @@ -91,4 +62,33 @@ export const linodeCreatePage = { cy.get('@selectionCard').click(); }); }, + + /** + * Select the Region with the given ID. + * + * @param regionId - ID of Region to select. + */ + selectRegionById: (regionId: string) => { + ui.regionSelect.find().click().type(`${regionId}{enter}`); + }, + + /** + * Sets the Linode's label. + * + * @param linodeLabel - Linode label to set. + */ + setLabel: (linodeLabel: string) => { + cy.findByLabelText('Linode Label').type(`{selectall}{del}${linodeLabel}`); + }, + + /** + * Sets the Linode's root password. + * + * @param linodePassword - Root password to set. + */ + setRootPassword: (linodePassword: string) => { + cy.findByLabelText('Root Password').as('rootPasswordField').click(); + + cy.get('@rootPasswordField').type(linodePassword, { log: false }); + }, }; diff --git a/packages/manager/cypress/support/util/arrays.ts b/packages/manager/cypress/support/util/arrays.ts index 74ce77cdf9f..b713f115d69 100644 --- a/packages/manager/cypress/support/util/arrays.ts +++ b/packages/manager/cypress/support/util/arrays.ts @@ -32,3 +32,14 @@ export const shuffleArray = (unsortedArray: T[]): T[] => { .sort((a, b) => a.sort - b.sort) .map(({ value }) => value); }; + +/** + * Returns a copy of an array with duplicate items removed. + * + * @param array - Array from which to create de-duplicated array. + * + * @returns Copy of `array` with duplicate items removed. + */ +export const removeDuplicates = (array: T[]): T[] => { + return Array.from(new Set(array)); +}; diff --git a/packages/manager/cypress/support/util/cleanup.ts b/packages/manager/cypress/support/util/cleanup.ts index b8260b6773d..0621107c4cf 100644 --- a/packages/manager/cypress/support/util/cleanup.ts +++ b/packages/manager/cypress/support/util/cleanup.ts @@ -65,7 +65,7 @@ const cleanUpMap: CleanUpMap = { */ export const cleanUp = (resources: CleanUpResource | CleanUpResource[]) => { const resourcesArray = Array.isArray(resources) ? resources : [resources]; - const promise = async () => { + const promiseGenerator = async () => { for (const resource of resourcesArray) { const cleanFunction = cleanUpMap[resource]; // Perform clean-up sequentially to avoid API rate limiting. @@ -74,7 +74,7 @@ export const cleanUp = (resources: CleanUpResource | CleanUpResource[]) => { } }; return cy.defer( - promise(), + promiseGenerator, `cleaning up test resources: ${resourcesArray.join(', ')}` ); }; diff --git a/packages/manager/cypress/support/util/tag.ts b/packages/manager/cypress/support/util/tag.ts new file mode 100644 index 00000000000..c7fa37f0a08 --- /dev/null +++ b/packages/manager/cypress/support/util/tag.ts @@ -0,0 +1,170 @@ +import type { Context } from 'mocha'; +import { removeDuplicates } from './arrays'; + +const queryRegex = /(?:-|\+)?([^\s]+)/g; + +/** + * Allowed test tags. + */ +export type TestTag = + // Feature-related tags. + // Used to identify tests which deal with a certain feature or features. + | 'feat:linodes' + | 'feat:placementGroups' + + // Purpose-related tags. + // Describes additional uses for which a test may serve. + // For example, a test which creates a Linode end-to-end could be useful for + // DC testing purposes even if that is not the primary purpose of the test. + | 'purpose:dcTesting' + | 'purpose:smokeTesting' + + // Method-related tags. + // Describe the way the tests operate -- either end-to-end using real API requests, + // or integration using mocked API requests. + | 'method:e2e' + | 'method:mock'; + +/** + * + */ +export const testTagMap: Map = new Map(); + +/** + * Extended Mocha context that contains a tags property. + * + * `Context` already allows for arbitrary key/value pairs, this type simply + * enforces the `tags` property as an optional array of strings. + */ +export type ExtendedMochaContext = Context & { + tags?: string[]; +}; + +/** + * Sets tags for the current runnable. + * + * @param tags - Test tags. + */ +export const tag = (...tags: TestTag[]) => { + const extendedMochaContext = cy.state('ctx') as ExtendedMochaContext; + + if (extendedMochaContext) { + extendedMochaContext.tags = removeDuplicates(tags); + } +}; + +/** + * Adds tags for the given runnable. + * + * If tags have already been set (e.g. using a hook), this method will add + * the given tags in addition the tags that have already been set. + * + * @param tags - Test tags. + */ +export const addTag = (...tags: TestTag[]) => { + const extendedMochaContext = cy.state('ctx') as ExtendedMochaContext; + + if (extendedMochaContext) { + extendedMochaContext.tags = removeDuplicates([ + ...(extendedMochaContext.tags || []), + ...tags, + ]); + } +}; + +/** + * Returns a boolean indicating whether `query` is a valid test tag query. + * + * @param query - Test tag query string. + * + * @return `true` if `query` is valid, `false` otherwise. + */ +export const validateQuery = (query: string) => { + // An empty string is a special case. + if (query === '') { + return true; + } + const result = queryRegex.test(query); + queryRegex.lastIndex = 0; + return result; +}; + +/** + * Gets an array of individual query rules from a query string. + * + * @param query - Query string from which to get query rules. + * + * @example + * // Query for all Linode or Volume tests, which also test Placement Groups, + * // and which are not end-to-end. + * const query = '+feat:linode,feat:volumes feat:placementGroups -e2e' + * getQueryRules(query); + * // Expected output: ['+feat:linode,feat:volumes', '+feat:placementGroups', '-e2e'] + * + * @returns Array of query rule strings. + */ +export const getQueryRules = (query: string) => { + return (query.match(queryRegex) ?? []).map((rule: string) => { + if (!['-', '+'].includes(rule[0]) || rule.length === 1) { + return `+${rule}`; + } + return rule; + }); +}; + +/** + * Returns an array of human-readable query rules. + * + * This can be useful for presentation or debugging purposes. + */ +export const getHumanReadableQueryRules = (query: string) => { + return getQueryRules(query).map((queryRule: string) => { + const queryOperation = queryRule[0]; + const queryOperands = queryRule.slice(1).split(','); + + const operationName = + queryOperation === '+' ? `HAS TAG` : `DOES NOT HAVE TAG`; + const tagNames = queryOperands.join(' OR '); + + return `${operationName} ${tagNames}`; + }); +}; + +/** + * Evaluates a query rule against an array of test tags. + * + * @param queryRule - Query rule against which to evaluate test tags. + * @param tags - Tags to evaluate. + * + * @returns `true` if tags satisfy the query rule, `false` otherwise. + */ +export const evaluateQueryRule = ( + queryRule: string, + tags: TestTag[] +): boolean => { + const queryOperation = queryRule[0]; // Either '-' or '+'. + const queryOperands = queryRule.slice(1).split(','); // The tags to check. + + return queryOperation === '+' + ? tags.some((tag) => queryOperands.includes(tag)) + : !tags.some((tag) => queryOperands.includes(tag)); +}; + +/** + * Evaluates a query against an array of test tags. + * + * Tags are considered to satisfy query if every query rule evaluates to `true`. + * + * @param query - Query against which to evaluate test tags. + * @param tags - Tags to evaluate. + * + * @returns `true` if tags satisfy query, `false` otherwise. + */ +export const evaluateQuery = (query: string, tags: TestTag[]): boolean => { + if (!validateQuery(query)) { + throw new Error(`Invalid test tag query '${query}'`); + } + return getQueryRules(query).every((queryRule) => + evaluateQueryRule(queryRule, tags) + ); +}; diff --git a/packages/manager/package.json b/packages/manager/package.json index a894bf51c24..a49eb4653d0 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.121.2", + "version": "1.122.0", "private": true, "type": "module", "bugs": { @@ -128,8 +128,9 @@ "@storybook/theming": "^8.1.0", "@swc/core": "^1.3.1", "@testing-library/cypress": "^10.0.2", + "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", - "@testing-library/react": "~14.2.1", + "@testing-library/react": "~16.0.0", "@testing-library/user-event": "^14.5.2", "@types/braintree-web": "^3.75.23", "@types/chai-string": "^1.4.5", @@ -164,11 +165,11 @@ "@types/uuid": "^3.4.3", "@types/yup": "^0.29.13", "@types/zxcvbn": "^4.4.0", - "@typescript-eslint/eslint-plugin": "^4.1.1", - "@typescript-eslint/parser": "^4.1.1", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react-swc": "^3.5.0", - "@vitest/coverage-v8": "^1.0.4", - "@vitest/ui": "^1.0.4", + "@vitest/coverage-v8": "^1.6.0", + "@vitest/ui": "^1.6.0", "chai-string": "^1.5.0", "chalk": "^5.2.0", "commander": "^6.2.1", @@ -179,7 +180,7 @@ "cypress-real-events": "^1.12.0", "cypress-vite": "^1.5.0", "dotenv": "^16.0.3", - "eslint": "^6.8.0", + "eslint": "^7.1.0", "eslint-config-prettier": "~8.1.0", "eslint-plugin-cypress": "^2.11.3", "eslint-plugin-jsx-a11y": "^6.7.1", @@ -212,7 +213,7 @@ "ts-node": "^10.9.2", "vite": "^5.1.7", "vite-plugin-svgr": "^3.2.0", - "vitest": "^1.3.1" + "vitest": "^1.6.0" }, "browserslist": [ ">1%", @@ -220,4 +221,4 @@ "Firefox ESR", "not ie < 9" ] -} +} \ No newline at end of file diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index c74fd368483..c73a627e77d 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -130,7 +130,11 @@ const Domains = React.lazy(() => })) ); const Images = React.lazy(() => import('src/features/Images')); -const Kubernetes = React.lazy(() => import('src/features/Kubernetes')); +const Kubernetes = React.lazy(() => + import('src/features/Kubernetes').then((module) => ({ + default: module.Kubernetes, + })) +); const ObjectStorage = React.lazy(() => import('src/features/ObjectStorage')); const Profile = React.lazy(() => import('src/features/Profile/Profile')); const LoadBalancers = React.lazy(() => diff --git a/packages/manager/src/assets/icons/account.svg b/packages/manager/src/assets/icons/account.svg index 810c046b89c..31b3352dd0b 100644 --- a/packages/manager/src/assets/icons/account.svg +++ b/packages/manager/src/assets/icons/account.svg @@ -1 +1 @@ - + diff --git a/packages/manager/src/components/AccessPanel/AccessPanel.tsx b/packages/manager/src/components/AccessPanel/AccessPanel.tsx index f0b8d393b00..f21c9e146da 100644 --- a/packages/manager/src/components/AccessPanel/AccessPanel.tsx +++ b/packages/manager/src/components/AccessPanel/AccessPanel.tsx @@ -1,14 +1,21 @@ -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { + DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES, + DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION, DISK_ENCRYPTION_GENERAL_DESCRIPTION, DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY, + ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON, + ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON, + ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY, + ENCRYPT_DISK_REBUILD_LKE_COPY, + ENCRYPT_DISK_REBUILD_STANDARD_COPY, } from 'src/components/DiskEncryption/constants'; import { DiskEncryption } from 'src/components/DiskEncryption/DiskEncryption'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Paper } from 'src/components/Paper'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -17,6 +24,8 @@ import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature import { Divider } from '../Divider'; import UserSSHKeyPanel from './UserSSHKeyPanel'; +import type { Theme } from '@mui/material/styles'; + const PasswordInput = React.lazy( () => import('src/components/PasswordInput/PasswordInput') ); @@ -46,8 +55,11 @@ interface Props { handleChange: (value: string) => void; heading?: string; hideStrengthLabel?: boolean; + isInRebuildFlow?: boolean; + isLKELinode?: boolean; isOptional?: boolean; label?: string; + linodeIsInDistributedRegion?: boolean; password: null | string; passwordHelperText?: string; placeholder?: string; @@ -58,6 +70,21 @@ interface Props { toggleDiskEncryptionEnabled?: () => void; } +interface DiskEncryptionDescriptionDeterminants { + isDistributedRegion: boolean | undefined; // Linode Create flow (region selected for a not-yet-created linode) + isInRebuildFlow: boolean | undefined; + isLKELinode: boolean | undefined; + linodeIsInDistributedRegion: boolean | undefined; // Linode Rebuild flow (linode exists already) +} + +interface DiskEncryptionDisabledReasonDeterminants { + isDistributedRegion: boolean | undefined; // Linode Create flow (region selected for a not-yet-created linode) + isInRebuildFlow: boolean | undefined; + isLKELinode: boolean | undefined; + linodeIsInDistributedRegion: boolean | undefined; // Linode Rebuild flow (linode exists already) + regionSupportsDiskEncryption: boolean; +} + export const AccessPanel = (props: Props) => { const { authorizedUsers, @@ -69,8 +96,11 @@ export const AccessPanel = (props: Props) => { error, handleChange: _handleChange, hideStrengthLabel, + isInRebuildFlow, + isLKELinode, isOptional, label, + linodeIsInDistributedRegion, password, passwordHelperText, placeholder, @@ -94,9 +124,70 @@ export const AccessPanel = (props: Props) => { 'Disk Encryption' ); + const isDistributedRegion = getIsDistributedRegion( + regions ?? [], + selectedRegion ?? '' + ); + const handleChange = (e: React.ChangeEvent) => _handleChange(e.target.value); + const determineDiskEncryptionDescription = ({ + isDistributedRegion, + isInRebuildFlow, + isLKELinode, + linodeIsInDistributedRegion, + }: DiskEncryptionDescriptionDeterminants) => { + // Linode Rebuild flow descriptions + if (isInRebuildFlow) { + // the order is significant: all Distributed instances are encrypted (broadest) + if (linodeIsInDistributedRegion) { + return ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY; + } + + if (isLKELinode) { + return ENCRYPT_DISK_REBUILD_LKE_COPY; + } + + if (!isLKELinode && !linodeIsInDistributedRegion) { + return ENCRYPT_DISK_REBUILD_STANDARD_COPY; + } + } + + // Linode Create flow descriptions + return isDistributedRegion + ? DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION + : DISK_ENCRYPTION_GENERAL_DESCRIPTION; + }; + + const determineDiskEncryptionDisabledReason = ({ + isDistributedRegion, + isInRebuildFlow, + isLKELinode, + linodeIsInDistributedRegion, + regionSupportsDiskEncryption, + }: DiskEncryptionDisabledReasonDeterminants) => { + if (isInRebuildFlow) { + // the order is significant: setting can't be changed for *any* Distributed instances (broadest) + if (linodeIsInDistributedRegion) { + return ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON; + } + + if (isLKELinode) { + return ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON; + } + + if (!regionSupportsDiskEncryption) { + return DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; + } + } + + // Linode Create flow disabled reasons + return isDistributedRegion + ? DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES + : DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; + }; + /** * Display the "Disk Encryption" section if: * 1) the feature is enabled @@ -111,9 +202,24 @@ export const AccessPanel = (props: Props) => { <> toggleDiskEncryptionEnabled()} /> diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 90a7202f4d5..65f21b124e9 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -66,7 +66,7 @@ export const Autocomplete = < props: EnhancedAutocompleteProps ) => { const { - clearOnBlur = false, + clearOnBlur, defaultValue, disablePortal = true, errorText = '', diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/DiskEncryption/constants.tsx index 539b19d3a6c..aff4981ad13 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/DiskEncryption/constants.tsx @@ -2,29 +2,71 @@ import React from 'react'; import { Link } from 'src/components/Link'; -// @TODO LDE: Update "Learn more" link +const DISK_ENCRYPTION_GUIDE_LINK = + 'https://www.linode.com/docs/products/compute/compute-instances/guides/local-disk-encryption'; + export const DISK_ENCRYPTION_GENERAL_DESCRIPTION = ( <> Secure this Linode using data at rest encryption. Data center systems take care of encrypting and decrypting for you. After the Linode is created, use - Rebuild to enable or disable this feature. Learn more. + Rebuild to enable or disable this feature.{' '} + Learn more. ); -export const DISK_ENCRYPTION_DESCRIPTION_NODE_POOL_REBUILD_CAVEAT = - 'Encrypt Linode data at rest to improve security. The disk encryption setting for Linodes added to a node pool will not be changed after rebuild.'; +export const DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION = + 'Distributed Compute Instances are secured using disk encryption. Encryption and decryption are automatically managed for you.'; + +const DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_DOCS_LINK = + 'https://www.linode.com/docs/products/compute/compute-instances/guides/local-disk-encryption/'; + +export const DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_COPY = ( + <> + Disk encryption is now standard on Linodes.{' '} + + Learn how + {' '} + to update and protect your clusters. + +); + +export const DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_BANNER_KEY = + 'disk-encryption-update-protect-clusters-banner'; export const DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY = - 'Disk encryption is not available in the selected region.'; + 'Disk encryption is not available in the selected region. Select another region to use Disk Encryption.'; -export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = - 'Virtual Machine Backups are not encrypted.'; +export const DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES = + 'Distributed Compute Instances are encrypted. This setting can not be changed.'; +// Guidance export const DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY = 'To enable disk encryption, delete the node pool and create a new node pool. New node pools are always encrypted.'; export const UNENCRYPTED_STANDARD_LINODE_GUIDANCE_COPY = 'Rebuild this Linode to enable or disable disk encryption.'; +// Caveats +export const DISK_ENCRYPTION_DESCRIPTION_NODE_POOL_REBUILD_CAVEAT = + 'Encrypt Linode data at rest to improve security. The disk encryption setting for Linodes added to a node pool will not be changed after rebuild.'; + +export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = + 'Virtual Machine Backups are not encrypted.'; + export const DISK_ENCRYPTION_IMAGES_CAVEAT_COPY = 'Virtual Machine Images are not encrypted.'; + +export const ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON = + 'The Encrypt Disk setting cannot be changed for a Linode attached to a node pool.'; + +export const ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON = + 'The Encrypt Disk setting cannot be changed for distributed instances.'; + +export const ENCRYPT_DISK_REBUILD_STANDARD_COPY = + 'Secure this Linode using data at rest encryption.'; + +export const ENCRYPT_DISK_REBUILD_LKE_COPY = + 'Secure this Linode using data at rest encryption. The disk encryption setting for Linodes added to a node pool will not be changed after rebuild.'; + +export const ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY = + 'Distributed Compute Instances are secured using disk encryption.'; diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index 2180c9a9e81..b1da36086ea 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -1,15 +1,19 @@ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; -import { OptionProps } from 'react-select'; import { makeStyles } from 'tss-react/mui'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Box } from 'src/components/Box'; -import { Item } from 'src/components/EnhancedSelect'; import { Option } from 'src/components/EnhancedSelect/components/Option'; -import { TooltipIcon } from 'src/components/TooltipIcon'; import { useFlags } from 'src/hooks/useFlags'; +import { Stack } from '../Stack'; +import { Tooltip } from '../Tooltip'; + +import type { ImageItem } from './ImageSelect'; +import type { Theme } from '@mui/material/styles'; +import type { OptionProps } from 'react-select'; + const useStyles = makeStyles()((theme: Theme) => ({ distroIcon: { fontSize: '1.8em', @@ -33,8 +37,10 @@ const useStyles = makeStyles()((theme: Theme) => ({ '& g': { fill: theme.name === 'dark' ? 'white' : '#888f91', }, - display: 'flex', - padding: `2px !important`, // Revisit use of important when we refactor the Select component + display: 'flex !important', + flexDirection: 'row', + justifyContent: 'space-between', + padding: '2px 8px !important', // Revisit use of important when we refactor the Select component }, selected: { '& g': { @@ -43,11 +49,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface ImageItem extends Item { - className?: string; - isCloudInitCompatible: boolean; -} - interface ImageOptionProps extends OptionProps { data: ImageItem; } @@ -59,48 +60,32 @@ export const ImageOption = (props: ImageOptionProps) => { return ( ); }; - -const sxCloudInitTooltipIcon = { - '& svg': { - height: 20, - width: 20, - }, - '&:hover': { - color: 'inherit', - }, - color: 'inherit', - marginLeft: 'auto', - padding: 0, - paddingRight: 1, -}; diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx index 25e88889795..664525d5383 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx @@ -34,6 +34,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2022-10-20T14:05:30', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Slackware 14.1', value: 'private/4', }, @@ -41,6 +42,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2022-10-20T14:05:30', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Slackware 14.1', value: 'private/5', }, @@ -72,6 +74,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2017-06-16T20:02:29', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Debian 9 (deprecated)', value: 'private/6', }, @@ -79,6 +82,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2017-06-16T20:02:29', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Debian 9 (deprecated)', value: 'private/7', }, diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index e4fbc8e9bac..5b320a4d7a7 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -21,10 +21,11 @@ import { distroIcons } from '../DistributionIcon'; export type Variant = 'all' | 'private' | 'public'; -interface ImageItem extends Item { +export interface ImageItem extends Item { className: string; created: string; isCloudInitCompatible: boolean; + isDistributedCompatible: boolean; } interface ImageSelectProps { @@ -111,6 +112,9 @@ export const imagesToGroupedItems = (images: Image[]) => { : `fl-tux`, created, isCloudInitCompatible: capabilities?.includes('cloud-init'), + isDistributedCompatible: capabilities?.includes( + 'distributed-images' + ), // Add suffix 'deprecated' to the image at end of life. label: differenceInMonths > 0 ? `${label} (deprecated)` : label, diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index f74e3570601..33923a9f889 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -36,4 +36,15 @@ describe('ImageOptionv2', () => { getByLabelText('This image is compatible with cloud-init.') ).toBeVisible(); }); + it('renders a distributed icon if image has the "distributed-images" capability', () => { + const image = imageFactory.build({ capabilities: ['distributed-images'] }); + + const { getByLabelText } = renderWithTheme( + + ); + + expect( + getByLabelText('This image is compatible with distributed regions.') + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index 4f38225e331..d8ceb098d02 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -1,6 +1,7 @@ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import React from 'react'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; import { useFlags } from 'src/hooks/useFlags'; import { SelectedIcon } from '../Autocomplete/Autocomplete.styles'; @@ -21,15 +22,30 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => { const flags = useFlags(); return ( -
  • - +
  • + {image.label} - + + + {image.capabilities.includes('distributed-images') && ( + +
    + +
    +
    + )} {flags.metadata && image.capabilities.includes('cloud-init') && ( diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index f1fca35a051..dc05c89a622 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -98,7 +98,7 @@ export const getInitialValuesFromUserPreferences = ( ); }; -export const sortData = (orderBy: string, order: Order) => { +export const sortData = (orderBy: string, order: Order) => { return sort((a, b) => { /* If the column we're sorting on is an array (e.g. 'tags', which is string[]), * we want to sort by the length of the array. Otherwise, do a simple comparison. @@ -155,7 +155,7 @@ export const sortData = (orderBy: string, order: Order) => { }); }; -export const OrderBy = (props: CombinedProps) => { +export const OrderBy = (props: CombinedProps) => { const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); const location = useLocation(); diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx index ed4af598942..0abdb665b0f 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx @@ -8,10 +8,10 @@ import { sortByString } from 'src/utilities/sort-by'; import { RegionMultiSelect } from './RegionMultiSelect'; import type { RegionMultiSelectProps } from './RegionSelect.types'; +import type { Region } from '@linode/api-v4'; import type { Meta, StoryObj } from '@storybook/react'; -import type { RegionSelectOption } from 'src/components/RegionSelect/RegionSelect.types'; -const sortRegionOptions = (a: RegionSelectOption, b: RegionSelectOption) => { +const sortRegionOptions = (a: Region, b: Region) => { return sortByString(a.label, b.label, 'asc'); }; @@ -30,7 +30,7 @@ export const Default: StoryObj = { diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx index 9a055bb41ad..ea64ef5929b 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx @@ -1,3 +1,4 @@ +import { Region } from '@linode/api-v4'; import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; @@ -6,24 +7,19 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { RegionMultiSelect } from './RegionMultiSelect'; -import type { RegionSelectOption } from 'src/components/RegionSelect/RegionSelect.types'; - -const regions = regionFactory.buildList(1, { +const regionNewark = regionFactory.build({ id: 'us-east', label: 'Newark, NJ', }); -const regionsNewark = regionFactory.buildList(1, { - id: 'us-east', - label: 'Newark, NJ', -}); -const regionsAtlanta = regionFactory.buildList(1, { +const regionAtlanta = regionFactory.build({ id: 'us-southeast', label: 'Atlanta, GA', }); + interface SelectedRegionsProps { onRemove: (region: string) => void; - selectedRegions: RegionSelectOption[]; + selectedRegions: Region[]; } const SelectedRegionsList = ({ onRemove, @@ -32,8 +28,8 @@ const SelectedRegionsList = ({
      {selectedRegions.map((region, index) => (
    • - {region.label} - + {region.label} ({region.id}) +
    • ))}
    @@ -46,8 +42,8 @@ describe('RegionMultiSelect', () => { renderWithTheme( ); @@ -56,11 +52,12 @@ describe('RegionMultiSelect', () => { }); it('should be able to select all the regions correctly', () => { + const onChange = vi.fn(); renderWithTheme( ); @@ -70,26 +67,17 @@ describe('RegionMultiSelect', () => { fireEvent.click(screen.getByRole('option', { name: 'Select All' })); - // Check if all the option is selected - expect( - screen.getByRole('option', { - name: 'Newark, NJ (us-east)', - }) - ).toHaveAttribute('aria-selected', 'true'); - expect( - screen.getByRole('option', { - name: 'Newark, NJ (us-east)', - }) - ).toHaveAttribute('aria-selected', 'true'); + expect(onChange).toHaveBeenCalledWith([regionAtlanta.id, regionNewark.id]); }); it('should be able to deselect all the regions', () => { + const onChange = vi.fn(); renderWithTheme( ); @@ -98,17 +86,7 @@ describe('RegionMultiSelect', () => { fireEvent.click(screen.getByRole('option', { name: 'Deselect All' })); - // Check if all the option is deselected selected - expect( - screen.getByRole('option', { - name: 'Newark, NJ (us-east)', - }) - ).toHaveAttribute('aria-selected', 'false'); - expect( - screen.getByRole('option', { - name: 'Newark, NJ (us-east)', - }) - ).toHaveAttribute('aria-selected', 'false'); + expect(onChange).toHaveBeenCalledWith([]); }); it('should render selected regions correctly', () => { @@ -121,30 +99,34 @@ describe('RegionMultiSelect', () => { /> )} currentCapability="Block Storage" - handleSelection={mockHandleSelection} - regions={[...regionsNewark, ...regionsAtlanta]} - selectedIds={[]} + onChange={mockHandleSelection} + regions={[regionNewark, regionAtlanta]} + selectedIds={[regionNewark.id]} /> ); // Open the dropdown fireEvent.click(screen.getByRole('button', { name: 'Open' })); - fireEvent.click(screen.getByRole('option', { name: 'Select All' })); - - // Close the dropdown - fireEvent.click(screen.getByRole('button', { name: 'Close' })); - - // Check if all the options are rendered + // Check Newark chip shows becaused it is selected expect( screen.getByRole('listitem', { - name: 'Newark, NJ (us-east)', + name: 'Newark, NJ', }) ).toBeInTheDocument(); + + // Newark is selected expect( - screen.getByRole('listitem', { + screen.getByRole('option', { name: 'Newark, NJ (us-east)', }) - ).toBeInTheDocument(); + ).toHaveAttribute('aria-selected', 'true'); + + // Atlanta is not selected + expect( + screen.getByRole('option', { + name: 'Atlanta, GA (us-southeast)', + }) + ).toHaveAttribute('aria-selected', 'false'); }); }); diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 2c69a74d0d5..4ba6d5879a7 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -1,13 +1,15 @@ +import { Region } from '@linode/api-v4'; import CloseIcon from '@mui/icons-material/Close'; -import React, { useEffect, useMemo, useState } from 'react'; +import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { StyledListItem } from 'src/components/Autocomplete/Autocomplete.styles'; import { Box } from 'src/components/Box'; import { Chip } from 'src/components/Chip'; import { Flag } from 'src/components/Flag'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; +import { StyledListItem } from '../Autocomplete/Autocomplete.styles'; import { RegionOption } from './RegionOption'; import { StyledAutocompleteContainer, @@ -15,19 +17,19 @@ import { } from './RegionSelect.styles'; import { getRegionOptions, - getSelectedRegionsByIds, + isRegionOptionUnavailable, } from './RegionSelect.utils'; import type { + DisableRegionOption, RegionMultiSelectProps, - RegionSelectOption, } from './RegionSelect.types'; interface LabelComponentProps { - selection: RegionSelectOption; + region: Region; } -const SelectedRegion = ({ selection }: LabelComponentProps) => { +const SelectedRegion = ({ region }: LabelComponentProps) => { return ( { transform: 'scale(0.8)', })} > - + - {selection.label} + {region.label} ({region.id}) ); }; @@ -55,10 +57,10 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { currentCapability, disabled, errorText, - handleSelection, helperText, isClearable, label, + onChange, placeholder, regions, required, @@ -72,84 +74,61 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { isLoading: accountAvailabilityLoading, } = useAllAccountAvailabilitiesQuery(); - const [selectedRegions, setSelectedRegions] = useState( - getSelectedRegionsByIds({ - accountAvailabilityData: accountAvailability, - currentCapability, - regions, - selectedRegionIds: selectedIds ?? [], - }) + const regionOptions = getRegionOptions({ currentCapability, regions }); + + const selectedRegions = regionOptions.filter((r) => + selectedIds.includes(r.id) ); - const handleRegionChange = (selection: RegionSelectOption[]) => { - setSelectedRegions(selection); + const handleRemoveOption = (regionToRemove: string) => { + onChange(selectedIds.filter((value) => value !== regionToRemove)); }; - useEffect(() => { - setSelectedRegions( - getSelectedRegionsByIds({ + const disabledRegions = regionOptions.reduce< + Record + >((acc, region) => { + if ( + isRegionOptionUnavailable({ accountAvailabilityData: accountAvailability, currentCapability, - regions, - selectedRegionIds: selectedIds ?? [], + region, }) - ); - }, [selectedIds, accountAvailability, currentCapability, regions]); - - const options = useMemo( - () => - getRegionOptions({ - accountAvailabilityData: accountAvailability, - currentCapability, - regions, - }), - [accountAvailability, currentCapability, regions] - ); - - const handleRemoveOption = (regionToRemove: string) => { - const updatedSelectedOptions = selectedRegions.filter( - (option) => option.value !== regionToRemove - ); - const updatedSelectedIds = updatedSelectedOptions.map( - (region) => region.value - ); - setSelectedRegions(updatedSelectedOptions); - handleSelection(updatedSelectedIds); - }; + ) { + acc[region.id] = { + reason: + 'This region is currently unavailable. For help, open a support ticket.', + }; + } + return acc; + }, {}); return ( <> - Boolean(option.disabledProps?.disabled) - } - groupBy={(option: RegionSelectOption) => { - return option?.data?.region; + groupBy={(option) => { + if (!option.site_type) { + // Render empty group for "Select All / Deselect All" + return ''; + } + return getRegionCountryGroup(option); }} - isOptionEqualToValue={( - option: RegionSelectOption, - value: RegionSelectOption - ) => option.value === value.value} - onChange={(_, selectedOption) => - handleRegionChange(selectedOption as RegionSelectOption[]) + onChange={(_, selectedOptions) => + onChange(selectedOptions.map((region) => region.id)) } - onClose={() => { - const selectedIds = selectedRegions.map((region) => region.value); - handleSelection(selectedIds); - }} renderOption={(props, option, { selected }) => { - if (!option.data) { - // Render options like "Select All / Deselect All " + if (!option.site_type) { + // Render options like "Select All / Deselect All" return {option.label}; } // Render regular options return ( ); @@ -158,11 +137,11 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { return tagValue.map((option, index) => ( } key={index} - label={} - onDelete={() => handleRemoveOption(option.value)} + label={} + onDelete={() => handleRemoveOption(option.id)} /> )); }} @@ -184,11 +163,12 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} + getOptionDisabled={(option) => Boolean(disabledRegions[option.id])} label={label ?? 'Regions'} loading={accountAvailabilityLoading} multiple noOptionsText="No results" - options={options} + options={regionOptions} placeholder={placeholder ?? 'Select Regions'} value={selectedRegions} /> diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 33c4a1b36f9..d5d87778acd 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -13,34 +13,37 @@ import { StyledListItem, sxDistributedRegionIcon, } from './RegionSelect.styles'; -import { RegionSelectOption } from './RegionSelect.types'; +import type { DisableRegionOption } from './RegionSelect.types'; +import type { Region } from '@linode/api-v4'; import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; -type Props = { - displayDistributedRegionIcon?: boolean; - option: RegionSelectOption; +interface Props { + disabledOptions?: DisableRegionOption; props: React.HTMLAttributes; + region: Region; selected?: boolean; -}; +} export const RegionOption = ({ - displayDistributedRegionIcon, - option, + disabledOptions, props, + region, selected, }: Props) => { const { className, onClick } = props; - const { data, disabledProps, label, value } = option; - const isRegionDisabled = Boolean(disabledProps?.disabled); - const isRegionDisabledReason = disabledProps?.reason; + const isRegionDisabled = Boolean(disabledOptions); + const isRegionDisabledReason = disabledOptions?.reason; + + const displayDistributedRegionIcon = + region.site_type === 'edge' || region.site_type === 'distributed'; return ( @@ -72,9 +74,9 @@ export const RegionOption = ({ <> - + - {label} + {region.label} ({region.id}) {displayDistributedRegionIcon && (  (This region is a distributed region.) @@ -84,7 +86,7 @@ export const RegionOption = ({ {isRegionDisabledReason} )} - {selected && } + {selected && } {displayDistributedRegionIcon && ( } diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx index fc7b5707a6d..45e9aa8a17d 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx @@ -1,3 +1,4 @@ +import { useArgs } from '@storybook/preview-api'; import React from 'react'; import { regions } from 'src/__data__/regionsData'; @@ -11,14 +12,12 @@ import type { Meta, StoryObj } from '@storybook/react'; export const Default: StoryObj = { render: (args) => { const SelectWrapper = () => { - const [open, setOpen] = React.useState(false); - + const [_, updateArgs] = useArgs(); return ( setOpen(false)} - open={open} + onChange={(e, region) => updateArgs({ value: region?.id })} /> ); @@ -34,11 +33,10 @@ const meta: Meta = { disabled: false, errorText: '', helperText: '', - isClearable: false, label: 'Region', regions, required: true, - selectedId: regions[2].id, + value: regions[2].id, }, component: RegionSelect, title: 'Components/Selects/Region Select', diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx index 13fe9614d57..bd97e2a64a1 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx @@ -15,13 +15,12 @@ describe('RegionSelect', () => { currentCapability: 'Linodes', disabled: false, errorText: '', - handleSelection: vi.fn(), + onChange: vi.fn(), helperText: '', - isClearable: false, label: '', regions, required: false, - selectedId: '', + value: '', tooltipText: '', width: 100, }; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 745857911a1..533201a3fa3 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -7,6 +7,7 @@ import { Flag } from 'src/components/Flag'; import { Link } from 'src/components/Link'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import { RegionOption } from './RegionOption'; import { @@ -15,12 +16,16 @@ import { StyledFlagContainer, sxDistributedRegionIcon, } from './RegionSelect.styles'; -import { getRegionOptions, getSelectedRegionById } from './RegionSelect.utils'; +import { + getRegionOptions, + isRegionOptionUnavailable, +} from './RegionSelect.utils'; import type { - RegionSelectOption, + DisableRegionOption, RegionSelectProps, } from './RegionSelect.types'; +import type { Region } from '@linode/api-v4'; /** * A specific select for regions. @@ -31,22 +36,26 @@ import type { * * We do not display the selected check mark for single selects. */ -export const RegionSelect = React.memo((props: RegionSelectProps) => { +export const RegionSelect = < + DisableClearable extends boolean | undefined = undefined +>( + props: RegionSelectProps +) => { const { currentCapability, + disableClearable, disabled, + disabledRegions: disabledRegionsFromProps, errorText, - handleDisabledRegion, - handleSelection, helperText, - isClearable, label, + onChange, regionFilter, regions, required, - selectedId, showDistributedRegionIconHelperText, tooltipText, + value, width, } = props; @@ -55,77 +64,48 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { isLoading: accountAvailabilityLoading, } = useAllAccountAvailabilitiesQuery(); - const regionFromSelectedId: RegionSelectOption | null = - getSelectedRegionById({ - accountAvailabilityData: accountAvailability, - currentCapability, - regions, - selectedRegionId: selectedId ?? '', - }) ?? null; - - const [selectedRegion, setSelectedRegion] = React.useState< - RegionSelectOption | null | undefined - >(regionFromSelectedId); + const regionOptions = getRegionOptions({ + currentCapability, + regionFilter, + regions, + }); - const handleRegionChange = (selection: RegionSelectOption | null) => { - setSelectedRegion(selection); - handleSelection(selection?.value || ''); - }; + const selectedRegion = value + ? regionOptions.find((r) => r.id === value) + : null; - React.useEffect(() => { - if (selectedId) { - setSelectedRegion(regionFromSelectedId); - } else { - // We need to reset the state when create types change - setSelectedRegion(null); + const disabledRegions = regionOptions.reduce< + Record + >((acc, region) => { + if (disabledRegionsFromProps?.[region.id]) { + acc[region.id] = disabledRegionsFromProps[region.id]; } - }, [selectedId, regions]); - - const options = React.useMemo( - () => - getRegionOptions({ + if ( + isRegionOptionUnavailable({ accountAvailabilityData: accountAvailability, currentCapability, - handleDisabledRegion, - regionFilter, - regions, - }), - [ - accountAvailability, - currentCapability, - handleDisabledRegion, - regions, - regionFilter, - ] - ); + region, + }) + ) { + acc[region.id] = { + reason: + 'This region is currently unavailable. For help, open a support ticket.', + }; + } + return acc; + }, {}); return ( - - Boolean(option.disabledProps?.disabled) - } - isOptionEqualToValue={( - option: RegionSelectOption, - { value }: RegionSelectOption - ) => option.value === value} - onChange={(_, selectedOption: RegionSelectOption) => { - handleRegionChange(selectedOption); - }} - renderOption={(props, option) => { - return ( - - ); - }} + + renderOption={(props, region) => ( + + )} sx={(theme) => ({ [theme.breakpoints.up('md')]: { width: '416px', @@ -134,20 +114,19 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { textFieldProps={{ ...props.textFieldProps, InputProps: { - endAdornment: regionFilter !== 'core' && - (selectedRegion?.site_type === 'distributed' || - selectedRegion?.site_type === 'edge') && ( - } - status="other" - sxTooltipIcon={sxDistributedRegionIcon} - text="This region is a distributed region." - /> - ), + endAdornment: (selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge') && ( + } + status="other" + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." + /> + ), required, startAdornment: selectedRegion && ( - + ), }, @@ -156,18 +135,21 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { autoHighlight clearOnBlur data-testid="region-select" - disableClearable={!isClearable} + disableClearable={disableClearable} disabled={disabled} errorText={errorText} - groupBy={(option: RegionSelectOption) => option.data.region} + getOptionDisabled={(option) => Boolean(disabledRegions[option.id])} + getOptionLabel={(region) => `${region.label} (${region.id})`} + groupBy={(option) => getRegionCountryGroup(option)} helperText={helperText} label={label ?? 'Region'} loading={accountAvailabilityLoading} loadingText="Loading regions..." noOptionsText="No results" - options={options} + onChange={onChange} + options={regionOptions} placeholder="Select a Region" - value={selectedRegion} + value={selectedRegion as Region} /> {showDistributedRegionIconHelperText && ( // @TODO Gecko Beta: Add docs link @@ -190,4 +172,4 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { )} ); -}); +}; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index e7a37e6c9dd..83413b4bae0 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -1,33 +1,30 @@ -import React from 'react'; - import type { AccountAvailability, Capabilities, - Country, Region, RegionSite, } from '@linode/api-v4'; +import type React from 'react'; import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Autocomplete'; -export interface RegionSelectOption { - data: { - country: Country; - region: string; - }; - disabledProps?: { - disabled: boolean; - reason?: JSX.Element | string; - tooltipWidth?: number; - }; - label: string; - site_type: RegionSite; - value: string; +export interface DisableRegionOption { + /** + * The reason the region option is disabled. + * This is shown to the user as a tooltip. + */ + reason: JSX.Element | string; + /** + * An optional minWith applied to the tooltip + * @default 215 + */ + tooltipWidth?: number; } -export interface RegionSelectProps - extends Omit< - EnhancedAutocompleteProps, - 'label' | 'onChange' | 'options' +export interface RegionSelectProps< + DisableClearable extends boolean | undefined = undefined +> extends Omit< + EnhancedAutocompleteProps, + 'label' | 'options' | 'value' > { /** * The specified capability to filter the regions on. Any region that does not have the `currentCapability` will not appear in the RegionSelect dropdown. @@ -37,71 +34,50 @@ export interface RegionSelectProps * See `ImageUpload.tsx` for an example of a RegionSelect with an undefined `currentCapability` - there is no capability associated with Images yet. */ currentCapability: Capabilities | undefined; - handleDisabledRegion?: ( - region: Region - ) => RegionSelectOption['disabledProps']; - handleSelection: (id: string) => void; + /** + * A key/value object for disabling regions by their ID. + */ + disabledRegions?: Record; helperText?: string; - isClearable?: boolean; label?: string; regionFilter?: RegionSite; regions: Region[]; required?: boolean; - selectedId: null | string; showDistributedRegionIconHelperText?: boolean; tooltipText?: string; + /** + * The ID of the selected region. + */ + value: string | undefined; width?: number; } export interface RegionMultiSelectProps extends Omit< - EnhancedAutocompleteProps, + EnhancedAutocompleteProps, 'label' | 'onChange' | 'options' > { SelectedRegionsList?: React.ComponentType<{ onRemove: (region: string) => void; - selectedRegions: RegionSelectOption[]; + selectedRegions: Region[]; }>; currentCapability: Capabilities | undefined; - handleSelection: (ids: string[]) => void; helperText?: string; isClearable?: boolean; label?: string; + onChange: (ids: string[]) => void; regions: Region[]; required?: boolean; selectedIds: string[]; - sortRegionOptions?: (a: RegionSelectOption, b: RegionSelectOption) => number; + sortRegionOptions?: (a: Region, b: Region) => number; tooltipText?: string; width?: number; } -export interface RegionOptionAvailability { +export interface GetRegionOptionAvailability { accountAvailabilityData: AccountAvailability[] | undefined; currentCapability: Capabilities | undefined; - handleDisabledRegion?: ( - region: Region - ) => RegionSelectOption['disabledProps']; -} - -export interface GetRegionOptions extends RegionOptionAvailability { - regionFilter?: RegionSite; - regions: Region[]; -} - -export interface GetSelectedRegionById extends RegionOptionAvailability { - regions: Region[]; - selectedRegionId: string; -} - -export interface GetRegionOptionAvailability extends RegionOptionAvailability { region: Region; } -export interface GetSelectedRegionsByIdsArgs { - accountAvailabilityData: AccountAvailability[] | undefined; - currentCapability: Capabilities | undefined; - regions: Region[]; - selectedRegionIds: string[]; -} - export type SupportedDistributedRegionTypes = 'Distributions' | 'StackScripts'; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index d7c05901120..22ba45e2dc8 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -2,271 +2,190 @@ import { accountAvailabilityFactory, regionFactory } from 'src/factories'; import { getRegionOptions, - getSelectedRegionById, - getSelectedRegionsByIds, isRegionOptionUnavailable, } from './RegionSelect.utils'; -import type { RegionSelectOption } from './RegionSelect.types'; -import type { Region } from '@linode/api-v4'; - -const accountAvailabilityData = [ - accountAvailabilityFactory.build({ - region: 'ap-south', - unavailable: ['Linodes'], - }), -]; - -const regions: Region[] = [ - regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'us-1', - label: 'US Location', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'ca', - id: 'ca-1', - label: 'CA Location', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'jp', - id: 'jp-1', - label: 'JP Location', - }), -]; - -const distributedRegions = [ - ...regions, - regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'us-den-10', - label: 'Gecko Distributed Region Test', - site_type: 'distributed', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'us-den-11', - label: 'Gecko Distributed Region Test 2', - site_type: 'distributed', - }), -]; - -const expectedRegions: RegionSelectOption[] = [ - { - data: { - country: 'us', - region: 'North America', - }, - disabledProps: { - disabled: false, - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }, - { - data: { country: 'ca', region: 'North America' }, - disabledProps: { - disabled: false, - }, - label: 'CA Location (ca-1)', - site_type: 'core', - value: 'ca-1', - }, - { - data: { country: 'jp', region: 'Asia' }, - disabledProps: { - disabled: false, - }, - label: 'JP Location (jp-1)', - site_type: 'core', - value: 'jp-1', - }, -]; - -const expectedDistributedRegions = [ - { - data: { country: 'us', region: 'North America' }, - disabledProps: { - disabled: false, - }, - label: 'Gecko Distributed Region Test (us-den-10)', - site_type: 'distributed', - value: 'us-den-10', - }, - { - data: { country: 'us', region: 'North America' }, - disabledProps: { - disabled: false, - }, - label: 'Gecko Distributed Region Test 2 (us-den-11)', - site_type: 'distributed', - value: 'us-den-11', - }, -]; - describe('getRegionOptions', () => { it('should return an empty array if no regions are provided', () => { - const regions: Region[] = []; const result = getRegionOptions({ - accountAvailabilityData, currentCapability: 'Linodes', - regions, + regions: [], }); expect(result).toEqual([]); }); - it('should return a sorted array of OptionType objects with North America first', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, + it('should return a sorted array of regions with North America first', () => { + const regions = [ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'jp', + id: 'jp-1', + label: 'JP Location', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'us-1', + label: 'US Location', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'ca', + id: 'ca-1', + label: 'CA Location', + }), + ]; + + const result = getRegionOptions({ currentCapability: 'Linodes', regions, }); - expect(result).toEqual(expectedRegions); + expect(result).toEqual([ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'us-1', + label: 'US Location', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'ca', + id: 'ca-1', + label: 'CA Location', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'jp', + id: 'jp-1', + label: 'JP Location', + }), + ]); }); it('should filter out regions that do not have the currentCapability if currentCapability is provided', () => { - const regionsToFilter: Region[] = [ - ...regions, + const distributedRegions = [ regionFactory.build({ - capabilities: ['Object Storage'], - country: 'pe', - id: 'peru-1', - label: 'Peru Location', + capabilities: ['Linodes'], + country: 'us', + id: 'us-den-10', + label: 'Gecko Distributed Region Test', + site_type: 'distributed', + }), + regionFactory.build({ + capabilities: [], + country: 'us', + id: 'us-den-11', + label: 'Gecko Distributed Region Test 2', + site_type: 'distributed', }), ]; - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions: regionsToFilter, - }); - - expect(result).toEqual(expectedRegions); - }); - - it('should filter out distributed regions if regionFilter is core', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regionFilter: 'core', - regions: distributedRegions, - }); - - expect(result).toEqual(expectedRegions); - }); - - it('should filter out core regions if regionFilter is "distributed"', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regionFilter: 'distributed', - regions: distributedRegions, - }); - - expect(result).toEqual(expectedDistributedRegions); - }); - - it('should not filter out any regions if regionFilter is undefined', () => { - const regions = [...expectedDistributedRegions, ...expectedRegions]; - - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, + const result = getRegionOptions({ currentCapability: 'Linodes', - regionFilter: undefined, regions: distributedRegions, }); - expect(result).toEqual(regions); - }); - - it('should have its option disabled if the region is unavailable', () => { - const _regions = [ - ...regions, + expect(result).toEqual([ regionFactory.build({ capabilities: ['Linodes'], country: 'us', - id: 'ap-south', - label: 'US Location 2', + id: 'us-den-10', + label: 'Gecko Distributed Region Test', + site_type: 'distributed', }), - ]; - - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions: _regions, - }); - - const unavailableRegion = result.find( - (region) => region.value === 'ap-south' - ); - - expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + ]); }); - it('should have its option disabled if `handleDisabledRegion` is passed', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - handleDisabledRegion: (region) => ({ - ...region, - disabled: true, + it('should filter out distributed regions if regionFilter is core', () => { + const regions = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', }), + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]; + + const result = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'core', regions, }); - const unavailableRegion = result.find((region) => region.value === 'us-1'); - - expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + expect(result).toEqual([ + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]); }); -}); -describe('getSelectedRegionById', () => { - it('should return the correct OptionType for a selected region', () => { - const selectedRegionId = 'us-1'; + it('should filter out core regions if regionFilter is "distributed"', () => { + const regions = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]; - const result = getSelectedRegionById({ - accountAvailabilityData, - currentCapability: 'Linodes', + const result = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed', regions, - selectedRegionId, }); - // Expected result - const expected = { - data: { - country: 'us', - region: 'North America', - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }; - - expect(result).toEqual(expected); + expect(result).toEqual([ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + ]); }); - it('should return undefined for an unknown region', () => { - const selectedRegionId = 'unknown'; - - const result = getSelectedRegionById({ - accountAvailabilityData, - currentCapability: 'Linodes', + it('should not filter out any regions if regionFilter is undefined', () => { + const regions = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]; + const result = getRegionOptions({ + currentCapability: undefined, + regionFilter: undefined, regions, - selectedRegionId, }); - expect(result).toBeUndefined(); + expect(result).toEqual(regions); }); }); +const accountAvailabilityData = [ + accountAvailabilityFactory.build({ + region: 'ap-south', + unavailable: ['Linodes'], + }), +]; + describe('getRegionOptionAvailability', () => { it('should return true if the region is not available', () => { const result = isRegionOptionUnavailable({ @@ -292,64 +211,3 @@ describe('getRegionOptionAvailability', () => { expect(result).toBe(false); }); }); - -describe('getSelectedRegionsByIds', () => { - it('should return an array of RegionSelectOptions for the given selectedRegionIds', () => { - const selectedRegionIds = ['us-1', 'ca-1']; - - const result = getSelectedRegionsByIds({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions, - selectedRegionIds, - }); - - const expected = [ - { - data: { - country: 'us', - region: 'North America', - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }, - { - data: { - country: 'ca', - region: 'North America', - }, - label: 'CA Location (ca-1)', - site_type: 'core', - value: 'ca-1', - }, - ]; - - expect(result).toEqual(expected); - }); - - it('should exclude regions that are not found in the regions array', () => { - const selectedRegionIds = ['us-1', 'non-existent-region']; - - const result = getSelectedRegionsByIds({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions, - selectedRegionIds, - }); - - const expected = [ - { - data: { - country: 'us', - region: 'North America', - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }, - ]; - - expect(result).toEqual(expected); - }); -}); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 41c782d9e57..1c417cb7d6d 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -1,16 +1,13 @@ -import { CONTINENT_CODE_TO_CONTINENT } from '@linode/api-v4'; - import { - getRegionCountryGroup, - getSelectedRegion, -} from 'src/utilities/formatRegion'; + CONTINENT_CODE_TO_CONTINENT, + Capabilities, + RegionSite, +} from '@linode/api-v4'; + +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import type { GetRegionOptionAvailability, - GetRegionOptions, - GetSelectedRegionById, - GetSelectedRegionsByIdsArgs, - RegionSelectOption, SupportedDistributedRegionTypes, } from './RegionSelect.types'; import type { AccountAvailability, Region } from '@linode/api-v4'; @@ -18,97 +15,58 @@ import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types' const NORTH_AMERICA = CONTINENT_CODE_TO_CONTINENT.NA; -/** - * Returns an array of OptionType objects for use in the RegionSelect component. - * Handles the disabled state of each region based on the user's account availability or an optional custom handler. - * Regions are sorted alphabetically by region, with North America first. - * - * @returns An array of RegionSelectOption objects - */ +interface RegionSelectOptionsOptions { + currentCapability: Capabilities | undefined; + regionFilter?: RegionSite; + regions: Region[]; +} + export const getRegionOptions = ({ - accountAvailabilityData, currentCapability, - handleDisabledRegion, regionFilter, regions, -}: GetRegionOptions): RegionSelectOption[] => { - const filteredRegionsByCapability = currentCapability - ? regions.filter((region) => - region.capabilities.includes(currentCapability) - ) - : regions; - - const filteredRegionsByCapabilityAndSiteType = regionFilter - ? filteredRegionsByCapability.filter( - (region) => region.site_type === regionFilter - ) - : filteredRegionsByCapability; - - const isRegionUnavailable = (region: Region) => - isRegionOptionUnavailable({ - accountAvailabilityData, - currentCapability, - region, - }); - - return filteredRegionsByCapabilityAndSiteType - .map((region: Region) => { - const group = getRegionCountryGroup(region); - - // The region availability is the first check we run, regardless of the handleDisabledRegion function. - // This check always runs, and if the region is unavailable, the region will be disabled. - const disabledProps = isRegionUnavailable(region) - ? { - disabled: true, - reason: - 'This region is currently unavailable. For help, open a support ticket.', - tooltipWidth: 250, - } - : handleDisabledRegion?.(region)?.disabled - ? handleDisabledRegion(region) - : { - disabled: false, - }; - - return { - data: { - country: region.country, - region: group, - }, - disabledProps, - label: `${region.label} (${region.id})`, - site_type: region.site_type, - value: region.id, - }; +}: RegionSelectOptionsOptions) => { + return regions + .filter((region) => { + if ( + currentCapability && + !region.capabilities.includes(currentCapability) + ) { + return false; + } + if (regionFilter && region.site_type !== regionFilter) { + return false; + } + return true; }) .sort((region1, region2) => { + const region1Group = getRegionCountryGroup(region1); + const region2Group = getRegionCountryGroup(region2); + // North America group comes first if ( - region1.data.region === NORTH_AMERICA && - region2.data.region !== NORTH_AMERICA + region1Group === 'North America' && + region2Group !== 'North America' ) { return -1; } - if ( - region1.data.region !== NORTH_AMERICA && - region2.data.region === NORTH_AMERICA - ) { + if (region1Group !== NORTH_AMERICA && region2Group === NORTH_AMERICA) { return 1; } // Rest of the regions are sorted alphabetically - if (region1.data.region < region2.data.region) { + if (region1Group < region2Group) { return -1; } - if (region1.data.region > region2.data.region) { + if (region1Group > region2Group) { return 1; } // Then we group by country - if (region1.data.country < region2.data.country) { + if (region1.country < region2.country) { return 1; } - if (region1.data.country > region2.data.country) { + if (region1.country > region2.country) { return -1; } @@ -121,34 +79,6 @@ export const getRegionOptions = ({ }); }; -/** - * Util to map a region ID to an OptionType object. - * - * @returns an RegionSelectOption object for the currently selected region. - */ -export const getSelectedRegionById = ({ - regions, - selectedRegionId, -}: GetSelectedRegionById): RegionSelectOption | undefined => { - const selectedRegion = getSelectedRegion(regions, selectedRegionId); - - if (!selectedRegion) { - return undefined; - } - - const group = getRegionCountryGroup(selectedRegion); - - return { - data: { - country: selectedRegion?.country, - region: group, - }, - label: `${selectedRegion.label} (${selectedRegion.id})`, - site_type: selectedRegion.site_type, - value: selectedRegion.id, - }; -}; - /** * Util to determine if a region is available to the user for a given capability. * @@ -177,29 +107,6 @@ export const isRegionOptionUnavailable = ({ return regionWithUnavailability.unavailable.includes(currentCapability); }; -/** - * This utility function takes an array of region IDs and returns an array of corresponding RegionSelectOption objects. - * - * @returns An array of RegionSelectOption objects corresponding to the selected region IDs. - */ -export const getSelectedRegionsByIds = ({ - accountAvailabilityData, - currentCapability, - regions, - selectedRegionIds, -}: GetSelectedRegionsByIdsArgs): RegionSelectOption[] => { - return selectedRegionIds - .map((selectedRegionId) => - getSelectedRegionById({ - accountAvailabilityData, - currentCapability, - regions, - selectedRegionId, - }) - ) - .filter((region): region is RegionSelectOption => !!region); -}; - /** * Util to determine whether a create type has support for distributed regions. * diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index db01e6a6f9f..34e14dc58a6 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -22,7 +22,7 @@ export type RemovableItem = { // as 'any' because we do not know what types they could be. // Trying to type them as 'unknown' led to type errors. [key: string]: any; - id: number; + id: number | string; label: string; }; @@ -117,9 +117,9 @@ export const RemovableSelectionsList = ( // used to determine when to display a box-shadow to indicate scrollability const listRef = React.useRef(null); const [listHeight, setListHeight] = React.useState(0); - const [removingItemId, setRemovingItemId] = React.useState( - null - ); + const [removingItemId, setRemovingItemId] = React.useState< + null | number | string + >(null); const [isRemoving, setIsRemoving] = React.useState(false); React.useEffect(() => { diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 1fbe47fa423..4c53d1cf9e2 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -1,4 +1,3 @@ -import { Capabilities } from '@linode/api-v4/lib/regions'; import { useTheme } from '@mui/material'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; @@ -25,12 +24,13 @@ import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { Box } from '../Box'; import { DocsLink } from '../DocsLink/DocsLink'; import { Link } from '../Link'; -import { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; +import type { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; +import type { Capabilities } from '@linode/api-v4/lib/regions'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; interface SelectRegionPanelProps { - RegionSelectProps?: Partial; + RegionSelectProps?: Partial>; currentCapability: Capabilities; disabled?: boolean; error?: string; @@ -147,17 +147,18 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { ) : null} handleSelection(region.id)} regionFilter={hideDistributedRegions ? 'core' : undefined} regions={regions ?? []} - selectedId={selectedId || null} - showDistributedRegionIconHelperText={ - showDistributedRegionIconHelperText - } + value={selectedId} {...RegionSelectProps} /> {showClonePriceWarning && ( diff --git a/packages/manager/src/components/TagCell/TagCell.test.tsx b/packages/manager/src/components/TagCell/TagCell.test.tsx new file mode 100644 index 00000000000..63bfc371a12 --- /dev/null +++ b/packages/manager/src/components/TagCell/TagCell.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { TagCell } from './TagCell'; + +describe('TagCell Component', () => { + const tags = ['tag1', 'tag2']; + const updateTags = vi.fn(() => Promise.resolve()); + + describe('Disabled States', () => { + it('does not allow adding a new tag when disabled', async () => { + const { getByTestId } = renderWithTheme( + + ); + const disabledButton = getByTestId('Button'); + expect(disabledButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should display the tooltip if disabled and tooltipText is true', async () => { + const { getByTestId } = renderWithTheme( + + ); + const disabledButton = getByTestId('Button'); + expect(disabledButton).toBeInTheDocument(); + + fireEvent.mouseOver(disabledButton); + + await waitFor(() => { + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + expect( + screen.getByText( + 'You must be an unrestricted User in order to add or modify tags on Linodes.' + ) + ).toBeVisible(); + }); + }); +}); diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 7340707d0a9..9226281fee9 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -1,7 +1,6 @@ import MoreHoriz from '@mui/icons-material/MoreHoriz'; import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { SxProps } from '@mui/system'; import * as React from 'react'; import { IconButton } from 'src/components/IconButton'; @@ -13,6 +12,8 @@ import { StyledPlusIcon, StyledTagButton } from '../Button/StyledTagButton'; import { CircleProgress } from '../CircleProgress'; import { AddTag } from './AddTag'; +import type { SxProps } from '@mui/system'; + export interface TagCellProps { /** * Disable adding or deleting tags. @@ -83,6 +84,11 @@ export const TagCell = (props: TagCellProps) => { const AddButton = (props: { panel?: boolean }) => ( } diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index e480ea3f0c6..5ce4c7f1598 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -69,6 +69,7 @@ export const TextTooltip = (props: TextTooltipProps) => { data-qa-tooltip={dataQaTooltip} enterTouchDelay={0} placement={placement ? placement : 'bottom'} + tabIndex={0} title={tooltipText} > diff --git a/packages/manager/src/components/VLANSelect.tsx b/packages/manager/src/components/VLANSelect.tsx index 99d5fe82996..f95f584a680 100644 --- a/packages/manager/src/components/VLANSelect.tsx +++ b/packages/manager/src/components/VLANSelect.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useVLANsInfiniteQuery } from 'src/queries/vlans'; @@ -51,6 +51,14 @@ export const VLANSelect = (props: Props) => { const [open, setOpen] = React.useState(false); const [inputValue, setInputValue] = useState(''); + useEffect(() => { + if (!value && inputValue) { + // If the value gets cleared, make sure the TextField's value also gets cleared. + setInputValue(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + const apiFilter = getVLANSelectFilter({ defaultFilter: filter, inputValue, diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index dfc7908ec46..74986c22644 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -4,11 +4,12 @@ import * as React from 'react'; import { useDispatch } from 'react-redux'; import withFeatureFlagProvider from 'src/containers/withFeatureFlagProvider.container'; -import { FlagSet, Flags } from 'src/featureFlags'; -import { Dispatch } from 'src/hooks/types'; import { useFlags } from 'src/hooks/useFlags'; import { setMockFeatureFlags } from 'src/store/mockFeatureFlags'; import { getStorage, setStorage } from 'src/utilities/storage'; + +import type { FlagSet, Flags } from 'src/featureFlags'; +import type { Dispatch } from 'src/hooks/types'; const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; /** @@ -24,6 +25,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, { flag: 'eventMessagesV2', label: 'Event Messages V2' }, { flag: 'gecko2', label: 'Gecko' }, + { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, diff --git a/packages/manager/src/factories/stackscripts.ts b/packages/manager/src/factories/stackscripts.ts index 398ac04a5ce..9406658d0b8 100644 --- a/packages/manager/src/factories/stackscripts.ts +++ b/packages/manager/src/factories/stackscripts.ts @@ -29,7 +29,7 @@ export const stackScriptFactory = Factory.Sync.makeFactory({ export const oneClickAppFactory = Factory.Sync.makeFactory({ alt_description: 'A test app', alt_name: 'Test App', - categories: ['App Creators'], + categories: ['Databases'], colors: { end: '#000000', start: '#000000', diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index 5229d085b50..192c8219ec2 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -173,6 +173,43 @@ export const volumeTypeFactory = Factory.Sync.makeFactory({ transfer: 0, }); +export const lkeStandardAvailabilityTypeFactory = Factory.Sync.makeFactory( + { + id: 'lke-sa', + label: 'LKE Standard Availability', + price: { + hourly: 0.0, + monthly: 0.0, + }, + region_prices: [], + transfer: 0, + } +); + +export const lkeHighAvailabilityTypeFactory = Factory.Sync.makeFactory( + { + id: 'lke-ha', + label: 'LKE High Availability', + price: { + hourly: 0.09, + monthly: 60.0, + }, + region_prices: [ + { + hourly: 0.108, + id: 'id-cgk', + monthly: 72.0, + }, + { + hourly: 0.126, + id: 'br-gru', + monthly: 84.0, + }, + ], + transfer: 0, + } +); + export const objectStorageTypeFactory = Factory.Sync.makeFactory({ id: 'objectstorage', label: 'Object Storage', @@ -218,3 +255,40 @@ export const objectStorageOverageTypeFactory = Factory.Sync.makeFactory( + { + id: 'distributed_network_transfer', + label: 'Distributed Network Transfer', + price: { + hourly: 0.01, + monthly: null, + }, + region_prices: [], + transfer: 0, + } +); + +export const networkTransferPriceTypeFactory = Factory.Sync.makeFactory( + { + id: 'network_transfer', + label: 'Network Transfer', + price: { + hourly: 0.005, + monthly: null, + }, + region_prices: [ + { + hourly: 0.015, + id: 'id-cgk', + monthly: null, + }, + { + hourly: 0.007, + id: 'br-gru', + monthly: null, + }, + ], + transfer: 0, + } +); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 811f3b66097..ff4ae7b05d1 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -7,6 +7,14 @@ import type { NoticeVariant } from 'src/components/Notice/Notice'; export interface TaxDetail { qi_registration?: string; tax_id: string; + tax_ids?: Record< + 'B2B' | 'B2C', + { + tax_id: string; + tax_name: string; + } + >; + tax_info?: string; tax_name: string; } @@ -73,6 +81,7 @@ export interface Flags { gecko: boolean; // @TODO gecko: delete this after next release gecko2: GaFeatureFlag; gpuv2: gpuV2; + imageServiceGen2: boolean; ipv6Sharing: boolean; linodeCreateRefactor: boolean; linodeCreateWithFirewall: boolean; diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts index 20dd0a0cf42..1acc134b281 100644 --- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts +++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts @@ -1,22 +1,14 @@ -import { - Account, - Invoice, - InvoiceItem, - Payment, -} from '@linode/api-v4/lib/account'; import axios from 'axios'; import jsPDF from 'jspdf'; import { splitEvery } from 'ramda'; import { ADDRESSES } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; -import { FlagSet, TaxDetail } from 'src/featureFlags'; import { formatDate } from 'src/utilities/formatDate'; import { getShouldUseAkamaiBilling } from '../billingUtils'; import AkamaiLogo from './akamai-logo.png'; import { - PdfResult, createFooter, createInvoiceItemsTable, createInvoiceTotalsTable, @@ -27,7 +19,15 @@ import { pageMargin, } from './utils'; +import type { PdfResult } from './utils'; import type { Region } from '@linode/api-v4'; +import type { + Account, + Invoice, + InvoiceItem, + Payment, +} from '@linode/api-v4/lib/account'; +import type { FlagSet, TaxDetail } from 'src/featureFlags'; const baseFont = 'helvetica'; @@ -98,17 +98,29 @@ const addLeftHeader = ( doc.setFont(baseFont, 'normal'); if (countryTax) { - addLine(`${countryTax.tax_name}: ${countryTax.tax_id}`); + const { tax_id, tax_ids, tax_name } = countryTax; + + addLine(`${tax_name}: ${tax_id}`); + + if (tax_ids?.B2B) { + const { tax_id: b2bTaxId, tax_name: b2bTaxName } = tax_ids.B2B; + addLine(`${b2bTaxName}: ${b2bTaxId}`); + } } /** - * M3-7847 Add Akamai's Japanese QI System ID to Japanese Invoices. + * [M3-7847, M3-8008] Add Akamai's Japanese QI System ID to Japanese Invoices. * Since LD automatically serves Tax data based on the user's * we can check on qi_registration field to render QI Registration. * */ if (countryTax && countryTax.qi_registration) { - const line = `QI Registration # ${countryTax.qi_registration}`; - addLine(line); + const qiRegistration = `QI Registration # ${countryTax.qi_registration}`; + addLine(qiRegistration); + } + + if (countryTax?.tax_info) { + addLine(countryTax.tax_info); } + if (provincialTax) { addLine(`${provincialTax.tax_name}: ${provincialTax.tax_id}`); } @@ -227,7 +239,7 @@ export const printInvoice = async ( unit: 'px', }); - const convertedInvoiceDate = invoice.date && dateConversion(invoice.date); + const convertedInvoiceDate = dateConversion(invoice.date); const TaxStartDate = taxes && taxes?.date ? dateConversion(taxes.date) : Infinity; @@ -248,6 +260,7 @@ export const printInvoice = async ( * as of 2/20/2020 we have the following cases: * * VAT: Applies only to EU countries; started from 6/1/2019 and we have an EU tax id + * - [M3-8277] For EU customers, invoices will include VAT for B2C transactions and exclude VAT for B2B transactions. Both VAT numbers will be shown on the invoice template for EU countries. * GMT: Applies to both Australia and India, but we only have a tax ID for Australia. */ const hasTax = !taxes?.date ? true : convertedInvoiceDate > TaxStartDate; diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index f5ad47c691d..c134e02cd63 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -2,10 +2,12 @@ import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { WithStartAndEnd } from 'src/features/Longview/request.types'; +import { CloudPulseRegionSelect } from '../shared/CloudPulseRegionSelect'; +import { CloudPulseResourcesSelect } from '../shared/CloudPulseResourcesSelect'; +import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; -import { CloudPulseRegionSelect } from '../shared/RegionSelect'; -import { CloudPulseTimeRangeSelect } from '../shared/TimeRangeSelect'; +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { WithStartAndEnd } from 'src/features/Longview/request.types'; export interface GlobalFilterProperties { handleAnyFilterChange(filters: FiltersObject): undefined | void; @@ -26,6 +28,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { }); const [selectedRegion, setRegion] = React.useState(); + const [, setResources] = React.useState(); // removed the unused variable, this will be used later point of time React.useEffect(() => { const triggerGlobalFilterChange = () => { const globalFilters: FiltersObject = { @@ -54,6 +57,13 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { setRegion(region); }, []); + const handleResourcesSelection = React.useCallback( + (resources: CloudPulseResources[]) => { + setResources(resources); + }, + [] + ); + return ( @@ -62,7 +72,15 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleRegionChange={handleRegionChange} /> - + + + + + ({ alignItems: 'end', boxSizing: 'border-box', diff --git a/packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx similarity index 76% rename from packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx rename to packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index c1e24bb8f84..6f17a20649d 100644 --- a/packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -2,8 +2,8 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { CloudPulseRegionSelectProps } from './RegionSelect'; -import { CloudPulseRegionSelect } from './RegionSelect'; +import { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; +import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; const props: CloudPulseRegionSelectProps = { handleRegionChange: vi.fn(), diff --git a/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx similarity index 79% rename from packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx rename to packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index b60ba23564f..898f947a94a 100644 --- a/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; @@ -14,22 +13,22 @@ export const CloudPulseRegionSelect = React.memo( const [selectedRegion, setRegion] = React.useState(); React.useEffect(() => { - props.handleRegionChange(selectedRegion); + if (selectedRegion) { + props.handleRegionChange(selectedRegion); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedRegion]); return ( { - setRegion(value); - }} currentCapability={undefined} + disableClearable fullWidth - isClearable={false} label="" noMarginTop + onChange={(e, region) => setRegion(region.id)} regions={regions ? regions : []} - selectedId={null} + value={undefined} /> ); } diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx new file mode 100644 index 00000000000..41ece50f4f1 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, screen } from '@testing-library/react'; +import * as React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseResourcesSelect } from './CloudPulseResourcesSelect'; + +const queryMocks = vi.hoisted(() => ({ + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); + +const mockResourceHandler = vi.fn(); +const SELECT_ALL = 'Select All'; +const ARIA_SELECTED = 'aria-selected'; + +describe('CloudPulseResourcesSelect component tests', () => { + it('should render disabled component if the the props are undefined or regions and service type does not have any resources', () => { + const { getByPlaceholderText, getByTestId } = renderWithTheme( + + ); + expect(getByTestId('Resource-select')).toBeInTheDocument(); + expect(getByPlaceholderText('Select Resources')).toBeInTheDocument(); + }), + it('should render resources happy path', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(2), + isError: false, + isLoading: false, + status: 'success', + }); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { + name: 'linode-0', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: 'linode-1', + }) + ).toBeInTheDocument(); + }); + + it('should be able to select all resources', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(2), + isError: false, + isLoading: false, + status: 'success', + }); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: SELECT_ALL })); + expect( + screen.getByRole('option', { + name: 'linode-2', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + }); + + it('should be able to deselect the selected resources', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(2), + isError: false, + isLoading: false, + status: 'success', + }); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: SELECT_ALL })); + fireEvent.click(screen.getByRole('option', { name: 'Deselect All' })); + expect( + screen.getByRole('option', { + name: 'linode-4', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: 'linode-5', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should select multiple resources', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(3), + isError: false, + isLoading: false, + status: 'success', + }); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: 'linode-6' })); + fireEvent.click(screen.getByRole('option', { name: 'linode-7' })); + + expect( + screen.getByRole('option', { + name: 'linode-6', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-7', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-8', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: 'Select All', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx new file mode 100644 index 00000000000..b04b1c7b28b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +export interface CloudPulseResources { + id: number; + label: string; + region?: string; // usually linodes are associated with only one region + regions?: string[]; // aclb are associated with multiple regions +} + +export interface CloudPulseResourcesSelectProps { + defaultSelection?: number[]; + handleResourcesSelection: (resources: CloudPulseResources[]) => void; + placeholder?: string; + region: string | undefined; + resourceType: string | undefined; +} + +export const CloudPulseResourcesSelect = React.memo( + (props: CloudPulseResourcesSelectProps) => { + const [selectedResource, setResources] = React.useState< + CloudPulseResources[] + >([]); + const { data: resources, isLoading } = useResourcesQuery( + props.region && props.resourceType ? true : false, + props.resourceType, + {}, + { region: props.region } + ); + + const getResourcesList = (): CloudPulseResources[] => { + return resources && resources.length > 0 ? resources : []; + }; + + React.useEffect(() => { + const defaultResources = resources?.filter((instance) => + props.defaultSelection?.includes(instance.id) + ); + + if (defaultResources && defaultResources.length > 0) { + setResources(defaultResources); + props.handleResourcesSelection(defaultResources!); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resources, props.region]); // only on any resources or region change, select defaults if any + + return ( + { + setResources(resourceSelections); + props.handleResourcesSelection(resourceSelections); + }} + autoHighlight + clearOnBlur + data-testid={'Resource-select'} + disabled={!props.region || !props.resourceType || isLoading} + isOptionEqualToValue={(option, value) => option.label === value.label} + label="" + limitTags={2} + multiple + options={getResourcesList()} + placeholder={props.placeholder ? props.placeholder : 'Select Resources'} + value={selectedResource ? selectedResource : []} + /> + ); + }, + compareProps // we can re-render this component, on only region and resource type changes +); + +function compareProps( + oldProps: CloudPulseResourcesSelectProps, + newProps: CloudPulseResourcesSelectProps +) { + return ( + oldProps.region == newProps.region && + oldProps.resourceType == newProps.resourceType + ); +} diff --git a/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx similarity index 95% rename from packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx rename to packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx index c7fd4c7020f..5d6bc306e72 100644 --- a/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx @@ -1,6 +1,6 @@ import { DateTime } from 'luxon'; -import { generateStartTime } from './TimeRangeSelect'; +import { generateStartTime } from './CloudPulseTimeRangeSelect'; describe('Utility Functions', () => { it('should create values as functions that return the correct datetime', () => { diff --git a/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx similarity index 100% rename from packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.tsx rename to packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 15b7feec0b2..36a598f3b4e 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -496,13 +496,12 @@ const DatabaseCreate = () => { - setFieldValue('region', selected) - } currentCapability="Managed Databases" + disableClearable errorText={errors.region} + onChange={(e, region) => setFieldValue('region', region.id)} regions={regionsData} - selectedId={values.region} + value={values.region} /> diff --git a/packages/manager/src/features/Events/EventRowV2.tsx b/packages/manager/src/features/Events/EventRowV2.tsx index 7a37cd42767..3f885b7bccc 100644 --- a/packages/manager/src/features/Events/EventRowV2.tsx +++ b/packages/manager/src/features/Events/EventRowV2.tsx @@ -1,7 +1,7 @@ // TODO eventMessagesV2: rename to EventRow.tsx when flag is removed -import { Event } from '@linode/api-v4/lib/account'; import * as React from 'react'; +import { BarPercent } from 'src/components/BarPercent'; import { Box } from 'src/components/Box'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Hidden } from 'src/components/Hidden'; @@ -9,7 +9,10 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { getEventTimestamp } from 'src/utilities/eventUtils'; -import { getEventMessage } from './utils'; +import { StyledGravatar } from './EventRow.styles'; +import { formatProgressEvent, getEventMessage } from './utils'; + +import type { Event } from '@linode/api-v4/lib/account'; interface EventRowProps { entityId?: number; @@ -29,18 +32,32 @@ export const EventRowV2 = (props: EventRowProps) => { return null; } + const { progressEventDisplay, showProgress } = formatProgressEvent(event); + return ( - {message} + {message} + {showProgress && ( + + )} - {username ?? 'Linode'} + + + {username ?? 'Linode'} + - {timestamp.toRelative()} + {progressEventDisplay} {username && (
    diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 6cb75abaa2b..0caf895222f 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -10,6 +10,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; import { useFlags } from 'src/hooks/useFlags'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; @@ -22,8 +23,6 @@ import { StyledTypography, } from './EventsLanding.styles'; -import type { Filter } from '@linode/api-v4'; - interface Props { emptyMessage?: string; // Custom message for the empty state (i.e. no events). entityId?: number; @@ -33,7 +32,7 @@ export const EventsLanding = (props: Props) => { const { emptyMessage, entityId } = props; const flags = useFlags(); - const filter: Filter = { action: { '+neq': 'profile_update' } }; + const filter = { ...EVENTS_LIST_FILTER }; if (entityId) { filter['entity.id'] = entityId; @@ -117,7 +116,7 @@ export const EventsLanding = (props: Props) => {
    )} - Relative Date + Relative Date Absolute Date diff --git a/packages/manager/src/features/Events/constants.ts b/packages/manager/src/features/Events/constants.ts index ff858a43a21..ad07694713c 100644 --- a/packages/manager/src/features/Events/constants.ts +++ b/packages/manager/src/features/Events/constants.ts @@ -1,5 +1,5 @@ // TODO eventMessagesV2: delete when flag is removed -import type { Event } from '@linode/api-v4/lib/account'; +import type { Event } from '@linode/api-v4'; export const EVENT_ACTIONS: Event['action'][] = [ 'account_settings_update', @@ -129,3 +129,40 @@ export const EVENT_STATUSES: Event['status'][] = [ 'failed', 'notification', ]; + +export const ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS: Event['action'][] = [ + 'linode_resize', + 'linode_migrate', + 'linode_migrate_datacenter', + 'disk_imagize', + 'linode_boot', + 'host_reboot', + 'lassie_reboot', + 'linode_reboot', + 'linode_shutdown', + 'linode_delete', + 'linode_clone', + 'disk_resize', + 'disk_duplicate', + 'backups_restore', + 'linode_snapshot', + 'linode_mutate', + 'linode_rebuild', + 'linode_create', + 'image_upload', + 'volume_migrate', + 'database_resize', +]; + +/** + * This is our base filter for GETing /v4/account/events. + * + * We exclude `profile_update` events because they are generated + * often (by updating user preferences for example) and we don't + * need them. + * + * @readonly Do not modify this object + */ +export const EVENTS_LIST_FILTER = Object.freeze({ + action: { '+neq': 'profile_update' }, +}); diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx index 7dd6a42376b..ba13193cdf1 100644 --- a/packages/manager/src/features/Events/factories/linode.tsx +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -1,10 +1,15 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useTypeQuery } from 'src/queries/types'; +import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { EventLink } from '../EventLink'; import type { PartialEventMap } from '../types'; +import type { Event } from '@linode/api-v4'; export const linode: PartialEventMap<'linode'> = { linode_addip: { @@ -285,12 +290,7 @@ export const linode: PartialEventMap<'linode'> = { migrated. ), - started: (e) => ( - <> - Linode is being{' '} - migrated. - - ), + started: (e) => , }, linode_migrate_datacenter_create: { notification: (e) => ( @@ -453,11 +453,7 @@ export const linode: PartialEventMap<'linode'> = { resizing. ), - started: (e) => ( - <> - Linode is resizing. - - ), + started: (e) => , }, linode_resize_create: { notification: (e) => ( @@ -540,3 +536,46 @@ export const linode: PartialEventMap<'linode'> = { ), }, }; + +const LinodeMigrateDataCenterMessage = ({ event }: { event: Event }) => { + const { data: linode } = useLinodeQuery(event.entity?.id ?? -1); + const { data: regions } = useRegionsQuery(); + const region = regions?.find((r) => r.id === linode?.region); + + return ( + <> + Linode is being{' '} + migrated + {region && ( + <> + {' '} + to {region.label} + + )} + . + + ); +}; + +const LinodeResizeStartedMessage = ({ event }: { event: Event }) => { + const { data: linode } = useLinodeQuery(event.entity?.id ?? -1); + const type = useTypeQuery(linode?.type ?? ''); + + return ( + <> + Linode is{' '} + resizing + {type && ( + <> + {' '} + to the{' '} + {type.data && ( + {formatStorageUnits(type.data.label)} + )}{' '} + Plan + + )} + . + + ); +}; diff --git a/packages/manager/src/features/Events/factories/tax.tsx b/packages/manager/src/features/Events/factories/tax.tsx index f107718a8e1..5ac7cb45211 100644 --- a/packages/manager/src/features/Events/factories/tax.tsx +++ b/packages/manager/src/features/Events/factories/tax.tsx @@ -4,6 +4,10 @@ import type { PartialEventMap } from '../types'; export const tax: PartialEventMap<'tax'> = { tax_id_invalid: { - notification: () => <>Tax Identification Number format is invalid., + notification: () => ( + <> + Tax Identification Number format is invalid. + + ), }, }; diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx index d08e630e415..89ce3f0c328 100644 --- a/packages/manager/src/features/Events/utils.test.tsx +++ b/packages/manager/src/features/Events/utils.test.tsx @@ -1,9 +1,14 @@ -import { Event } from '@linode/api-v4'; - import { eventFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { getEventMessage } from './utils'; +import { + formatEventTimeRemaining, + formatProgressEvent, + getEventMessage, +} from './utils'; + +import type { Event } from '@linode/api-v4'; +import { DateTime } from 'luxon'; describe('getEventMessage', () => { const mockEvent1: Event = eventFactory.build({ @@ -81,3 +86,79 @@ describe('getEventMessage', () => { expect(boldedWords[1]).toHaveTextContent('created'); }); }); + +describe('formatEventTimeRemaining', () => { + it('returns null if the time is null', () => { + expect(formatEventTimeRemaining(null)).toBeNull(); + }); + + it('returns null if the time is not formatted correctly', () => { + expect(formatEventTimeRemaining('12:34')).toBeNull(); + }); + + it('returns the formatted time remaining', () => { + expect(formatEventTimeRemaining('0:45:31')).toBe('46 minutes remaining'); + }); + + it('returns the formatted time remaining', () => { + expect(formatEventTimeRemaining('1:23:45')).toBe('1 hour remaining'); + }); +}); + +describe('formatProgressEvent', () => { + const mockEvent1: Event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + percent_complete: null, + status: 'finished', + }); + + const mockEvent2: Event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + percent_complete: 50, + status: 'started', + }); + + it('returns the correct format for a finished Event', () => { + const currentDateMock = DateTime.fromISO(mockEvent1.created).plus({ + seconds: 1, + }); + vi.setSystemTime(currentDateMock.toJSDate()); + const { progressEventDisplay, showProgress } = formatProgressEvent( + mockEvent1 + ); + + expect(progressEventDisplay).toBe('1 second ago'); + expect(showProgress).toBe(false); + }); + + it('returns the correct format for a "started" event without time remaining info', () => { + const currentDateMock = DateTime.fromISO(mockEvent2.created).plus({ + seconds: 1, + }); + vi.setSystemTime(currentDateMock.toJSDate()); + const { progressEventDisplay, showProgress } = formatProgressEvent( + mockEvent2 + ); + + expect(progressEventDisplay).toBe('Started 1 second ago'); + expect(showProgress).toBe(true); + }); + + it('returns the correct format for a "started" event with time remaining', () => { + const { progressEventDisplay, showProgress } = formatProgressEvent({ + ...mockEvent2, + + time_remaining: '0:50:00', + }); + expect(progressEventDisplay).toBe('~50 minutes remaining'); + expect(showProgress).toBe(true); + }); +}); diff --git a/packages/manager/src/features/Events/utils.tsx b/packages/manager/src/features/Events/utils.tsx index 291599ab218..a7d3f301796 100644 --- a/packages/manager/src/features/Events/utils.tsx +++ b/packages/manager/src/features/Events/utils.tsx @@ -1,3 +1,9 @@ +import { Duration } from 'luxon'; + +import { ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS } from 'src/features/Events/constants'; +import { isInProgressEvent } from 'src/queries/events/event.helpers'; +import { getEventTimestamp } from 'src/utilities/eventUtils'; + import { eventMessages } from './factory'; import type { Event } from '@linode/api-v4'; @@ -38,3 +44,83 @@ export function getEventMessage( return message ? message(event as Event) : null; } + +/** + * Format the time remaining for an event. + * This is used for the progress events in the notification center. + */ +export const formatEventTimeRemaining = (time: null | string) => { + if (!time) { + return null; + } + + try { + const [hours, minutes, seconds] = time.split(':').map(Number); + if ( + [hours, minutes, seconds].some( + (thisNumber) => typeof thisNumber === 'undefined' + ) || + [hours, minutes, seconds].some(isNaN) + ) { + // Bad input, don't display a duration + return null; + } + const duration = Duration.fromObject({ hours, minutes, seconds }); + return hours > 0 + ? `${Math.round(duration.as('hours'))} ${ + hours > 1 ? 'hours' : 'hour' + } remaining` + : `${Math.round(duration.as('minutes'))} minutes remaining`; + } catch { + // Broken/unexpected input + return null; + } +}; + +/** + * Determines if the progress bar should be shown for an event (in the notification center or on the event page). + * + * Progress events are determined based on `event.percent_complete` being defined and < 100. + * However, some events are not worth showing progress for, usually because they complete too quickly. + * To that effect, we have an `.includes` for progress events. + * A new action should be added to `ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS` to ensure the display of the progress bar. + * + * Additionally, we only want to show the progress bar if the event is not in a scheduled state. + * For some reason the API will return a percent_complete value for scheduled events. + */ +const shouldShowEventProgress = (event: Event): boolean => { + const isProgressEvent = isInProgressEvent(event); + + return ( + isProgressEvent && + ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS.includes(event.action) && + event.status !== 'scheduled' + ); +}; + +interface ProgressEventDisplay { + progressEventDisplay: null | string; + showProgress: boolean; +} + +/** + * Format the event for display in the notification center and event page. + * + * If the event is a progress event, we'll show the time remaining, if available. + * Else, we'll show the time the event occurred, relative to now. + */ +export const formatProgressEvent = (event: Event): ProgressEventDisplay => { + const showProgress = shouldShowEventProgress(event); + const parsedTimeRemaining = formatEventTimeRemaining(event.time_remaining); + + const progressEventDisplay = showProgress + ? parsedTimeRemaining + ? `~${parsedTimeRemaining}` + : `Started ${getEventTimestamp(event).toRelative()}` + : getEventTimestamp(event).toRelative(); + + return { + progressEventDisplay, + showProgress, + }; +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index b2698e00c3a..5667d69ada9 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -1,6 +1,4 @@ -import { NodeBalancer } from '@linode/api-v4'; import { useTheme } from '@mui/material'; -import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useParams } from 'react-router-dom'; @@ -16,12 +14,13 @@ import { useAddFirewallDeviceMutation, useAllFirewallsQuery, } from 'src/queries/firewalls'; -import { queryKey } from 'src/queries/nodebalancers'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; +import type { NodeBalancer } from '@linode/api-v4'; + interface Props { helperText: string; onClose: () => void; @@ -35,9 +34,8 @@ export const AddNodebalancerDrawer = (props: Props) => { const { data: grants } = useGrants(); const { data: profile } = useProfile(); const isRestrictedUser = Boolean(profile?.restricted); - const queryClient = useQueryClient(); - const { data, error, isLoading } = useAllFirewallsQuery(); + const { data, error, isLoading } = useAllFirewallsQuery(open); const firewall = data?.find((firewall) => firewall.id === Number(id)); @@ -73,12 +71,6 @@ export const AddNodebalancerDrawer = (props: Props) => { enqueueSnackbar(`NodeBalancer ${label} successfully added`, { variant: 'success', }); - queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - id, - 'firewalls', - ]); return; } failedNodebalancers.push(selectedNodebalancers[index]); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 0d9c3cd2d7a..98b43f25baf 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -1,4 +1,3 @@ -import { FirewallDevice } from '@linode/api-v4'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -7,9 +6,10 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; import { useRemoveFirewallDeviceMutation } from 'src/queries/firewalls'; -import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; + +import type { FirewallDevice } from '@linode/api-v4'; export interface Props { device: FirewallDevice | undefined; @@ -36,10 +36,16 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { const deviceDialog = deviceType === 'linode' ? 'Linode' : 'NodeBalancer'; const onDelete = async () => { + if (!device) { + return; + } + await mutateAsync(); + const toastMessage = onService ? `Firewall ${firewallLabel} successfully unassigned` - : `${deviceDialog} ${device?.entity.label} successfully removed`; + : `${deviceDialog} ${device.entity.label} successfully removed`; + enqueueSnackbar(toastMessage, { variant: 'success', }); @@ -48,18 +54,19 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { enqueueSnackbar(error[0].reason, { variant: 'error' }); } - const queryKey = - deviceType === 'linode' ? linodesQueryKey : nodebalancersQueryKey; - // Since the linode was removed as a device, invalidate the linode-specific firewall query - queryClient.invalidateQueries([ - queryKey, - deviceType, - device?.entity.id, - 'firewalls', - ]); - - queryClient.invalidateQueries([firewallQueryKey]); + if (deviceType === 'linode') { + queryClient.invalidateQueries({ + queryKey: [linodesQueryKey, deviceType, device.entity.id, 'firewalls'], + }); + } + + if (deviceType === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(device.entity.id)._ctx + .firewalls.queryKey, + }); + } onClose(); }; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 5d978b108fc..d888aa9759d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -13,7 +13,7 @@ import { useUpdateFirewallRulesMutation, } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { FirewallRuleDrawer } from './FirewallRuleDrawer'; @@ -202,17 +202,26 @@ export const FirewallRulesLanding = React.memo((props: Props) => { .then((_rules) => { setSubmitting(false); // Invalidate Firewalls assigned to NodeBalancers and Linodes. - // eslint-disable-next-line no-unused-expressions - devices?.forEach((device) => - queryClient.invalidateQueries([ - device.entity.type === 'linode' - ? linodesQueryKey - : nodebalancersQueryKey, - device.entity.type, - device.entity.id, - 'firewalls', - ]) - ); + if (devices) { + for (const device of devices) { + if (device.entity.type === 'linode') { + queryClient.invalidateQueries({ + queryKey: [ + linodesQueryKey, + device.entity.type, + device.entity.id, + 'firewalls', + ], + }); + } + if (device.entity.type === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(device.entity.id) + ._ctx.firewalls.queryKey, + }); + } + } + } // Reset editor state. inboundDispatch({ rules: _rules.inbound ?? [], type: 'RESET' }); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 08be0d94aae..e24524718ab 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -1,13 +1,5 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ -import { Linode } from '@linode/api-v4'; -import { - CreateFirewallPayload, - Firewall, - FirewallDeviceEntityType, -} from '@linode/api-v4/lib/firewalls'; -import { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; import { CreateFirewallSchema } from '@linode/validation/lib/firewalls.schema'; -import { useQueryClient } from '@tanstack/react-query'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -27,13 +19,7 @@ import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { - queryKey as firewallQueryKey, - useAllFirewallsQuery, - useCreateFirewall, -} from 'src/queries/firewalls'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; +import { useAllFirewallsQuery, useCreateFirewall } from 'src/queries/firewalls'; import { useGrants } from 'src/queries/profile/profile'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -49,6 +35,13 @@ import { NODEBALANCER_CREATE_FLOW_TEXT, } from './constants'; +import type { + CreateFirewallPayload, + Firewall, + FirewallDeviceEntityType, + Linode, + NodeBalancer, +} from '@linode/api-v4'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; export const READ_ONLY_DEVICES_HIDDEN_MESSAGE = @@ -81,10 +74,9 @@ export const CreateFirewallDrawer = React.memo( const { _hasGrant, _isRestrictedUser } = useAccountManagement(); const { data: grants } = useGrants(); const { mutateAsync } = useCreateFirewall(); - const { data } = useAllFirewallsQuery(); + const { data } = useAllFirewallsQuery(open); const { enqueueSnackbar } = useSnackbar(); - const queryClient = useQueryClient(); const location = useLocation(); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); @@ -132,35 +124,10 @@ export const CreateFirewallDrawer = React.memo( mutateAsync(payload) .then((response) => { setSubmitting(false); - queryClient.invalidateQueries([firewallQueryKey]); enqueueSnackbar(`Firewall ${payload.label} successfully created`, { variant: 'success', }); - // Invalidate for Linodes - if (payload.devices?.linodes) { - payload.devices.linodes.forEach((linodeId) => { - queryClient.invalidateQueries([ - linodesQueryKey, - 'linode', - linodeId, - 'firewalls', - ]); - }); - } - - // Invalidate for NodeBalancers - if (payload.devices?.nodebalancers) { - payload.devices.nodebalancers.forEach((nodebalancerId) => { - queryClient.invalidateQueries([ - nodebalancersQueryKey, - 'nodebalancer', - nodebalancerId, - 'firewalls', - ]); - }); - } - if (onFirewallCreated) { onFirewallCreated(response); } diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index 0fc5940e071..fc36b3089ec 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -5,46 +5,37 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; -import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { capitalize } from 'src/utilities/capitalize'; +import type { Firewall } from '@linode/api-v4'; + export type Mode = 'delete' | 'disable' | 'enable'; interface Props { mode: Mode; onClose: () => void; open: boolean; - selectedFirewallId: number; - selectedFirewallLabel: string; + selectedFirewall: Firewall; } export const FirewallDialog = React.memo((props: Props) => { const { enqueueSnackbar } = useSnackbar(); const queryClient = useQueryClient(); - const { - mode, - onClose, - open, - selectedFirewallId, - selectedFirewallLabel: label, - } = props; - - const { data: devices } = useAllFirewallDevicesQuery(selectedFirewallId); + const { mode, onClose, open, selectedFirewall } = props; const { error: updateError, isLoading: isUpdating, mutateAsync: updateFirewall, - } = useMutateFirewall(selectedFirewallId); + } = useMutateFirewall(selectedFirewall.id); const { error: deleteError, isLoading: isDeleting, mutateAsync: deleteFirewall, - } = useDeleteFirewall(selectedFirewallId); + } = useDeleteFirewall(selectedFirewall.id); const requestMap = { delete: () => deleteFirewall(), @@ -66,23 +57,30 @@ export const FirewallDialog = React.memo((props: Props) => { const onSubmit = async () => { await requestMap[mode](); + // Invalidate Firewalls assigned to NodeBalancers and Linodes when Firewall is enabled, disabled, or deleted. - // eslint-disable-next-line no-unused-expressions - devices?.forEach((device) => { - const deviceType = device.entity.type; - queryClient.invalidateQueries([ - deviceType === 'linode' ? linodesQueryKey : nodebalancersQueryKey, - deviceType, - device.entity.id, - 'firewalls', - ]); - }); - if (mode === 'delete') { - queryClient.invalidateQueries([firewallQueryKey]); + for (const entity of selectedFirewall.entities) { + if (entity.type === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(entity.id)._ctx.firewalls + .queryKey, + }); + } + + if (entity.type === 'linode') { + queryClient.invalidateQueries({ + queryKey: [linodesQueryKey, 'linode', entity.id, 'firewalls'], + }); + } } - enqueueSnackbar(`Firewall ${label} successfully ${mode}d`, { - variant: 'success', - }); + + enqueueSnackbar( + `Firewall ${selectedFirewall.label} successfully ${mode}d`, + { + variant: 'success', + } + ); + onClose(); }; @@ -101,7 +99,7 @@ export const FirewallDialog = React.memo((props: Props) => { error={errorMap[mode]?.[0].reason} onClose={onClose} open={open} - title={`${capitalize(mode)} Firewall ${label}?`} + title={`${capitalize(mode)} Firewall ${selectedFirewall.label}?`} > Are you sure you want to {mode} this firewall? diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 7e37d83c7b8..6665926e079 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -18,11 +18,13 @@ import { useFirewallsQuery } from 'src/queries/firewalls'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { CreateFirewallDrawer } from './CreateFirewallDrawer'; -import { ActionHandlers as FirewallHandlers } from './FirewallActionMenu'; -import { FirewallDialog, Mode } from './FirewallDialog'; +import { FirewallDialog } from './FirewallDialog'; import { FirewallLandingEmptyState } from './FirewallLandingEmptyState'; import { FirewallRow } from './FirewallRow'; +import type { ActionHandlers as FirewallHandlers } from './FirewallActionMenu'; +import type { Mode } from './FirewallDialog'; + const preferenceKey = 'firewalls'; const FirewallLanding = () => { @@ -175,13 +177,12 @@ const FirewallLanding = () => { onClose={onCloseCreateDrawer} open={isCreateFirewallDrawerOpen} /> - {selectedFirewallId && ( + {selectedFirewall && ( setIsModalOpen(false)} open={isModalOpen} - selectedFirewallId={selectedFirewallId} - selectedFirewallLabel={selectedFirewall?.label ?? ''} + selectedFirewall={selectedFirewall} /> )} diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx index d74809ac0ab..7128c1ecb87 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx @@ -68,21 +68,21 @@ describe('FirewallRow', () => { describe('getDeviceLinks', () => { it('should return a single Link if one Device is attached', () => { const device = firewallDeviceFactory.build(); - const links = getDeviceLinks([device]); + const links = getDeviceLinks([device.entity]); const { getByText } = renderWithTheme(links); expect(getByText(device.entity.label)); }); it('should render up to three comma-separated links', () => { const devices = firewallDeviceFactory.buildList(3); - const links = getDeviceLinks(devices); + const links = getDeviceLinks(devices.map((device) => device.entity)); const { queryAllByTestId } = renderWithTheme(links); expect(queryAllByTestId('firewall-row-link')).toHaveLength(3); }); it('should render "plus N more" text for any devices over three', () => { const devices = firewallDeviceFactory.buildList(13); - const links = getDeviceLinks(devices); + const links = getDeviceLinks(devices.map((device) => device.entity)); const { getByText, queryAllByTestId } = renderWithTheme(links); expect(queryAllByTestId('firewall-row-link')).toHaveLength(3); expect(getByText(/10 more/)); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx index 270442226fa..dcd3504860d 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx @@ -1,5 +1,3 @@ -import { Firewall, FirewallDevice } from '@linode/api-v4/lib/firewalls'; -import { APIError } from '@linode/api-v4/lib/types'; import React from 'react'; import { Link } from 'react-router-dom'; @@ -7,17 +5,17 @@ import { Hidden } from 'src/components/Hidden'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { capitalize } from 'src/utilities/capitalize'; -import { ActionHandlers, FirewallActionMenu } from './FirewallActionMenu'; +import { FirewallActionMenu } from './FirewallActionMenu'; + +import type { ActionHandlers } from './FirewallActionMenu'; +import type { Firewall, FirewallDeviceEntity } from '@linode/api-v4'; export interface FirewallRowProps extends Firewall, ActionHandlers {} export const FirewallRow = React.memo((props: FirewallRowProps) => { - const { id, label, rules, status, ...actionHandlers } = props; - - const { data: devices, error, isLoading } = useAllFirewallDevicesQuery(id); + const { entities, id, label, rules, status, ...actionHandlers } = props; const count = getCountOfRules(rules); @@ -34,9 +32,7 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => { {getRuleString(count)} - - {getDevicesCellString(devices ?? [], isLoading, error ?? undefined)} - + {getDevicesCellString(entities)} { return [(rules.inbound || []).length, (rules.outbound || []).length]; }; -const getDevicesCellString = ( - data: FirewallDevice[], - loading: boolean, - error?: APIError[] -): JSX.Element | string => { - if (loading) { - return 'Loading...'; - } - - if (error) { - return 'Error retrieving Linodes'; - } - - if (data.length === 0) { +const getDevicesCellString = (entities: FirewallDeviceEntity[]) => { + if (entities.length === 0) { return 'None assigned'; } - return getDeviceLinks(data); + return getDeviceLinks(entities); }; -export const getDeviceLinks = (data: FirewallDevice[]): JSX.Element => { - const firstThree = data.slice(0, 3); +export const getDeviceLinks = (entities: FirewallDeviceEntity[]) => { + const firstThree = entities.slice(0, 3); return ( <> - {firstThree.map((thisDevice, idx) => ( - <> + {firstThree.map((entity, idx) => ( + {idx > 0 && ', '} - {thisDevice.entity.label} + {entity.label} - + ))} - {data.length > 3 && , plus {data.length - 3} more.} + {entities.length > 3 && , plus {entities.length - 3} more.} ); }; diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx new file mode 100644 index 00000000000..cdda9cd1340 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -0,0 +1,168 @@ +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { + imageFactory, + linodeDiskFactory, + linodeFactory, + regionFactory, +} from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CreateImageTab } from './CreateImageTab'; + +describe('CreateImageTab', () => { + it('should render fields, titles, and buttons in their default state', () => { + const { getByLabelText, getByText } = renderWithTheme(); + + expect(getByText('Select Linode & Disk')).toBeVisible(); + + expect(getByLabelText('Linode')).toBeVisible(); + + const diskSelect = getByLabelText('Disk'); + + expect(diskSelect).toBeVisible(); + expect(diskSelect).toBeDisabled(); + + expect(getByText('Select a Linode to see available disks')).toBeVisible(); + + expect(getByText('Image Details')).toBeVisible(); + + expect(getByLabelText('Label')).toBeVisible(); + expect(getByLabelText('Add Tags')).toBeVisible(); + expect(getByLabelText('Description')).toBeVisible(); + + const submitButton = getByText('Create Image').closest('button'); + + expect(submitButton).toBeVisible(); + expect(submitButton).toBeEnabled(); + }); + + it('should render client side validation errors', async () => { + const { getByText } = renderWithTheme(); + + const submitButton = getByText('Create Image').closest('button'); + + await userEvent.click(submitButton!); + + expect(getByText('Disk is required.')).toBeVisible(); + }); + + it('should allow the user to select a disk and submit the form', async () => { + const linode = linodeFactory.build(); + const disk = linodeDiskFactory.build(); + const image = imageFactory.build(); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id/disks', () => { + return HttpResponse.json(makeResourcePage([disk])); + }), + http.post('*/v4/images', () => { + return HttpResponse.json(image); + }) + ); + + const { + findByText, + getByLabelText, + getByText, + queryByText, + } = renderWithTheme(); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + const diskSelect = getByLabelText('Disk'); + + // Once a Linode is selected, the Disk select should become enabled + expect(diskSelect).toBeEnabled(); + expect(queryByText('Select a Linode to see available disks')).toBeNull(); + + await userEvent.click(diskSelect); + + const diskOption = await findByText(disk.label); + + await userEvent.click(diskOption); + + const submitButton = getByText('Create Image').closest('button'); + + await userEvent.click(submitButton!); + + // Verify success toast shows + await findByText('Image scheduled for creation.'); + }); + + it('should render a notice if the user selects a Linode in a distributed compute region', async () => { + const region = regionFactory.build({ site_type: 'distributed' }); + const linode = linodeFactory.build({ region: region.id }); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id', () => { + return HttpResponse.json(linode); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme(); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + // Verify distributed compute region notice renders + await findByText( + 'This Linode is in a distributed compute region. Images captured from this Linode will be stored in the closest core site.' + ); + }); + + it('should render an encryption notice if disk encryption is enabled and the Linode is not in a distributed compute region', async () => { + const region = regionFactory.build({ site_type: 'core' }); + const linode = linodeFactory.build({ region: region.id }); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id', () => { + return HttpResponse.json(linode); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme(, { + flags: { linodeDiskEncryption: true }, + }); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + // Verify encryption notice renders + await findByText('Virtual Machine Images are not encrypted.'); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index d32eca891ae..b1f27f2e10b 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -101,16 +101,11 @@ export const CreateImageTab = () => { const isRawDisk = selectedDisk?.filesystem === 'raw'; - /* - We only want to display the notice about disk encryption if: - 1. the Disk Encryption feature is enabled - 2. the selected linode is not in an Edge region - */ const { data: regionsData } = useRegionsQuery(); const { data: linode } = useLinodeQuery( selectedLinodeId ?? -1, - Boolean(selectedLinodeId) && isDiskEncryptionFeatureEnabled + selectedLinodeId !== null ); const linodeIsInDistributedRegion = getIsDistributedRegion( @@ -118,6 +113,23 @@ export const CreateImageTab = () => { linode?.region ?? '' ); + /* + We only want to display the notice about disk encryption if: + 1. the Disk Encryption feature is enabled + 2. a linode is selected + 2. the selected linode is not in an Edge region + */ + const showDiskEncryptionWarning = + isDiskEncryptionFeatureEnabled && + selectedLinodeId !== null && + !linodeIsInDistributedRegion; + + const linodeSelectHelperText = grants?.linode.some( + (grant) => grant.permissions === 'read_only' + ) + ? 'You can only create Images from Linodes you have read/write access to.' + : undefined; + return (
    @@ -135,7 +147,7 @@ export const CreateImageTab = () => { variant="error" /> )} - + Select Linode & Disk By default, Linode images are limited to 6144 MB of data per disk. @@ -153,6 +165,12 @@ export const CreateImageTab = () => { created from a raw disk or a disk that’s formatted using a custom file system. + {linodeIsInDistributedRegion && ( + + This Linode is in a distributed compute region. Images captured + from this Linode will be stored in the closest core site. + + )} { ) : undefined } - helperText={ - grants?.linode.some( - (grant) => grant.permissions === 'read_only' - ) - ? 'You can only create Images from Linodes you have read/write access to.' - : undefined - } onSelectionChange={(linode) => { setSelectedLinodeId(linode?.id ?? null); if (linode === null) { @@ -178,21 +189,18 @@ export const CreateImageTab = () => { } }} disabled={isImageCreateRestricted} + helperText={linodeSelectHelperText} noMarginTop required value={selectedLinodeId} /> - {isDiskEncryptionFeatureEnabled && - !linodeIsInDistributedRegion && - selectedLinodeId !== null && ( - - ({ fontFamily: theme.font.normal })} - > - {DISK_ENCRYPTION_IMAGES_CAVEAT_COPY} - - - )} + {showDiskEncryptionWarning && ( + + ({ fontFamily: theme.font.normal })}> + {DISK_ENCRYPTION_IMAGES_CAVEAT_COPY} + + + )} ( { - + Image Details ( diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx index fafc8614c04..d1bf1d2b064 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx @@ -6,7 +6,7 @@ import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; const ImageUpload = React.lazy(() => - import('../ImageUpload').then((module) => ({ default: module.ImageUpload })) + import('./ImageUpload').then((module) => ({ default: module.ImageUpload })) ); const CreateImageTab = React.lazy(() => diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx similarity index 97% rename from packages/manager/src/features/Images/ImageUpload.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index 16f1a318504..bfef25f2a31 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -38,15 +38,15 @@ import { setPendingUpload } from 'src/store/pendingUpload'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { readableBytes } from 'src/utilities/unitConversions'; -import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; -import { getRestrictedResourceText } from '../Account/utils'; +import { EUAgreementCheckbox } from '../../Account/Agreements/EUAgreementCheckbox'; +import { getRestrictedResourceText } from '../../Account/utils'; import { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; import { ImageUploadFormData, ImageUploadNavigationState, } from './ImageUpload.utils'; import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; -import { uploadImageFile } from './requests'; +import { uploadImageFile } from '../requests'; import type { AxiosError, AxiosProgressEvent } from 'axios'; @@ -256,13 +256,14 @@ export const ImageUpload = () => { onBlur: field.onBlur, }} currentCapability={undefined} + disableClearable errorText={fieldState.error?.message} - handleSelection={field.onChange} helperText="For fastest initial upload, select the region that is geographically closest to you. Once uploaded, you will be able to deploy the image to other regions." label="Region" + onChange={(e, region) => field.onChange(region.id)} regionFilter="core" // Images service will not be supported for Gecko Beta regions={regions ?? []} - selectedId={field.value ?? null} + value={field.value ?? null} /> )} control={form.control} diff --git a/packages/manager/src/features/Images/ImageUpload.utils.ts b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts similarity index 100% rename from packages/manager/src/features/Images/ImageUpload.utils.ts rename to packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx similarity index 100% rename from packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx similarity index 100% rename from packages/manager/src/features/Images/ImageUploadCLIDialog.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx diff --git a/packages/manager/src/features/Images/ImagesDrawer.test.tsx b/packages/manager/src/features/Images/ImagesDrawer.test.tsx deleted file mode 100644 index 32b4846720f..00000000000 --- a/packages/manager/src/features/Images/ImagesDrawer.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { linodeFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { ImagesDrawer, Props } from './ImagesDrawer'; - -const props: Props = { - changeDescription: vi.fn(), - changeDisk: vi.fn(), - changeLabel: vi.fn(), - changeLinode: vi.fn(), - changeTags: vi.fn(), - mode: 'edit', - onClose: vi.fn(), - open: true, - selectedLinode: null, -}; - -describe('ImagesDrawer edit mode', () => { - it('should render', async () => { - const { getByText } = renderWithTheme( - - ); - - // Verify title renders - getByText('Edit Image'); - }); - - it('should allow editing image details', async () => { - const { getByLabelText, getByText } = renderWithTheme( - - ); - - fireEvent.change(getByLabelText('Label'), { - target: { value: 'test-image-label' }, - }); - - fireEvent.change(getByLabelText('Description'), { - target: { value: 'test description' }, - }); - - fireEvent.change(getByLabelText('Tags'), { - target: { value: 'new-tag' }, - }); - fireEvent.click(getByText('Create "new-tag"')); - - fireEvent.click(getByText('Save Changes')); - - expect(props.changeLabel).toBeCalledWith( - expect.objectContaining({ - target: expect.objectContaining({ value: 'test-image-label' }), - }) - ); - - expect(props.changeDescription).toBeCalledWith( - expect.objectContaining({ - target: expect.objectContaining({ value: 'test description' }), - }) - ); - - expect(props.changeTags).toBeCalledWith(['new-tag']); - }); -}); - -describe('ImagesDrawer restore mode', () => { - it('should render', async () => { - const { getByText } = renderWithTheme( - - ); - - // Verify title renders - getByText('Restore from Image'); - }); - - it('should allow editing image details', async () => { - const { findByText, getByRole, getByText } = renderWithTheme( - - ); - - server.use( - http.get('*/linode/instances', () => { - return HttpResponse.json(makeResourcePage(linodeFactory.buildList(5))); - }) - ); - - await userEvent.click(getByRole('combobox')); - await userEvent.click(await findByText('linode-1')); - await userEvent.click(getByText('Restore Image')); - - expect(props.changeLinode).toBeCalledWith(1); - }); -}); diff --git a/packages/manager/src/features/Images/ImagesDrawer.tsx b/packages/manager/src/features/Images/ImagesDrawer.tsx deleted file mode 100644 index 444213ccaed..00000000000 --- a/packages/manager/src/features/Images/ImagesDrawer.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import * as React from 'react'; -import { useHistory } from 'react-router-dom'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { Notice } from 'src/components/Notice/Notice'; -import { TagsInput } from 'src/components/TagsInput/TagsInput'; -import { TextField } from 'src/components/TextField'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { useUpdateImageMutation } from 'src/queries/images'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; - -import { useImageAndLinodeGrantCheck } from './utils'; - -export interface Props { - changeDescription: (e: React.ChangeEvent) => void; - changeDisk: (disk: null | string) => void; - changeLabel: (e: React.ChangeEvent) => void; - changeLinode: (linodeId: number) => void; - changeTags: (tags: string[]) => void; - description?: string; - imageId?: string; - label?: string; - mode: DrawerMode; - onClose: () => void; - open?: boolean; - selectedLinode: null | number; - tags?: string[]; -} - -type CombinedProps = Props; - -export type DrawerMode = 'edit' | 'restore'; - -const titleMap: Record = { - edit: 'Edit Image', - restore: 'Restore from Image', -}; - -const buttonTextMap: Record = { - edit: 'Save Changes', - restore: 'Restore Image', -}; - -export const ImagesDrawer = (props: CombinedProps) => { - const { - changeDescription, - changeLabel, - changeLinode, - changeTags, - description, - imageId, - label, - mode, - onClose, - open, - selectedLinode, - tags, - } = props; - - const history = useHistory(); - const { - canCreateImage, - permissionedLinodes: availableLinodes, - } = useImageAndLinodeGrantCheck(); - - const [notice, setNotice] = React.useState(undefined); - const [submitting, setSubmitting] = React.useState(false); - const [errors, setErrors] = React.useState(undefined); - - const { mutateAsync: updateImage } = useUpdateImageMutation(); - - const handleLinodeChange = (linodeID: number) => { - // Clear any errors - setErrors(undefined); - changeLinode(linodeID); - }; - - const safeDescription = description ? description : ' '; - - const onSubmit = () => { - setErrors(undefined); - setNotice(undefined); - setSubmitting(true); - - switch (mode) { - case 'edit': - if (!imageId) { - setSubmitting(false); - return; - } - - updateImage({ description: safeDescription, imageId, label, tags }) - .then(onClose) - .catch((errorResponse: APIError[]) => { - setErrors( - getAPIErrorOrDefault(errorResponse, 'Unable to edit Image') - ); - }) - .finally(() => { - setSubmitting(false); - }); - return; - - case 'restore': - if (!selectedLinode) { - setSubmitting(false); - setErrors([{ field: 'linode_id', reason: 'Choose a Linode.' }]); - return; - } - close(); - history.push({ - pathname: `/linodes/${selectedLinode}/rebuild`, - state: { selectedImageId: imageId }, - }); - default: - return; - } - }; - - const hasErrorFor = getAPIErrorFor( - { - disk_id: 'Disk', - label: 'Label', - linode_id: 'Linode', - region: 'Region', - size: 'Size', - }, - errors - ); - const labelError = hasErrorFor('label'); - const descriptionError = hasErrorFor('description'); - const generalError = hasErrorFor('none'); - const linodeError = hasErrorFor('linode_id'); - const tagsError = hasErrorFor('tags'); - - return ( - { - setErrors(undefined); - }} - onClose={onClose} - open={open} - title={titleMap[mode]} - > - {!canCreateImage ? ( - - ) : null} - {generalError && ( - - )} - - {notice && } - - {mode === 'restore' && ( - { - if (linode !== null) { - handleLinodeChange(linode.id); - } - }} - optionsFilter={(linode) => - availableLinodes ? availableLinodes.includes(linode.id) : true - } - clearable={false} - disabled={!canCreateImage} - errorText={linodeError} - value={selectedLinode} - /> - )} - - {mode === 'edit' && ( - <> - - - changeTags(tags.map((tag) => tag.value))} - tagError={tagsError} - value={tags?.map((t) => ({ label: t, value: t })) ?? []} - /> - - )} - - - - ); -}; diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx new file mode 100644 index 00000000000..4ee4a28ba84 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { imageFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditImageDrawer } from './EditImageDrawer'; + +const props = { + image: imageFactory.build(), + onClose: vi.fn(), + open: true, +}; + +const mockUpdateImage = vi.fn(); +vi.mock('@linode/api-v4', async () => { + return { + ...(await vi.importActual('@linode/api-v4')), + updateImage: (imageId: any, data: any) => { + mockUpdateImage(imageId, data); + return Promise.resolve(props.image); + }, + }; +}); + +describe('EditImageDrawer', () => { + it('should render', async () => { + const { getByText } = renderWithTheme(); + + // Verify title renders + getByText('Edit Image'); + }); + + it('should allow editing image details', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + fireEvent.change(getByLabelText('Label'), { + target: { value: 'test-image-label' }, + }); + + fireEvent.change(getByLabelText('Description'), { + target: { value: 'test description' }, + }); + + fireEvent.change(getByLabelText('Tags'), { + target: { value: 'new-tag' }, + }); + fireEvent.click(getByText('Create "new-tag"')); + + fireEvent.click(getByText('Save Changes')); + + await waitFor(() => { + expect(mockUpdateImage).toHaveBeenCalledWith('private/0', { + description: 'test description', + label: 'test-image-label', + tags: ['new-tag'], + }); + }); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx new file mode 100644 index 00000000000..582a7738462 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -0,0 +1,168 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { updateImageSchema } from '@linode/validation'; +import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; +import { TextField } from 'src/components/TextField'; +import { usePrevious } from 'src/hooks/usePrevious'; +import { useUpdateImageMutation } from 'src/queries/images'; + +import { useImageAndLinodeGrantCheck } from '../utils'; + +import type { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + onClose: () => void; +} +export const EditImageDrawer = (props: Props) => { + const { image, onClose } = props; + + const { canCreateImage } = useImageAndLinodeGrantCheck(); + + // Prevent content from disappearing when closing drawer + const prevImage = usePrevious(image); + const defaultValues = { + description: image?.description ?? prevImage?.description ?? undefined, + label: image?.label ?? prevImage?.label, + tags: image?.tags ?? prevImage?.tags, + }; + + const { + control, + formState, + handleSubmit, + reset, + setError, + } = useForm({ + defaultValues, + mode: 'onBlur', + resolver: yupResolver(updateImageSchema), + values: defaultValues, + }); + + const { mutateAsync: updateImage } = useUpdateImageMutation(); + + const onSubmit = handleSubmit(async (values) => { + if (!image) { + return; + } + + const safeDescription = values.description?.length + ? values.description + : ' '; + + await updateImage({ + imageId: image.id, + ...values, + description: safeDescription, + }) + .then(onClose) + .catch((errors: APIError[]) => { + for (const error of errors) { + if ( + error.field === 'label' || + error.field == 'description' || + error.field == 'tags' + ) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + }); + }); + + return ( + + {!canCreateImage && ( + + )} + + {formState.errors.root?.message && ( + + )} + + ( + field.onChange(e.target.value)} + value={field.value} + /> + )} + control={control} + name="label" + /> + + ( + field.onChange(e.target.value)} + rows={1} + value={field.value} + /> + )} + control={control} + name="description" + /> + + ( + ({ label: tag, value: tag })) ?? [] + } + disabled={!canCreateImage} + label="Tags" + onChange={(tags) => field.onChange(tags.map((tag) => tag.value))} + tagError={fieldState.error?.message} + /> + )} + control={control} + name="tags" + /> + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx new file mode 100644 index 00000000000..c6fa1646bda --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -0,0 +1,93 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { imageFactory } from 'src/factories'; +import { + mockMatchMedia, + renderWithTheme, + wrapWithTableBody, +} from 'src/utilities/testHelpers'; + +import ImageRow from './ImageRow'; + +import type { Handlers } from './ImagesActionMenu'; + +beforeAll(() => mockMatchMedia()); + +describe('Image Table Row', () => { + const image = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-images'], + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + + const handlers: Handlers = { + onCancelFailed: vi.fn(), + onDelete: vi.fn(), + onDeploy: vi.fn(), + onEdit: vi.fn(), + onManageRegions: vi.fn(), + onRestore: vi.fn(), + onRetry: vi.fn(), + }; + + it('should render an image row', async () => { + const { getAllByText, getByLabelText, getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Check to see if the row rendered some data + getByText(image.label); + getAllByText('Ready'); + getAllByText((text) => text.includes(image.regions[0].region)); + getAllByText('+1'); + getAllByText('Cloud-init, Distributed'); + expect(getAllByText('1500 MB').length).toBe(2); + getAllByText(image.id); + + // Open action menu + const actionMenu = getByLabelText(`Action menu for Image ${image.label}`); + await userEvent.click(actionMenu); + + getByText('Edit'); + getByText('Manage Regions'); + getByText('Deploy to New Linode'); + getByText('Rebuild an Existing Linode'); + getByText('Delete'); + }); + + it('calls handlers when performing actions', async () => { + const { getByLabelText, getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Open action menu + const actionMenu = getByLabelText(`Action menu for Image ${image.label}`); + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Edit')); + expect(handlers.onEdit).toBeCalledWith(image); + + await userEvent.click(getByText('Manage Regions')); + expect(handlers.onManageRegions).toBeCalledWith(image); + + await userEvent.click(getByText('Deploy to New Linode')); + expect(handlers.onDeploy).toBeCalledWith(image.id); + + await userEvent.click(getByText('Rebuild an Existing Linode')); + expect(handlers.onRestore).toBeCalledWith(image); + + await userEvent.click(getByText('Delete')); + expect(handlers.onDelete).toBeCalledWith( + image.label, + image.id, + image.status + ); + }); +}); diff --git a/packages/manager/src/features/Images/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx similarity index 64% rename from packages/manager/src/features/Images/ImageRow.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index f4cec9bf9f1..bd3e50581e2 100644 --- a/packages/manager/src/features/Images/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -1,5 +1,3 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { Image } from '@linode/api-v4/lib/images'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -10,33 +8,47 @@ import { useProfile } from 'src/queries/profile/profile'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; -import { Handlers, ImagesActionMenu } from './ImagesActionMenu'; +import { ImagesActionMenu } from './ImagesActionMenu'; +import { RegionsList } from './RegionsList'; -export interface ImageWithEvent extends Image { +import type { Handlers } from './ImagesActionMenu'; +import type { Event, Image, ImageCapabilities } from '@linode/api-v4'; + +const capabilityMap: Record = { + 'cloud-init': 'Cloud-init', + 'distributed-images': 'Distributed', +}; + +interface Props { event?: Event; + handlers: Handlers; + image: Image; + multiRegionsEnabled?: boolean; // TODO Image Service v2: delete after GA } -interface Props extends Handlers, ImageWithEvent {} - const ImageRow = (props: Props) => { + const { event, handlers, image, multiRegionsEnabled } = props; + const { + capabilities, created, - description, - event, expiry, id, label, - onCancelFailed, - onRetry, + regions, size, status, - ...rest - } = props; + total_size, + } = image; const { data: profile } = useProfile(); const isFailed = status === 'pending_upload' && event?.status === 'failed'; + const compatibilitiesList = multiRegionsEnabled + ? capabilities.map((capability) => capabilityMap[capability]).join(', ') + : ''; + const getStatusForImage = (status: string) => { switch (status) { case 'creating': @@ -74,15 +86,41 @@ const ImageRow = (props: Props) => { {label} {status ? {getStatusForImage(status)} : null} + + {multiRegionsEnabled && ( + <> + + + {regions && regions.length > 0 && ( + handlers.onManageRegions?.(image)} + regions={regions} + /> + )} + + + + {compatibilitiesList} + + + )} + + {getSizeForImage(size, status, event?.status)} + + {multiRegionsEnabled && ( + + + {getSizeForImage(total_size, status, event?.status)} + + + )} + {formatDate(created, { timezone: profile?.timezone, })} - - {getSizeForImage(size, status, event?.status)} - {expiry ? ( @@ -92,17 +130,13 @@ const ImageRow = (props: Props) => { ) : null} + {multiRegionsEnabled && ( + + {id} + + )} - + ); diff --git a/packages/manager/src/features/Images/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx similarity index 67% rename from packages/manager/src/features/Images/ImagesActionMenu.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index 5e9b7ecefda..41ea6d1b519 100644 --- a/packages/manager/src/features/Images/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -1,20 +1,17 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { ImageStatus } from '@linode/api-v4/lib/images/types'; import * as React from 'react'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { Event, Image, ImageStatus } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; export interface Handlers { onCancelFailed?: (imageID: string) => void; onDelete?: (label: string, imageID: string, status?: ImageStatus) => void; onDeploy?: (imageID: string) => void; - onEdit?: ( - label: string, - description: string, - imageID: string, - tags: string[] - ) => void; - onRestore?: (imageID: string) => void; + onEdit?: (image: Image) => void; + onManageRegions?: (image: Image) => void; + onRestore?: (image: Image) => void; onRetry?: ( imageID: string, label: string, @@ -22,30 +19,26 @@ export interface Handlers { ) => void; } -interface Props extends Handlers { - description: null | string; - event: Event | undefined; - id: string; - label: string; - status?: ImageStatus; - tags: string[]; +interface Props { + event?: Event; + handlers: Handlers; + image: Image; } export const ImagesActionMenu = (props: Props) => { + const { event, handlers, image } = props; + + const { description, id, label, status } = image; + const { - description, - event, - id, - label, onCancelFailed, onDelete, onDeploy, onEdit, + onManageRegions, onRestore, onRetry, - status, - tags, - } = props; + } = handlers; const actions: Action[] = React.useMemo(() => { const isDisabled = status && status !== 'available'; @@ -65,12 +58,21 @@ export const ImagesActionMenu = (props: Props) => { : [ { disabled: isDisabled, - onClick: () => onEdit?.(label, description ?? ' ', id, tags), + onClick: () => onEdit?.(image), title: 'Edit', tooltip: isDisabled ? 'Image is not yet available for use.' : undefined, }, + ...(onManageRegions + ? [ + { + disabled: isDisabled, + onClick: () => onManageRegions(image), + title: 'Manage Regions', + }, + ] + : []), { disabled: isDisabled, onClick: () => onDeploy?.(id), @@ -81,7 +83,7 @@ export const ImagesActionMenu = (props: Props) => { }, { disabled: isDisabled, - onClick: () => onRestore?.(id), + onClick: () => onRestore?.(image), title: 'Rebuild an Existing Linode', tooltip: isDisabled ? 'Image is not yet available for use.' @@ -94,23 +96,24 @@ export const ImagesActionMenu = (props: Props) => { ]; }, [ status, - description, + event, + onRetry, id, label, - onDelete, - onRestore, - onDeploy, - onEdit, - onRetry, + description, onCancelFailed, - event, - tags, + onEdit, + image, + onManageRegions, + onDeploy, + onRestore, + onDelete, ]); return ( ); }; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx new file mode 100644 index 00000000000..1a9601dcfc6 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -0,0 +1,255 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { imageFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import ImagesLanding from './ImagesLanding'; + +const mockHistory = { + push: vi.fn(), + replace: vi.fn(), +}; + +// Mock useHistory +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: vi.fn(() => mockHistory), + }; +}); + +beforeAll(() => mockMatchMedia()); + +const loadingTestId = 'circle-progress'; + +describe('Images Landing Table', () => { + it('should render images landing table with items', async () => { + server.use( + http.get('*/images', () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByText, getByTestId } = renderWithTheme(, { + flags: { imageServiceGen2: true }, + }); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Two tables should render + getAllByText('Custom Images'); + getAllByText('Recovery Images'); + + // Static text and table column headers + expect(getAllByText('Image').length).toBe(2); + expect(getAllByText('Status').length).toBe(2); + expect(getAllByText('Region(s)').length).toBe(1); + expect(getAllByText('Compatibility').length).toBe(1); + expect(getAllByText('Size').length).toBe(2); + expect(getAllByText('Total Size').length).toBe(1); + expect(getAllByText('Created').length).toBe(2); + expect(getAllByText('Image ID').length).toBe(1); + }); + + it('should render custom images empty state', async () => { + server.use( + http.get('*/images', ({ request }) => { + return HttpResponse.json( + makeResourcePage( + request.headers.get('x-filter')?.includes('automatic') + ? [imageFactory.build({ type: 'automatic' })] + : [] + ) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect(getByText('No Custom Images to display.')).toBeInTheDocument(); + }); + + it('should render automatic images empty state', async () => { + server.use( + http.get('*/images', ({ request }) => { + return HttpResponse.json( + makeResourcePage( + request.headers.get('x-filter')?.includes('manual') + ? [imageFactory.build({ type: 'manual' })] + : [] + ) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect(getByText('No Recovery Images to display.')).toBeInTheDocument(); + }); + + it('should render images landing empty state', async () => { + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect( + getByText((text) => text.includes('Store your own custom Linux images')) + ).toBeInTheDocument(); + }); + + it('should allow opening the Edit Image drawer', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Edit')); + + getByText('Edit Image'); + }); + + it('should allow opening the Restore Image drawer', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Rebuild an Existing Linode')); + + getByText('Restore from Image'); + }); + + it('should allow deploying to a new Linode', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Deploy to New Linode')); + expect(mockHistory.push).toBeCalledWith({ + pathname: '/linodes/create/', + search: `?type=Images&imageID=${images[0].id}`, + state: { selectedImageId: images[0].id }, + }); + }); + + it('should allow deleting an image', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Delete')); + + getByText(`Delete Image ${images[0].label}`); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx similarity index 72% rename from packages/manager/src/features/Images/ImagesLanding.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index d52729e5923..c3cc58de087 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -1,11 +1,9 @@ -import { Event, Image, ImageStatus } from '@linode/api-v4'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/Close'; import { useQueryClient } from '@tanstack/react-query'; -import produce from 'immer'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; +import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -14,6 +12,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; +import { IconButton } from 'src/components/IconButton'; +import { InputAdornment } from 'src/components/InputAdornment'; import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -24,11 +24,13 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { listToItemsByID } from 'src/queries/base'; import { isEventImageUpload, isEventInProgressDiskImagize, @@ -41,10 +43,18 @@ import { } from 'src/queries/images'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import ImageRow, { ImageWithEvent } from './ImageRow'; -import { Handlers as ImageHandlers } from './ImagesActionMenu'; -import { DrawerMode, ImagesDrawer } from './ImagesDrawer'; +import { getEventsForImages } from '../utils'; +import { EditImageDrawer } from './EditImageDrawer'; +import ImageRow from './ImageRow'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; +import { RebuildImageDrawer } from './RebuildImageDrawer'; + +import type { Handlers as ImageHandlers } from './ImagesActionMenu'; +import type { Image, ImageStatus } from '@linode/api-v4'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { Theme } from '@mui/material/styles'; + +const searchQueryKey = 'query'; const useStyles = makeStyles()((theme: Theme) => ({ imageTable: { @@ -60,16 +70,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface ImageDrawerState { - description?: string; - imageID?: string; - label?: string; - mode: DrawerMode; - open: boolean; - selectedLinode?: number; - tags?: string[]; -} - interface ImageDialogState { error?: string; image?: string; @@ -79,16 +79,6 @@ interface ImageDialogState { submitting: boolean; } -interface ImagesLandingProps extends ImageDrawerState, ImageDialogState {} - -const defaultDrawerState: ImageDrawerState = { - description: '', - label: '', - mode: 'edit', - open: false, - tags: [], -}; - const defaultDialogState = { error: undefined, image: '', @@ -97,10 +87,14 @@ const defaultDialogState = { submitting: false, }; -export const ImagesLanding: React.FC = () => { +export const ImagesLanding = () => { const { classes } = useStyles(); const history = useHistory(); const { enqueueSnackbar } = useSnackbar(); + const flags = useFlags(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const imageLabelFromParam = queryParams.get(searchQueryKey) ?? ''; const queryClient = useQueryClient(); @@ -124,9 +118,14 @@ export const ImagesLanding: React.FC = () => { ['+order_by']: manualImagesOrderBy, }; + if (imageLabelFromParam) { + manualImagesFilter['label'] = { '+contains': imageLabelFromParam }; + } + const { data: manualImages, error: manualImagesError, + isFetching: manualImagesIsFetching, isLoading: manualImagesLoading, } = useImagesQuery( { @@ -164,9 +163,14 @@ export const ImagesLanding: React.FC = () => { ['+order_by']: automaticImagesOrderBy, }; + if (imageLabelFromParam) { + automaticImagesFilter['label'] = { '+contains': imageLabelFromParam }; + } + const { data: automaticImages, error: automaticImagesError, + isFetching: automaticImagesIsFetching, isLoading: automaticImagesLoading, } = useImagesQuery( { @@ -191,20 +195,30 @@ export const ImagesLanding: React.FC = () => { ) ?? []; // Private images with the associated events tied in. - const manualImagesData = getImagesWithEvents( + const manualImagesEvents = getEventsForImages( manualImages?.data ?? [], imageEvents ); + // TODO Image Service V2: delete after GA + const multiRegionsEnabled = + (flags.imageServiceGen2 && + manualImages?.data.some((image) => image.regions?.length)) ?? + false; + // Automatic images with the associated events tied in. - const automaticImagesData = getImagesWithEvents( + const automaticImagesEvents = getEventsForImages( automaticImages?.data ?? [], imageEvents ); - const [drawer, setDrawer] = React.useState( - defaultDrawerState - ); + const [ + // @ts-expect-error This will be unused until the regions drawer is implemented + manageRegionsDrawerImage, + setManageRegionsDrawerImage, + ] = React.useState(); + const [editDrawerImage, setEditDrawerImage] = React.useState(); + const [rebuildDrawerImage, setRebuildDrawerImage] = React.useState(); const [dialog, setDialogState] = React.useState( defaultDialogState @@ -290,30 +304,6 @@ export const ImagesLanding: React.FC = () => { queryClient.invalidateQueries(imageQueries.paginated._def); }; - const openForEdit = ( - label: string, - description: string, - imageID: string, - tags: string[] - ) => { - setDrawer({ - description, - imageID, - label, - mode: 'edit', - open: true, - tags, - }); - }; - - const openForRestore = (imageID: string) => { - setDrawer({ - imageID, - mode: 'restore', - open: true, - }); - }; - const deployNewLinode = (imageID: string) => { history.push({ pathname: `/linodes/create/`, @@ -322,44 +312,6 @@ export const ImagesLanding: React.FC = () => { }); }; - const changeSelectedLinode = (linodeId: null | number) => { - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - selectedDisk: null, - selectedLinode: linodeId ?? undefined, - })); - }; - - const changeSelectedDisk = (disk: null | string) => { - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - selectedDisk: disk, - })); - }; - - const setLabel = (e: React.ChangeEvent) => { - const value = e.target.value; - - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - label: value, - })); - }; - - const setDescription = (e: React.ChangeEvent) => { - const value = e.target.value; - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - description: value, - })); - }; - - const setTags = (tags: string[]) => - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - tags, - })); - const getActions = () => { return ( = () => { ); }; - const closeImageDrawer = () => { - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - open: false, - })); + const resetSearch = () => { + queryParams.delete(searchQueryKey); + history.push({ search: queryParams.toString() }); }; - const renderImageDrawer = () => { - return ( - - ); + const onSearch = (e: React.ChangeEvent) => { + queryParams.delete('page'); + queryParams.set(searchQueryKey, e.target.value); + history.push({ search: queryParams.toString() }); }; const handlers: ImageHandlers = { onCancelFailed: onCancelFailedClick, onDelete: openDialog, onDeploy: deployNewLinode, - onEdit: openForEdit, - onRestore: openForRestore, + onEdit: setEditDrawerImage, + onManageRegions: multiRegionsEnabled + ? setManageRegionsDrawerImage + : undefined, + onRestore: setRebuildDrawerImage, onRetry: onRetryClick, }; @@ -446,20 +385,23 @@ export const ImagesLanding: React.FC = () => { /** Empty States */ if ( - (!manualImagesData || manualImagesData.length === 0) && - (!automaticImagesData || automaticImagesData.length === 0) + !manualImages.data.length && + !automaticImages.data.length && + !imageLabelFromParam ) { return renderEmpty(); } const noManualImages = ( - + ); const noAutomaticImages = ( ); + const isFetching = manualImagesIsFetching || automaticImagesIsFetching; + return ( @@ -469,6 +411,32 @@ export const ImagesLanding: React.FC = () => { onButtonClick={() => history.push('/images/create')} title="Images" /> + + {isFetching && } + + + + + + ), + }} + onChange={debounce(400, (e) => { + onSearch(e); + })} + hideLabel + label="Search" + placeholder="Search Images" + sx={{ mb: 2 }} + value={imageLabelFromParam} + />
    Custom Images @@ -491,7 +459,30 @@ export const ImagesLanding: React.FC = () => { Status - + {multiRegionsEnabled && ( + <> + + Region(s) + + + Compatibility + + + )} + + Size + + {multiRegionsEnabled && ( + + Total Size + + )} + = () => { Created - - Size - + {multiRegionsEnabled && ( + + Image ID + + )} - {manualImagesData.length > 0 - ? manualImagesData.map((manualImage) => ( + {manualImages.data.length > 0 + ? manualImages.data.map((manualImage) => ( )) : noManualImages} @@ -580,15 +570,20 @@ export const ImagesLanding: React.FC = () => { - {automaticImagesData.length > 0 - ? automaticImagesData.map((automaticImage) => ( - - )) - : noAutomaticImages} + {isFetching ? ( + + ) : automaticImages.data.length > 0 ? ( + automaticImages.data.map((automaticImage) => ( + + )) + ) : ( + noAutomaticImages + )} = () => { pageSize={paginationForAutomaticImages.pageSize} /> - {renderImageDrawer()} + setEditDrawerImage(undefined)} + /> + setRebuildDrawerImage(undefined)} + /> = () => { }; export default ImagesLanding; - -const getImagesWithEvents = (images: Image[], events: Event[]) => { - const itemsById = listToItemsByID(images ?? []); - return Object.values(itemsById).reduce( - (accum, thisImage: Image) => - produce(accum, (draft: any) => { - if (!thisImage.is_public) { - // NB: the secondary_entity returns only the numeric portion of the image ID so we have to interpolate. - const matchingEvent = events.find( - (thisEvent) => - `private/${thisEvent.secondary_entity?.id}` === thisImage.id || - (`private/${thisEvent.entity?.id}` === thisImage.id && - thisEvent.status === 'failed') - ); - if (matchingEvent) { - draft.push({ ...thisImage, event: matchingEvent }); - } else { - draft.push(thisImage); - } - } - }), - [] - ) as ImageWithEvent[]; -}; diff --git a/packages/manager/src/features/Images/ImagesLandingEmptyState.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.tsx similarity index 100% rename from packages/manager/src/features/Images/ImagesLandingEmptyState.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.tsx diff --git a/packages/manager/src/features/Images/ImagesLandingEmptyStateData.ts b/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyStateData.ts similarity index 100% rename from packages/manager/src/features/Images/ImagesLandingEmptyStateData.ts rename to packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyStateData.ts diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx new file mode 100644 index 00000000000..1214868b31d --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx @@ -0,0 +1,56 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { imageFactory, linodeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RebuildImageDrawer } from './RebuildImageDrawer'; + +const props = { + changeLinode: vi.fn(), + image: imageFactory.build(), + onClose: vi.fn(), + open: true, +}; + +const mockHistoryPush = vi.fn(); +vi.mock('react-router-dom', async () => { + return { + ...(await vi.importActual('react-router-dom')), + useHistory: () => ({ + push: mockHistoryPush, + }), + }; +}); + +describe('RebuildImageDrawer', () => { + it('should render', async () => { + const { getByText } = renderWithTheme(); + + // Verify title renders + getByText('Restore from Image'); + }); + + it('should allow selecting a Linode to rebuild', async () => { + const { findByText, getByRole, getByText } = renderWithTheme( + + ); + + server.use( + http.get('*/linode/instances', () => { + return HttpResponse.json(makeResourcePage(linodeFactory.buildList(5))); + }) + ); + + await userEvent.click(getByRole('combobox')); + await userEvent.click(await findByText('linode-1')); + await userEvent.click(getByText('Restore Image')); + + expect(mockHistoryPush).toBeCalledWith({ + pathname: '/linodes/1/rebuild', + search: 'selectedImageId=private%2F0', + }); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx new file mode 100644 index 00000000000..2c7685bc32d --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; +import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; + +import { REBUILD_LINODE_IMAGE_PARAM_NAME } from '../../Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage'; +import { useImageAndLinodeGrantCheck } from '../utils'; + +import type { Image } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + onClose: () => void; +} + +export const RebuildImageDrawer = (props: Props) => { + const { image, onClose } = props; + + const history = useHistory(); + const { + permissionedLinodes: availableLinodes, + } = useImageAndLinodeGrantCheck(); + + const { control, formState, handleSubmit, reset } = useForm<{ + linodeId: number; + }>({ + defaultValues: { linodeId: undefined }, + mode: 'onBlur', + }); + + const onSubmit = handleSubmit((values) => { + if (!image) { + return; + } + + onClose(); + + history.push({ + pathname: `/linodes/${values.linodeId}/rebuild`, + search: new URLSearchParams({ + [REBUILD_LINODE_IMAGE_PARAM_NAME]: image.id, + }).toString(), + }); + }); + + return ( + + {formState.errors.root?.message && ( + + )} + + ( + { + field.onChange(linode?.id); + }} + optionsFilter={(linode) => + availableLinodes ? availableLinodes.includes(linode.id) : true + } + clearable={true} + errorText={fieldState.error?.message} + onBlur={field.onBlur} + value={field.value} + /> + )} + rules={{ + required: { + message: 'Select a Linode to restore.', + value: true, + }, + }} + control={control} + name="linodeId" + /> + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx new file mode 100644 index 00000000000..ea58d15f6dc --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx @@ -0,0 +1,43 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RegionsList } from './RegionsList'; + +describe('RegionsList', () => { + it('should render a single region', async () => { + const { findByText } = renderWithTheme( + + ); + + // Should initially fallback to region id + await findByText('us-east'); + await findByText('Newark, NJ'); + }); + + it('should allow expanding to view multiple regions', async () => { + const manageRegions = vi.fn(); + + const { findByRole, findByText } = renderWithTheme( + + ); + + await findByText((text) => text.includes('Newark, NJ')); + const expand = await findByRole('button'); + expect(expand).toHaveTextContent('+1'); + + await userEvent.click(expand); + expect(manageRegions).toBeCalled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx new file mode 100644 index 00000000000..e17785ea634 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { ImageRegion } from '@linode/api-v4'; + +interface Props { + onManageRegions: () => void; + regions: ImageRegion[]; +} + +export const RegionsList = ({ onManageRegions, regions }: Props) => { + const { data: regionsData } = useRegionsQuery(); + + return ( + + {regionsData?.find((region) => region.id == regions[0].region)?.label ?? + regions[0].region} + {regions.length > 1 && ( + <> + ,{' '} + + +{regions.length - 1} + + + )} + + ); +}; diff --git a/packages/manager/src/features/Images/index.tsx b/packages/manager/src/features/Images/index.tsx index 4f294a76b29..91767da9302 100644 --- a/packages/manager/src/features/Images/index.tsx +++ b/packages/manager/src/features/Images/index.tsx @@ -4,7 +4,7 @@ import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -const ImagesLanding = React.lazy(() => import('./ImagesLanding')); +const ImagesLanding = React.lazy(() => import('./ImagesLanding/ImagesLanding')); const ImageCreate = React.lazy( () => import('./ImagesCreate/ImageCreateContainer') ); diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index 40bcdf61f1e..81dc5f25783 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -1,6 +1,6 @@ -import { imageFactory, linodeFactory } from 'src/factories'; +import { eventFactory, imageFactory, linodeFactory } from 'src/factories'; -import { getImageLabelForLinode } from './utils'; +import { getEventsForImages, getImageLabelForLinode } from './utils'; describe('getImageLabelForLinode', () => { it('handles finding an image and getting the label', () => { @@ -31,3 +31,23 @@ describe('getImageLabelForLinode', () => { expect(getImageLabelForLinode(linode, images)).toBe(null); }); }); + +describe('getEventsForImages', () => { + it('sorts events by image', () => { + imageFactory.resetSequenceNumber(); + const images = imageFactory.buildList(3); + const successfulEvent = eventFactory.build({ secondary_entity: { id: 0 } }); + const failedEvent = eventFactory.build({ + entity: { id: 1 }, + status: 'failed', + }); + const unrelatedEvent = eventFactory.build(); + + expect( + getEventsForImages(images, [successfulEvent, failedEvent, unrelatedEvent]) + ).toEqual({ + ['private/0']: successfulEvent, + ['private/1']: failedEvent, + }); + }); +}); diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index 193eda19887..8f23a81e38f 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -1,6 +1,6 @@ import { useGrants, useProfile } from 'src/queries/profile/profile'; -import type { Image, Linode } from '@linode/api-v4'; +import type { Event, Image, Linode } from '@linode/api-v4'; export const useImageAndLinodeGrantCheck = () => { const { data: profile } = useProfile(); @@ -25,3 +25,16 @@ export const getImageLabelForLinode = (linode: Linode, images: Image[]) => { const image = images?.find((image) => image.id === linode.image); return image?.label ?? linode.image; }; + +export const getEventsForImages = (images: Image[], events: Event[]) => + Object.fromEntries( + images.map(({ id: imageId }) => [ + imageId, + events.find( + (thisEvent) => + `private/${thisEvent.secondary_entity?.id}` === imageId || + (`private/${thisEvent.entity?.id}` === imageId && + thisEvent.status === 'failed') + ), + ]) + ); diff --git a/packages/manager/src/features/Kubernetes/ClusterList/constants.ts b/packages/manager/src/features/Kubernetes/ClusterList/constants.ts new file mode 100644 index 00000000000..a7cd5ef9b09 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/ClusterList/constants.ts @@ -0,0 +1,2 @@ +export const ADD_NODE_POOLS_DESCRIPTION = + 'Add groups of Linodes to your cluster. You can have a maximum of 250 Linodes per node pool. Node Pool data is encrypted at rest.'; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index aaeb389f057..734db88e0cb 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -1,10 +1,3 @@ -import { Region } from '@linode/api-v4'; -import { - CreateKubeClusterPayload, - CreateNodePoolData, - KubeNodePoolResponse, -} from '@linode/api-v4/lib/kubernetes'; -import { APIError } from '@linode/api-v4/lib/types'; import { Divider } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import { pick, remove, update } from 'ramda'; @@ -14,7 +7,7 @@ import { useHistory } from 'react-router-dom'; import { Box } from 'src/components/Box'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; @@ -34,6 +27,7 @@ import { } from 'src/queries/account/agreements'; import { useCreateKubernetesClusterMutation, + useKubernetesTypesQuery, useKubernetesVersionQuery, } from 'src/queries/kubernetes'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -42,11 +36,9 @@ import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; import { plansNoticesUtils } from 'src/utilities/planNotices'; -import { - DOCS_LINK_LABEL_DC_PRICING, - LKE_HA_PRICE, -} from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import KubeCheckoutBar from '../KubeCheckoutBar'; @@ -58,9 +50,19 @@ import { import { HAControlPlane } from './HAControlPlane'; import { NodePoolPanel } from './NodePoolPanel'; +import type { + CreateKubeClusterPayload, + CreateNodePoolData, + KubeNodePoolResponse, +} from '@linode/api-v4/lib/kubernetes'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + export const CreateCluster = () => { const { classes } = useStyles(); - const [selectedRegionID, setSelectedRegionID] = React.useState(''); + const [selectedRegionId, setSelectedRegionId] = React.useState< + string | undefined + >(); const [nodePools, setNodePools] = React.useState([]); const [label, setLabel] = React.useState(); const [version, setVersion] = React.useState | undefined>(); @@ -76,6 +78,16 @@ export const CreateCluster = () => { const { data: account } = useAccount(); const { showHighAvailability } = getKubeHighAvailability(account); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); + + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); + const { data: allTypes, error: typesError, @@ -121,7 +133,7 @@ export const CreateCluster = () => { k8s_version, label, node_pools, - region: selectedRegionID, + region: selectedRegionId, }; createKubernetesCluster(payload) @@ -164,31 +176,23 @@ export const CreateCluster = () => { setLabel(newLabel ? newLabel : undefined); }; - /** - * @param regionId - region selection or null if no selection made - * @returns dynamically calculated high availability price by region - */ - const getHighAvailabilityPrice = (regionId: Region['id'] | null) => { - const dcSpecificPrice = regionId - ? getDCSpecificPrice({ basePrice: LKE_HA_PRICE, regionId }) - : undefined; - return dcSpecificPrice ? parseFloat(dcSpecificPrice) : undefined; - }; + const highAvailabilityPrice = getDCSpecificPriceByType({ + regionId: selectedRegionId, + type: lkeHAType, + }); const errorMap = getErrorMap( ['region', 'node_pools', 'label', 'k8s_version', 'versionLoad'], errors ); - const selectedId = selectedRegionID || null; - const { hasSelectedRegion, isPlanPanelDisabled, isSelectedRegionEligibleForPlan, } = plansNoticesUtils({ regionsData, - selectedRegionID, + selectedRegionID: selectedRegionId, }); if (typesError || regionsError || versionLoadError) { @@ -220,17 +224,16 @@ export const CreateCluster = () => { - setSelectedRegionID(regionID) - } textFieldProps={{ helperText: , helperTextPosition: 'top', }} currentCapability="Kubernetes" + disableClearable errorText={errorMap.region} + onChange={(e, region) => setSelectedRegionId(region.id)} regions={regionsData} - selectedId={selectedId} + value={selectedRegionId} /> @@ -256,7 +259,14 @@ export const CreateCluster = () => { {showHighAvailability ? ( @@ -277,7 +287,7 @@ export const CreateCluster = () => { isPlanPanelDisabled={isPlanPanelDisabled} isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} regionsData={regionsData} - selectedRegionId={selectedRegionID} + selectedRegionId={selectedRegionId} types={typesData || []} typesLoading={typesLoading} /> @@ -288,10 +298,15 @@ export const CreateCluster = () => { data-testid="kube-checkout-bar" > { createCluster={createCluster} hasAgreed={hasAgreed} highAvailability={highAvailability} - highAvailabilityPrice={getHighAvailabilityPrice(selectedId)} pools={nodePools} - region={selectedRegionID} + region={selectedRegionId} regionsData={regionsData} removePool={removePool} showHighAvailability={showHighAvailability} diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx index f6e654363fd..b8f995c02f1 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx @@ -1,13 +1,18 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { HAControlPlane, HAControlPlaneProps } from './HAControlPlane'; +import { HAControlPlane } from './HAControlPlane'; + +import type { HAControlPlaneProps } from './HAControlPlane'; const props: HAControlPlaneProps = { - highAvailabilityPrice: LKE_HA_PRICE, + highAvailabilityPrice: '60.00', + isErrorKubernetesTypes: false, + isLoadingKubernetesTypes: false, + selectedRegionId: 'us-southeast', setHighAvailability: vi.fn(), }; @@ -18,12 +23,17 @@ describe('HAControlPlane', () => { expect(getByTestId('ha-control-plane-form')).toBeVisible(); }); - it('should not render an HA price when the price is undefined', () => { - const { queryAllByText } = renderWithTheme( - + it('should not render an HA price when there is a price error', () => { + const { getByText } = renderWithTheme( + ); - expect(queryAllByText(/\$60\.00/)).toHaveLength(0); + getByText(/The cost for HA control plane is not available at this time./); + getByText(/For this region, HA control plane costs \$--.--\/month./); }); it('should render an HA price when the price is a number', async () => { diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx index 6acbfc82bd0..be39c12bb0b 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx @@ -1,16 +1,20 @@ import { FormLabel } from '@mui/material'; import * as React from 'react'; -import { displayPrice } from 'src/components/DisplayPrice'; +import { CircleProgress } from 'src/components/CircleProgress'; import { FormControl } from 'src/components/FormControl'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; import { Radio } from 'src/components/Radio/Radio'; import { RadioGroup } from 'src/components/RadioGroup'; import { Typography } from 'src/components/Typography'; export interface HAControlPlaneProps { - highAvailabilityPrice: number | undefined; + highAvailabilityPrice: string; + isErrorKubernetesTypes: boolean; + isLoadingKubernetesTypes: boolean; + selectedRegionId: string | undefined; setHighAvailability: (ha: boolean | undefined) => void; } @@ -26,8 +30,23 @@ export const HACopy = () => ( ); +export const getRegionPriceLink = (selectedRegionId: string) => { + if (selectedRegionId === 'id-cgk') { + return 'https://www.linode.com/pricing/jakarta/#kubernetes'; + } else if (selectedRegionId === 'br-gru') { + return 'https://www.linode.com/pricing/sao-paulo/#kubernetes'; + } + return 'https://www.linode.com/pricing/#kubernetes'; +}; + export const HAControlPlane = (props: HAControlPlaneProps) => { - const { highAvailabilityPrice, setHighAvailability } = props; + const { + highAvailabilityPrice, + isErrorKubernetesTypes, + isLoadingKubernetesTypes, + selectedRegionId, + setHighAvailability, + } = props; const handleChange = (e: React.ChangeEvent) => { setHighAvailability(e.target.value === 'yes'); @@ -46,17 +65,31 @@ export const HAControlPlane = (props: HAControlPlaneProps) => { HA Control Plane + {isLoadingKubernetesTypes && selectedRegionId ? ( + + ) : selectedRegionId && isErrorKubernetesTypes ? ( + + + The cost for HA control plane is not available at this time. Refer + to pricing{' '} + for information. + + + ) : null} handleChange(e)} > + Yes, enable HA control plane.{' '} + {selectedRegionId + ? `For this region, HA control plane costs $${highAvailabilityPrice}/month.` + : '(Select a region to view price information.)'} + + } control={} name="yes" value="yes" diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx index aab2e592cbc..bbc316947a2 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx @@ -1,13 +1,23 @@ -import { KubeNodePoolResponse, LinodeTypeClass, Region } from '@linode/api-v4'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { ExtendedType, extendType } from 'src/utilities/extendType'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; +import { extendType } from 'src/utilities/extendType'; +import { ADD_NODE_POOLS_DESCRIPTION } from '../ClusterList/constants'; import { KubernetesPlansPanel } from '../KubernetesPlansPanel/KubernetesPlansPanel'; +import type { + KubeNodePoolResponse, + LinodeTypeClass, + Region, +} from '@linode/api-v4'; +import type { ExtendedType } from 'src/utilities/extendType'; + const DEFAULT_PLAN_COUNT = 3; export interface NodePoolPanelProps { @@ -17,21 +27,17 @@ export interface NodePoolPanelProps { isPlanPanelDisabled: (planType?: LinodeTypeClass) => boolean; isSelectedRegionEligibleForPlan: (planType?: LinodeTypeClass) => boolean; regionsData: Region[]; - selectedRegionId: Region['id']; + selectedRegionId: Region['id'] | undefined; types: ExtendedType[]; typesError?: string; typesLoading: boolean; } -export const NodePoolPanel: React.FunctionComponent = ( - props -) => { +export const NodePoolPanel = (props: NodePoolPanelProps) => { return ; }; -const RenderLoadingOrContent: React.FunctionComponent = ( - props -) => { +const RenderLoadingOrContent = (props: NodePoolPanelProps) => { const { typesError, typesLoading } = props; if (typesError) { @@ -45,7 +51,7 @@ const RenderLoadingOrContent: React.FunctionComponent = ( return ; }; -const Panel: React.FunctionComponent = (props) => { +const Panel = (props: NodePoolPanelProps) => { const { addNodePool, apiError, @@ -57,6 +63,12 @@ const Panel: React.FunctionComponent = (props) => { types, } = props; + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const regions = useRegionsQuery().data ?? []; + const [typeCountMap, setTypeCountMap] = React.useState>( new Map() ); @@ -81,17 +93,27 @@ const Panel: React.FunctionComponent = (props) => { setSelectedType(planId); }; + const regionSupportsDiskEncryption = doesRegionSupportFeature( + selectedRegionId ?? '', + regions, + 'Disk Encryption' + ); + return ( typeCountMap.get(planId) ?? DEFAULT_PLAN_COUNT } types={extendedTypes.filter( (t) => t.class !== 'nanode' && t.class !== 'gpu' )} // No Nanodes or GPUs in clusters - copy="Add groups of Linodes to your cluster. You can have a maximum of 100 Linodes per node pool." error={apiError} hasSelectedRegion={hasSelectedRegion} header="Add Node Pools" diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx index 64564568879..60e58baa7cc 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx @@ -3,13 +3,13 @@ import * as React from 'react'; import { regionFactory } from 'src/factories'; import { nodePoolFactory } from 'src/factories/kubernetesCluster'; -import { - LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE, - LKE_HA_PRICE, -} from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import KubeCheckoutBar, { Props } from './KubeCheckoutBar'; +import KubeCheckoutBar from './KubeCheckoutBar'; + +import type { Props } from './KubeCheckoutBar'; const pools = nodePoolFactory.buildList(5, { count: 3, type: 'g6-standard-1' }); @@ -17,7 +17,7 @@ const props: Props = { createCluster: vi.fn(), hasAgreed: false, highAvailability: false, - highAvailabilityPrice: LKE_HA_PRICE, + highAvailabilityPrice: '60', pools, region: 'us-east', regionsData: regionFactory.buildList(1), @@ -34,7 +34,7 @@ const renderComponent = (_props: Props) => describe('KubeCheckoutBar', () => { it('should render helper text and disable create button until a region has been selected', async () => { const { findByText, getByTestId, getByText } = renderWithTheme( - + ); await waitForElementToBeRemoved(getByTestId('circle-progress')); @@ -84,12 +84,41 @@ describe('KubeCheckoutBar', () => { await findByText(/\$210\.00/); }); - it('should display the DC-Specific total price of the cluster for a region with a price increase', async () => { + it('should display the DC-Specific total price of the cluster for a region with a price increase without HA selection', async () => { const { findByText } = renderWithTheme( ); - // 5 node pools * 3 linodes per pool * 10 per linode * 20% increase for Jakarta + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA + await findByText(/\$180\.00/); + }); + + it('should display the DC-Specific total price of the cluster for a region with a price increase with HA selection', async () => { + const { findByText } = renderWithTheme( + + ); + + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA + await findByText(/\$252\.00/); + }); + + it('should display UNKNOWN_PRICE for HA when not available and show total price of cluster as the sum of the node pools', async () => { + const { findByText, getByText } = renderWithTheme( + + ); + + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + UNKNOWN_PRICE await findByText(/\$180\.00/); + getByText(/\$--.--\/month/); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx index 16d3f07758e..c2cfb96908a 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx @@ -1,11 +1,9 @@ -import { KubeNodePoolResponse, Region } from '@linode/api-v4'; import { Typography, styled } from '@mui/material'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; import { CircleProgress } from 'src/components/CircleProgress'; -import { displayPrice } from 'src/components/DisplayPrice'; import { Divider } from 'src/components/Divider'; import { Notice } from 'src/components/Notice/Notice'; import { RenderGuard } from 'src/components/RenderGuard'; @@ -22,15 +20,17 @@ import { } from 'src/utilities/pricing/kubernetes'; import { nodeWarning } from '../kubeUtils'; -import NodePoolSummary from './NodePoolSummary'; +import { NodePoolSummary } from './NodePoolSummary'; + +import type { KubeNodePoolResponse, Region } from '@linode/api-v4'; export interface Props { createCluster: () => void; hasAgreed: boolean; highAvailability?: boolean; - highAvailabilityPrice: number | undefined; + highAvailabilityPrice: string; pools: KubeNodePoolResponse[]; - region: string; + region: string | undefined; regionsData: Region[]; removePool: (poolIdx: number) => void; showHighAvailability: boolean | undefined; @@ -39,7 +39,7 @@ export interface Props { updatePool: (poolIdx: number, updatedPool: KubeNodePoolResponse) => void; } -export const KubeCheckoutBar: React.FC = (props) => { +export const KubeCheckoutBar = (props: Props) => { const { createCluster, hasAgreed, @@ -81,7 +81,7 @@ export const KubeCheckoutBar: React.FC = (props) => { highAvailabilityPrice !== undefined; const disableCheckout = Boolean( - needsAPool || gdprConditions || haConditions || region === '' + needsAPool || gdprConditions || haConditions || !region ); if (isLoading) { @@ -96,10 +96,10 @@ export const KubeCheckoutBar: React.FC = (props) => { ) : undefined } calculatedPrice={ - region !== '' + region ? getTotalClusterPrice({ highAvailabilityPrice: highAvailability - ? highAvailabilityPrice + ? Number(highAvailabilityPrice) : undefined, pools, region, @@ -122,7 +122,7 @@ export const KubeCheckoutBar: React.FC = (props) => { types?.find((thisType) => thisType.id === thisPool.type) || null } price={ - region !== '' + region ? getKubernetesMonthlyPrice({ count: thisPool.count, region, @@ -148,14 +148,12 @@ export const KubeCheckoutBar: React.FC = (props) => { variant="warning" /> )} - {region != '' && highAvailability ? ( + {region && highAvailability ? ( High Availability (HA) Control Plane - - {displayPrice(Number(highAvailabilityPrice))}/month - + {`$${highAvailabilityPrice}/month`} ) : undefined} diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx index c264ea67f85..26927ba9879 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { extendedTypes } from 'src/__data__/ExtendedType'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import NodePoolSummary, { Props } from './NodePoolSummary'; +import { NodePoolSummary, Props } from './NodePoolSummary'; const props: Props = { nodeCount: 3, diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx index 6d9f5a7923f..a36523911dc 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx @@ -1,7 +1,7 @@ import Close from '@mui/icons-material/Close'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; import { DisplayPrice } from 'src/components/DisplayPrice'; @@ -55,7 +55,7 @@ export interface Props { updateNodeCount: (count: number) => void; } -export const NodePoolSummary: React.FC = (props) => { +export const NodePoolSummary = React.memo((props: Props) => { const { classes } = useStyles(); const { nodeCount, onRemove, poolType, price, updateNodeCount } = props; @@ -109,6 +109,4 @@ export const NodePoolSummary: React.FC = (props) => { ); -}; - -export default React.memo(NodePoolSummary); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx index e84e1621b04..d7f2407acb3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx @@ -1,21 +1,31 @@ -import { KubernetesCluster } from '@linode/api-v4'; +import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; -import { useAllKubernetesNodePoolQuery } from 'src/queries/kubernetes'; +import { + useAllKubernetesNodePoolQuery, + useKubernetesTypesQuery, +} from 'src/queries/kubernetes'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { pluralize } from 'src/utilities/pluralize'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { + HA_PRICE_ERROR_MESSAGE, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; import { getTotalClusterMemoryCPUAndStorage } from '../kubeUtils'; +import type { KubernetesCluster } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + interface Props { cluster: KubernetesCluster; } @@ -45,15 +55,19 @@ const useStyles = makeStyles()((theme: Theme) => ({ marginBottom: theme.spacing(3), padding: `${theme.spacing(2.5)} ${theme.spacing(2.5)} ${theme.spacing(3)}`, }, + tooltip: { + '& .MuiTooltip-tooltip': { + minWidth: 320, + }, + }, })); -export const KubeClusterSpecs = (props: Props) => { +export const KubeClusterSpecs = React.memo((props: Props) => { const { cluster } = props; const { classes } = useStyles(); const { data: regions } = useRegionsQuery(); - + const theme = useTheme(); const { data: pools } = useAllKubernetesNodePoolQuery(cluster.id); - const typesQuery = useSpecificTypes(pools?.map((pool) => pool.type) ?? []); const types = extendTypesQueryResult(typesQuery); @@ -62,30 +76,53 @@ export const KubeClusterSpecs = (props: Props) => { types ?? [] ); - const region = regions?.find((r) => r.id === cluster.region); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); - const displayRegion = region?.label ?? cluster.region; + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); - const dcSpecificPrice = cluster.control_plane.high_availability - ? getDCSpecificPrice({ - basePrice: LKE_HA_PRICE, - regionId: region?.id, - }) - : undefined; + const region = regions?.find((r) => r.id === cluster.region); + const displayRegion = region?.label ?? cluster.region; - const highAvailabilityPrice = dcSpecificPrice - ? parseFloat(dcSpecificPrice) + const highAvailabilityPrice = cluster.control_plane.high_availability + ? getDCSpecificPriceByType({ regionId: region?.id, type: lkeHAType }) : undefined; const kubeSpecsLeft = [ `Version ${cluster.k8s_version}`, displayRegion, - `$${getTotalClusterPrice({ - highAvailabilityPrice, - pools: pools ?? [], - region: region?.id, - types: types ?? [], - }).toFixed(2)}/month`, + isLoadingKubernetesTypes ? ( + + ) : cluster.control_plane.high_availability && isErrorKubernetesTypes ? ( + <> + ${UNKNOWN_PRICE}/month + + + ) : ( + `$${getTotalClusterPrice({ + highAvailabilityPrice: highAvailabilityPrice + ? Number(highAvailabilityPrice) + : undefined, + pools: pools ?? [], + region: region?.id, + types: types ?? [], + }).toFixed(2)}/month` + ), ]; const kubeSpecsRight = [ @@ -115,6 +152,4 @@ export const KubeClusterSpecs = (props: Props) => { {kubeSpecsRight.map(kubeSpecItem)} ); -}; - -export default KubeClusterSpecs; +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index 56436e6aab2..e3f2a409034 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -1,6 +1,4 @@ -import { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -12,7 +10,7 @@ import { Chip } from 'src/components/Chip'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Paper } from 'src/components/Paper'; import { TagCell } from 'src/components/TagCell/TagCell'; -import KubeClusterSpecs from 'src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs'; +import { KubeClusterSpecs } from 'src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useKubernetesClusterMutation, @@ -25,6 +23,9 @@ import { DeleteKubernetesClusterDialog } from './DeleteKubernetesClusterDialog'; import { KubeConfigDisplay } from './KubeConfigDisplay'; import { KubeConfigDrawer } from './KubeConfigDrawer'; +import type { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ actionRow: { '& button': { @@ -100,7 +101,7 @@ interface Props { cluster: KubernetesCluster; } -export const KubeSummaryPanel = (props: Props) => { +export const KubeSummaryPanel = React.memo((props: Props) => { const { cluster } = props; const { classes } = useStyles(); const { enqueueSnackbar } = useSnackbar(); @@ -258,6 +259,4 @@ export const KubeSummaryPanel = (props: Props) => { ); -}; - -export default React.memo(KubeSummaryPanel); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx index 9e8383e09b2..93cc3987a39 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx @@ -15,7 +15,7 @@ import { import { useRegionsQuery } from 'src/queries/regions/regions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import KubeSummaryPanel from './KubeSummaryPanel'; +import { KubeSummaryPanel } from './KubeSummaryPanel'; import { NodePoolsDisplay } from './NodePoolsDisplay/NodePoolsDisplay'; import { UpgradeKubernetesClusterToHADialog } from './UpgradeClusterDialog'; import UpgradeKubernetesVersionBanner from './UpgradeKubernetesVersionBanner'; @@ -25,24 +25,21 @@ export const KubernetesClusterDetail = () => { const { clusterID } = useParams<{ clusterID: string }>(); const id = Number(clusterID); const location = useLocation(); - const { data: cluster, error, isLoading } = useKubernetesClusterQuery(id); - const { data: regionsData } = useRegionsQuery(); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( id ); - const [updateError, setUpdateError] = React.useState(); - - const [isUpgradeToHAOpen, setIsUpgradeToHAOpen] = React.useState(false); - const { isClusterHighlyAvailable, showHighAvailability, } = getKubeHighAvailability(account, cluster); + const [updateError, setUpdateError] = React.useState(); + const [isUpgradeToHAOpen, setIsUpgradeToHAOpen] = React.useState(false); + if (error) { return ( { ); }; - -export default KubernetesClusterDetail; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx index 6b697426e6a..b61c0226f54 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx @@ -1,10 +1,10 @@ -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Checkbox } from 'src/components/Checkbox'; +import { CircleProgress } from 'src/components/CircleProgress'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; @@ -12,12 +12,17 @@ import { localStorageWarning, nodesDeletionWarning, } from 'src/features/Kubernetes/kubeUtils'; -import { useKubernetesClusterMutation } from 'src/queries/kubernetes'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { + useKubernetesClusterMutation, + useKubernetesTypesQuery, +} from 'src/queries/kubernetes'; +import { HA_UPGRADE_PRICE_ERROR_MESSAGE } from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { HACopy } from '../CreateCluster/HAControlPlane'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ noticeHeader: { fontSize: '0.875rem', @@ -39,11 +44,10 @@ interface Props { regionID: string; } -export const UpgradeKubernetesClusterToHADialog = (props: Props) => { +export const UpgradeKubernetesClusterToHADialog = React.memo((props: Props) => { const { clusterID, onClose, open, regionID } = props; const { enqueueSnackbar } = useSnackbar(); const [checked, setChecked] = React.useState(false); - const toggleChecked = () => setChecked((isChecked) => !isChecked); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( @@ -53,6 +57,16 @@ export const UpgradeKubernetesClusterToHADialog = (props: Props) => { const [submitting, setSubmitting] = React.useState(false); const { classes } = useStyles(); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); + + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); + const onUpgrade = () => { setSubmitting(true); setError(undefined); @@ -70,6 +84,11 @@ export const UpgradeKubernetesClusterToHADialog = (props: Props) => { }); }; + const highAvailabilityPrice = getDCSpecificPriceByType({ + regionId: regionID, + type: lkeHAType, + }); + const actions = ( { open={open} title="Upgrade to High Availability" > - - - For this region, pricing for the HA control plane is $ - {getDCSpecificPrice({ - basePrice: LKE_HA_PRICE, - regionId: regionID, - })}{' '} - per month per cluster. - - - - Caution: - -
      -
    • {nodesDeletionWarning}
    • -
    • {localStorageWarning}
    • -
    • - This may take several minutes, as nodes will be replaced on a - rolling basis. -
    • -
    -
    - + {isLoadingKubernetesTypes ? ( + + ) : ( + <> + + {isErrorKubernetesTypes ? ( + + {HA_UPGRADE_PRICE_ERROR_MESSAGE} + + ) : ( + <> + + For this region, pricing for the HA control plane is $ + {highAvailabilityPrice} per month per cluster. + + + + Caution: + +
      +
    • {nodesDeletionWarning}
    • +
    • {localStorageWarning}
    • +
    • + This may take several minutes, as nodes will be replaced on + a rolling basis. +
    • +
    +
    + + + )} + + )} ); -}; +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx deleted file mode 100644 index 2dd9213e1e0..00000000000 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './KubernetesClusterDetail'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx index 086327952c6..3477800a7bb 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx @@ -3,6 +3,12 @@ import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { CircleProgress } from 'src/components/CircleProgress'; +import { + DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_BANNER_KEY, + DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_COPY, +} from 'src/components/DiskEncryption/constants'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; @@ -15,6 +21,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; +import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; @@ -92,6 +99,10 @@ export const KubernetesLanding = () => { filter ); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + const openUpgradeDialog = ( clusterID: number, clusterLabel: string, @@ -149,6 +160,17 @@ export const KubernetesLanding = () => { return ( <> + {isDiskEncryptionFeatureEnabled && ( + + + {DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_COPY} + + + )} import('./KubernetesLanding/KubernetesLanding') ); + const ClusterCreate = React.lazy(() => import('./CreateCluster/CreateCluster').then((module) => ({ default: module.CreateCluster, })) ); -const ClusterDetail = React.lazy(() => import('./KubernetesClusterDetail')); -const Kubernetes: React.FC = () => { +const KubernetesClusterDetail = React.lazy(() => + import('./KubernetesClusterDetail/KubernetesClusterDetail').then( + (module) => ({ + default: module.KubernetesClusterDetail, + }) + ) +); + +export const Kubernetes = () => { return ( }> @@ -43,5 +51,3 @@ const Kubernetes: React.FC = () => { ); }; - -export default Kubernetes; diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index 30f67b917be..35619fcce5f 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -5,7 +5,10 @@ import { } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; -import { getTotalClusterMemoryCPUAndStorage } from './kubeUtils'; +import { + getLatestVersion, + getTotalClusterMemoryCPUAndStorage, +} from './kubeUtils'; describe('helper functions', () => { const badPool = nodePoolFactory.build({ @@ -64,4 +67,39 @@ describe('helper functions', () => { }); }); }); + describe('getLatestVersion', () => { + it('should return the correct latest version from a list of versions', () => { + const versions = [ + { label: '1.00', value: '1.00' }, + { label: '1.10', value: '1.10' }, + { label: '2.00', value: '2.00' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '2.00', value: '2.00' }); + }); + + it('should handle latest version minor version correctly', () => { + const versions = [ + { label: '1.22', value: '1.22' }, + { label: '1.23', value: '1.23' }, + { label: '1.30', value: '1.30' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '1.30', value: '1.30' }); + }); + it('should handle latest patch version correctly', () => { + const versions = [ + { label: '1.22', value: '1.30' }, + { label: '1.23', value: '1.15' }, + { label: '1.30', value: '1.50.1' }, + { label: '1.30', value: '1.50' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '1.50.1', value: '1.50.1' }); + }); + it('should return default fallback value when called with empty versions', () => { + const result = getLatestVersion([]); + expect(result).toEqual({ label: '', value: '' }); + }); + }); }); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index e19fe18873a..0189a5b2dfd 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -1,13 +1,13 @@ -import { Account } from '@linode/api-v4/lib/account'; -import { +import { sortByVersion } from 'src/utilities/sort-by'; + +import type { Account } from '@linode/api-v4/lib/account'; +import type { KubeNodePoolResponse, KubernetesCluster, KubernetesVersion, } from '@linode/api-v4/lib/kubernetes'; -import { Region } from '@linode/api-v4/lib/regions'; - +import type { Region } from '@linode/api-v4/lib/regions'; import type { ExtendedType } from 'src/utilities/extendType'; - export const nodeWarning = `We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.`; export const nodesDeletionWarning = `All nodes will be deleted and new nodes will be created to replace them.`; export const localStorageWarning = `Any local storage (such as \u{2019}hostPath\u{2019} volumes) will be erased.`; @@ -112,15 +112,40 @@ export const getKubeHighAvailability = ( }; }; +/** + * Retrieves the latest version from an array of version objects. + * + * This function sorts an array of objects containing version information and returns the object + * with the highest version number. The sorting is performed in ascending order based on the + * `value` property of each object, and the last element of the sorted array, which represents + * the latest version, is returned. + * + * @param {{label: string, value: string}[]} versions - An array of objects with `label` and `value` + * properties where `value` is a version string. + * @returns {{label: string, value: string}} Returns the object with the highest version number. + * If the array is empty, returns an default fallback object. + * + * @example + * // Returns the latest version object + * getLatestVersion([ + * { label: 'Version 1.1', value: '1.1' }, + * { label: 'Version 2.0', value: '2.0' } + * ]); + * // Output: { label: '2.0', value: '2.0' } + */ export const getLatestVersion = ( versions: { label: string; value: string }[] -) => { - const versionsNumbersArray: number[] = []; +): { label: string; value: string } => { + const sortedVersions = versions.sort((a, b) => { + return sortByVersion(a.value, b.value, 'asc'); + }); + + const latestVersion = sortedVersions.pop(); - for (const element of versions) { - versionsNumbersArray.push(parseFloat(element.value)); + if (!latestVersion) { + // Return a default fallback object + return { label: '', value: '' }; } - const latestVersionValue = Math.max.apply(null, versionsNumbersArray); - return { label: `${latestVersionValue}`, value: `${latestVersionValue}` }; + return { label: `${latestVersion.value}`, value: `${latestVersion.value}` }; }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx new file mode 100644 index 00000000000..2e7e9b08a4c --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx @@ -0,0 +1,176 @@ +import { waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { + grantsFactory, + linodeFactory, + linodeTypeFactory, + profileFactory, + regionFactory, +} from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { Region } from './Region'; + +import type { LinodeCreateFormValues } from './utilities'; + +describe('Region', () => { + it('should render a heading', () => { + const { getAllByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const heading = getAllByText('Region')[0]; + + expect(heading).toBeVisible(); + expect(heading.tagName).toBe('H2'); + }); + + it('should render a Region Select', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByPlaceholderText('Select a Region'); + + expect(select).toBeVisible(); + expect(select).toBeEnabled(); + }); + + it('should disable the region select is the user does not have permission to create Linodes', async () => { + const profile = profileFactory.build({ restricted: true }); + const grants = grantsFactory.build({ global: { add_linodes: false } }); + + server.use( + http.get('*/v4/profile/grants', () => { + return HttpResponse.json(grants); + }), + http.get('*/v4/profile', () => { + return HttpResponse.json(profile); + }) + ); + + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByPlaceholderText('Select a Region'); + + await waitFor(() => { + expect(select).toBeDisabled(); + }); + }); + + it('should render regions returned by the API', async () => { + const regions = regionFactory.buildList(5, { capabilities: ['Linodes'] }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage(regions)); + }) + ); + + const { + findByText, + getByPlaceholderText, + } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByPlaceholderText('Select a Region'); + + await userEvent.click(select); + + for (const region of regions) { + // eslint-disable-next-line no-await-in-loop + expect(await findByText(`${region.label} (${region.id})`)).toBeVisible(); + } + }); + + it('renders a warning if the user selects a region with different pricing when cloning', async () => { + const regionA = regionFactory.build({ capabilities: ['Linodes'] }); + const regionB = regionFactory.build({ capabilities: ['Linodes'] }); + + const type = linodeTypeFactory.build({ + region_prices: [{ hourly: 99, id: regionB.id, monthly: 999 }], + }); + + const linode = linodeFactory.build({ region: regionA.id, type: type.id }); + + server.use( + http.get('*/v4/linode/types/:id', () => { + return HttpResponse.json(type); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([regionA, regionB])); + }) + ); + + const { + findByText, + getByPlaceholderText, + } = renderWithThemeAndHookFormContext({ + component: , + options: { + MemoryRouter: { initialEntries: ['/linodes/create?type=Clone+Linode'] }, + }, + useFormOptions: { + defaultValues: { + linode, + }, + }, + }); + + const select = getByPlaceholderText('Select a Region'); + + await userEvent.click(select); + + await userEvent.click(await findByText(`${regionB.label} (${regionB.id})`)); + + await findByText('The selected region has a different price structure.'); + }); + + it('renders a warning if the user tries to clone across datacenters', async () => { + const regionA = regionFactory.build({ capabilities: ['Linodes'] }); + const regionB = regionFactory.build({ capabilities: ['Linodes'] }); + + const linode = linodeFactory.build({ region: regionA.id }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([regionA, regionB])); + }) + ); + + const { + findByText, + getByPlaceholderText, + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + options: { + MemoryRouter: { initialEntries: ['/linodes/create?type=Clone+Linode'] }, + }, + useFormOptions: { + defaultValues: { + linode, + }, + }, + }); + + const select = getByPlaceholderText('Select a Region'); + + await userEvent.click(select); + + await userEvent.click(await findByText(`${regionB.label} (${regionB.id})`)); + + expect( + getByText( + 'Cloning a powered off instance across data centers may cause long periods of down time.' + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index 0497e4b5f4f..3b3d42e5896 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -1,32 +1,156 @@ import React from 'react'; -import { useController } from 'react-hook-form'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; -import { SelectRegionPanel } from 'src/components/SelectRegionPanel/SelectRegionPanel'; +import { Box } from 'src/components/Box'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; +import { DocsLink } from 'src/components/DocsLink/DocsLink'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useTypeQuery } from 'src/queries/types'; +import { + DIFFERENT_PRICE_STRUCTURE_WARNING, + DOCS_LINK_LABEL_DC_PRICING, +} from 'src/utilities/pricing/constants'; +import { isLinodeTypeDifferentPriceInSelectedRegion } from 'src/utilities/pricing/linodes'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import { CROSS_DATA_CENTER_CLONE_WARNING } from '../LinodesCreate/constants'; +import { defaultInterfaces, useLinodeCreateQueryParams } from './utilities'; + +import type { LinodeCreateFormValues } from './utilities'; +import type { Region as RegionType } from '@linode/api-v4'; export const Region = () => { - const { field, formState } = useController({ + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const flags = useFlags(); + + const { params } = useLinodeCreateQueryParams(); + + const { control, reset } = useFormContext(); + const { field, fieldState } = useController({ + control, name: 'region', }); + const selectedLinode = useWatch({ control, name: 'linode' }); + + const { data: type } = useTypeQuery( + selectedLinode?.type ?? '', + Boolean(selectedLinode) + ); + const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', }); + const { data: regions } = useRegionsQuery(); + + const onChange = (region: RegionType) => { + const isDistributedRegion = + region.site_type === 'distributed' || region.site_type === 'edge'; + + const defaultDiskEncryptionValue = region.capabilities.includes( + 'Disk Encryption' + ) + ? 'enabled' + : undefined; + + reset((prev) => ({ + ...prev, + // Reset interfaces because VPC and VLANs are region-sepecific + interfaces: defaultInterfaces, + // Reset Cloud-init metadata because not all regions support it + metadata: undefined, + // Reset the placement group because they are region-specific + placement_group: undefined, + // Set the region + region: region.id, + // Backups and Private IP are not supported in distributed compute regions + ...(isDistributedRegion && { + backups_enabled: false, + private_ip: false, + }), + // If disk encryption is enabled, set the default value to "enabled" if the region supports it + ...(isDiskEncryptionFeatureEnabled && { + disk_encryption: defaultDiskEncryptionValue, + }), + })); + }; + + const showCrossDataCenterCloneWarning = + params.type === 'Clone Linode' && + selectedLinode && + selectedLinode.region !== field.value; + + const showClonePriceWarning = + params.type === 'Clone Linode' && + isLinodeTypeDifferentPriceInSelectedRegion({ + regionA: selectedLinode?.region, + regionB: field.value, + type, + }); + + const hideDistributedRegions = + !flags.gecko2?.enabled || + flags.gecko2?.ga || + !isDistributedRegionSupported(params.type ?? 'Distributions'); + + const showDistributedRegionIconHelperText = + !hideDistributedRegions && + regions?.some( + (region) => + region.site_type === 'distributed' || region.site_type === 'edge' + ); + return ( - + + + Region + + + + {showCrossDataCenterCloneWarning && ( + + theme.font.bold}> + {CROSS_DATA_CENTER_CLONE_WARNING} + + + )} + onChange(region)} + regionFilter={hideDistributedRegions ? 'core' : undefined} + regions={regions ?? []} + textFieldProps={{ onBlur: field.onBlur }} + value={field.value} + /> + {showClonePriceWarning && ( + + theme.font.bold}> + {DIFFERENT_PRICE_STRUCTURE_WARNING}{' '} + Learn more. + + + )} + ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx similarity index 91% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx index 6bdad7d20b1..acd014cd307 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx @@ -12,16 +12,16 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { Access } from './Access'; +import { Security } from './Security'; import type { LinodeCreateFormValues } from './utilities'; -describe('Access', () => { +describe('Security', () => { it( 'should render a root password input', async () => { const { findByLabelText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const rootPasswordInput = await findByLabelText('Root Password'); @@ -34,7 +34,7 @@ describe('Access', () => { it('should render a SSH Keys heading', async () => { const { getAllByText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const heading = getAllByText('SSH Keys')[0]; @@ -45,7 +45,7 @@ describe('Access', () => { it('should render an "Add An SSH Key" button', async () => { const { getByText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const addSSHKeyButton = getByText('Add an SSH Key'); @@ -70,7 +70,7 @@ describe('Access', () => { ); const { findByLabelText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const rootPasswordInput = await findByLabelText('Root Password'); @@ -97,7 +97,7 @@ describe('Access', () => { ); const { findByText, getByRole } = renderWithThemeAndHookFormContext({ - component: , + component: , }); // Make sure the restricted user's SSH keys are loaded @@ -121,7 +121,7 @@ describe('Access', () => { ); const { findByText } = renderWithThemeAndHookFormContext({ - component: , + component: , options: { flags: { linodeDiskEncryption: true } }, }); @@ -150,13 +150,13 @@ describe('Access', () => { const { findByLabelText, } = renderWithThemeAndHookFormContext({ - component: , + component: , options: { flags: { linodeDiskEncryption: true } }, useFormOptions: { defaultValues: { region: region.id } }, }); await findByLabelText( - 'Disk encryption is not available in the selected region.' + 'Disk encryption is not available in the selected region. Select another region to use Disk Encryption.' ); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx similarity index 79% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx index 14862cb2573..916632806b6 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx @@ -3,6 +3,8 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import UserSSHKeyPanel from 'src/components/AccessPanel/UserSSHKeyPanel'; import { + DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES, + DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION, DISK_ENCRYPTION_GENERAL_DESCRIPTION, DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY, } from 'src/components/DiskEncryption/constants'; @@ -10,7 +12,9 @@ import { DiskEncryption } from 'src/components/DiskEncryption/DiskEncryption'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Divider } from 'src/components/Divider'; import { Paper } from 'src/components/Paper'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Skeleton } from 'src/components/Skeleton'; +import { Typography } from 'src/components/Typography'; import { inputMaxWidth } from 'src/foundations/themes/light'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -21,7 +25,7 @@ const PasswordInput = React.lazy( () => import('src/components/PasswordInput/PasswordInput') ); -export const Access = () => { +export const Security = () => { const { control } = useFormContext(); const { @@ -37,12 +41,20 @@ export const Access = () => { 'Disk Encryption' ); + const isDistributedRegion = getIsDistributedRegion( + regions ?? [], + selectedRegion?.id ?? '' + ); + const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', }); return ( + + Security + } > @@ -83,12 +95,20 @@ export const Access = () => { ( field.onChange(checked ? 'enabled' : 'disabled') } - descriptionCopy={DISK_ENCRYPTION_GENERAL_DESCRIPTION} disabled={!regionSupportsDiskEncryption} - disabledReason={DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY} error={fieldState.error?.message} isEncryptDiskChecked={field.value === 'enabled'} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.test.tsx index 020bb95191f..6e2f6f28f14 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.test.tsx @@ -10,7 +10,7 @@ describe('Distributions', () => { component: , }); - const header = getByText('Choose a Distribution'); + const header = getByText('Choose an OS'); expect(header).toBeVisible(); expect(header.tagName).toBe('H2'); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx index d76c3cdbbef..4c7aa4d24c7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx @@ -19,7 +19,7 @@ export const Distributions = () => { return ( - Choose a Distribution + Choose an OS { const { isLoading } = useMarketplaceAppsQuery(true); + const [query, setQuery] = useState(''); + const [category, setCategory] = useState(); + return ( @@ -37,7 +42,9 @@ export const AppSelect = (props: Props) => { label="Search marketplace" loading={isLoading} noMarginTop + onSearch={setQuery} placeholder="Search for app name" + value={query} /> { }} disabled={isLoading} label="Select category" + onChange={(e, value) => setCategory(value?.label)} options={categoryOptions} placeholder="Select category" /> - + diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx index 839e13bc267..a3e1d720328 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx @@ -12,25 +12,33 @@ import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { getDefaultUDFData } from '../StackScripts/UserDefinedFields/utilities'; import { AppSection } from './AppSection'; import { AppSelectionCard } from './AppSelectionCard'; -import { getAppSections } from './utilities'; +import { getAppSections, getFilteredApps } from './utilities'; import type { LinodeCreateFormValues } from '../../utilities'; import type { StackScript } from '@linode/api-v4'; +import type { AppCategory } from 'src/features/OneClickApps/types'; interface Props { + /** + * The selected category to filter by + */ + category: AppCategory | undefined; /** * Opens the Marketplace App details drawer for the given app */ onOpenDetailsDrawer: (stackscriptId: number) => void; + /** + * The search query + */ + query: string; } -export const AppsList = ({ onOpenDetailsDrawer }: Props) => { +export const AppsList = (props: Props) => { + const { category, onOpenDetailsDrawer, query } = props; const { data: stackscripts, error, isLoading } = useMarketplaceAppsQuery( true ); - const filter = null; - const { setValue } = useFormContext(); const { field } = useController({ name: 'stackscript_id', @@ -62,10 +70,16 @@ export const AppsList = ({ onOpenDetailsDrawer }: Props) => { return ; } - if (filter) { + if (category || query) { + const filteredStackScripts = getFilteredApps({ + category, + query, + stackscripts, + }); + return ( - {stackscripts?.map((stackscript) => ( + {filteredStackScripts?.map((stackscript) => ( { + it('should not perform any filtering if the search is empty', () => { + const result = getFilteredApps({ + category: undefined, + query: '', + stackscripts, + }); + + expect(result).toStrictEqual(stackscripts); + }); + + it('should allow a simple filter on label', () => { + const result = getFilteredApps({ + category: undefined, + query: 'mysql', + stackscripts, + }); + + expect(result).toStrictEqual([mysql]); + }); + + it('should allow a filter on label and catergory', () => { + const result = getFilteredApps({ + category: undefined, + query: 'mysql, database', + stackscripts, + }); + + expect(result).toStrictEqual([mysql]); + }); + + it('should allow filtering on StackScript id', () => { + const result = getFilteredApps({ + category: undefined, + query: '1037038', + stackscripts, + }); + + expect(result).toStrictEqual([vault]); + }); + + it('should allow filtering on alt description with many words', () => { + const result = getFilteredApps({ + category: undefined, + query: 'HashiCorp password', + stackscripts, + }); + + expect(result).toStrictEqual([vault]); + }); + + it('should filter if a category is selected in the category dropdown', () => { + const result = getFilteredApps({ + category: 'Databases', + query: '', + stackscripts, + }); + + expect(result).toStrictEqual([mysql]); + }); + + it('should allow searching by both a query and a category', () => { + const result = getFilteredApps({ + category: 'Databases', + query: 'My', + stackscripts, + }); + + expect(result).toStrictEqual([mysql]); + }); + + it('should return no matches if there are no results when searching by both query and category', () => { + const result = getFilteredApps({ + category: 'Databases', + query: 'HashiCorp', + stackscripts, + }); + + expect(result).toStrictEqual([]); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts index 3fb682fee07..c7dc8eababd 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts @@ -1,6 +1,7 @@ import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import type { StackScript } from '@linode/api-v4'; +import type { AppCategory } from 'src/features/OneClickApps/types'; /** * Get all categories from our marketplace apps list so @@ -52,3 +53,88 @@ export const getAppSections = (stackscripts: StackScript[]) => { }, ]; }; + +interface FilterdAppsOptions { + category: AppCategory | undefined; + query: string; + stackscripts: StackScript[]; +} + +/** + * Performs the client side filtering Marketplace Apps on the Linode Create flow + * + * Currently, we only allow users to search OR filter by category in the UI. + * We don't allow both at the same time. If we want to change that, this function + * will need to be modified. + * + * @returns Stackscripts that have been filtered based on the options passed + */ +export const getFilteredApps = (options: FilterdAppsOptions) => { + const { category, query, stackscripts } = options; + + return stackscripts.filter((stackscript) => { + if (query && category) { + return ( + getDoesStackScriptMatchQuery(query, stackscript) && + getDoesStackScriptMatchCategory(category, stackscript) + ); + } + + if (query) { + return getDoesStackScriptMatchQuery(query, stackscript); + } + + if (category) { + return getDoesStackScriptMatchCategory(category, stackscript); + } + + return true; + }); +}; + +/** + * Compares a StackScript's details to a given text search query + * + * @param query the current search query + * @param stackscript the StackScript to compare aginst + * @returns true if the StackScript matches the given query + */ +const getDoesStackScriptMatchQuery = ( + query: string, + stackscript: StackScript +) => { + const appDetails = oneClickApps[stackscript.id]; + + const queryWords = query + .replace(/[,.-]/g, '') + .trim() + .toLocaleLowerCase() + .split(' '); + + const searchableAppFields = [ + String(stackscript.id), + stackscript.label, + appDetails.name, + appDetails.alt_name, + appDetails.alt_description, + ...appDetails.categories, + ]; + + return searchableAppFields.some((field) => + queryWords.some((queryWord) => field.toLowerCase().includes(queryWord)) + ); +}; + +/** + * Checks if the given StackScript has a category + * + * @param category The category to check for + * @param stackscript The StackScript to compare aginst + * @returns true if the given StackScript has the given category + */ +const getDoesStackScriptMatchCategory = ( + category: AppCategory, + stackscript: StackScript +) => { + return oneClickApps[stackscript.id].categories.includes(category); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 49ba44f093f..f476eaef074 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -17,7 +17,7 @@ import { } from 'src/queries/linodes/linodes'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { Access } from './Access'; +import { Security } from './Security'; import { Actions } from './Actions'; import { Addons } from './Addons/Addons'; import { Details } from './Details/Details'; @@ -154,7 +154,7 @@ export const LinodeCreatev2 = () => { {params.type !== 'Backups' && }
    - {params.type !== 'Clone Linode' && } + {params.type !== 'Clone Linode' && } {params.type !== 'Clone Linode' && } diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index b1c76485e89..c0e337e3e7d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -209,23 +209,23 @@ export const getInterfacesPayload = ( return undefined; }; -const defaultVPCInterface = { - ipam_address: '', - label: '', - purpose: 'vpc', -} as const; - -const defaultVLANInterface = { - ipam_address: '', - label: '', - purpose: 'vlan', -} as const; - -const defaultPublicInterface = { - ipam_address: '', - label: '', - purpose: 'public', -} as const; +export const defaultInterfaces: InterfacePayload[] = [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + }, + { + ipam_address: '', + label: '', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, +]; /** * We extend the API's payload type so that we can hold some extra state @@ -268,11 +268,7 @@ export const defaultValues = async (): Promise => { return { backup_id: params.backupID, image: getDefaultImageId(params), - interfaces: [ - defaultVPCInterface, - defaultVLANInterface, - defaultPublicInterface, - ], + interfaces: defaultInterfaces, linode, private_ip: privateIp, region: linode ? linode.region : '', @@ -305,33 +301,21 @@ const getDefaultImageId = (params: ParsedLinodeCreateQueryParams) => { }; const defaultValuesForImages = { - interfaces: [ - defaultVPCInterface, - defaultVLANInterface, - defaultPublicInterface, - ], + interfaces: defaultInterfaces, region: '', type: '', }; const defaultValuesForDistributions = { image: DEFAULT_DISTRIBUTION, - interfaces: [ - defaultVPCInterface, - defaultVLANInterface, - defaultPublicInterface, - ], + interfaces: defaultInterfaces, region: '', type: '', }; const defaultValuesForStackScripts = { image: undefined, - interfaces: [ - defaultVPCInterface, - defaultVLANInterface, - defaultPublicInterface, - ], + interfaces: defaultInterfaces, region: '', stackscript_id: undefined, type: '', diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index 30d8a7313f2..db1b6ce6be0 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -4,6 +4,7 @@ import { useSnackbar } from 'notistack'; import * as React from 'react'; import { TagCell } from 'src/components/TagCell/TagCell'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useLinodeUpdateMutation } from 'src/queries/linodes/linodes'; import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -16,8 +17,8 @@ import { sxLastListItem, sxListItemFirstChild, } from './LinodeEntityDetail.styles'; -import { LinodeHandlers } from './LinodesLanding/LinodesLanding'; +import type { LinodeHandlers } from './LinodesLanding/LinodesLanding'; import type { Linode } from '@linode/api-v4/lib/linodes/types'; import type { TypographyProps } from 'src/components/Typography'; @@ -59,6 +60,11 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { openTagDrawer, } = props; + const isReadOnlyAccountAccess = useRestrictedGlobalGrantCheck({ + globalGrantType: 'account_access', + permittedGrantLevel: 'read_write', + }); + const { mutateAsync: updateLinode } = useLinodeUpdateMutation(linodeId); const { enqueueSnackbar } = useSnackbar(); @@ -157,7 +163,7 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { sx={{ width: '100%', }} - disabled={isLinodesGrantReadOnly} + disabled={isLinodesGrantReadOnly || isReadOnlyAccountAccess} listAllTags={openTagDrawer} tags={linodeTags} updateTags={updateTags} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 62ee744b9b3..09ea9ba4f01 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -543,7 +543,7 @@ export class LinodeCreate extends React.PureComponent< void; @@ -42,6 +46,8 @@ export const LinodeRebuildDialog = (props: Props) => { linodeId !== undefined && open ); + const { data: regionsData } = useRegionsQuery(); + const isReadOnly = Boolean(profile?.restricted) && grants?.linode.find((grant) => grant.id === linodeId)?.permissions === @@ -51,11 +57,24 @@ export const LinodeRebuildDialog = (props: Props) => { const unauthorized = isReadOnly; const disabled = hostMaintenance || unauthorized; + // LDE-related checks + const isEncrypted = linode?.disk_encryption === 'enabled'; + const isLKELinode = Boolean(linode?.lke_cluster_id); + const linodeIsInDistributedRegion = getIsDistributedRegion( + regionsData ?? [], + linode?.region ?? '' + ); + const theme = useTheme(); const [mode, setMode] = React.useState('fromImage'); const [rebuildError, setRebuildError] = React.useState(''); + const [ + diskEncryptionEnabled, + setDiskEncryptionEnabled, + ] = React.useState(isEncrypted); + const onExitDrawer = () => { setRebuildError(''); setMode('fromImage'); @@ -65,6 +84,10 @@ export const LinodeRebuildDialog = (props: Props) => { setRebuildError(status); }; + const toggleDiskEncryptionEnabled = () => { + setDiskEncryptionEnabled(!diskEncryptionEnabled); + }; + return ( { {mode === 'fromImage' && ( )} {mode === 'fromCommunityStackScript' && ( )} {mode === 'fromAccountStackScript' && ( )} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx index 9874a1a6dcc..645a4c8f48a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import * as React from 'react'; import { reactRouterProps } from 'src/__data__/reactRouterProps'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; +import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; import { RebuildFromImage } from './RebuildFromImage'; @@ -11,18 +11,66 @@ vi.mock('src/components/EnhancedSelect/Select'); const props = { disabled: false, + diskEncryptionEnabled: true, handleRebuildError: vi.fn(), + isLKELinode: false, linodeId: 1234, + linodeIsInDistributedRegion: false, onClose: vi.fn(), passwordHelperText: '', + toggleDiskEncryptionEnabled: vi.fn(), ...reactRouterProps, }; +const diskEncryptionEnabledMock = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; +}); + describe('RebuildFromImage', () => { + vi.mock('src/components/DiskEncryption/utils.ts', async () => { + const actual = await vi.importActual( + 'src/components/DiskEncryption/utils.ts' + ); + return { + ...actual, + __esModule: true, + useIsDiskEncryptionFeatureEnabled: diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementation( + () => { + return { + isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent + }; + } + ), + }; + }); + it('renders a SelectImage panel', () => { const { queryByText } = render( wrapWithTheme() ); expect(queryByText('Select Image')).toBeInTheDocument(); }); + + // @TODO LDE: Remove feature flagging/conditionality once LDE is fully rolled out + it('does not render a "Disk Encryption" section when the Disk Encryption feature is disabled', () => { + const { queryByText } = renderWithTheme(); + + expect(queryByText('Encrypt Disk')).not.toBeInTheDocument(); + }); + + it('renders a "Disk Encryption" section when the Disk Encryption feature is enabled', () => { + diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce( + () => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + } + ); + + const { queryByText } = renderWithTheme(); + + expect(queryByText('Encrypt Disk')).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx index e0155d4adc5..38799062f1e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx @@ -9,6 +9,7 @@ import { Formik, FormikProps } from 'formik'; import { useSnackbar } from 'notistack'; import { isEmpty } from 'ramda'; import * as React from 'react'; +import { useLocation } from 'react-router-dom'; import { AccessPanel } from 'src/components/AccessPanel/AccessPanel'; import { Box } from 'src/components/Box'; @@ -28,6 +29,7 @@ import { handleFieldErrors, handleGeneralErrors, } from 'src/utilities/formikErrorUtils'; +import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { extendValidationSchema } from 'src/utilities/validatePassword'; @@ -39,12 +41,16 @@ import { interface Props { disabled: boolean; + diskEncryptionEnabled: boolean; handleRebuildError: (status: string) => void; + isLKELinode: boolean; linodeId: number; + linodeIsInDistributedRegion: boolean; linodeLabel?: string; linodeRegion?: string; onClose: () => void; passwordHelperText: string; + toggleDiskEncryptionEnabled: () => void; } interface RebuildFromImageForm { @@ -63,15 +69,21 @@ const initialValues: RebuildFromImageForm = { root_pass: '', }; +export const REBUILD_LINODE_IMAGE_PARAM_NAME = 'selectedImageId'; + export const RebuildFromImage = (props: Props) => { const { disabled, + diskEncryptionEnabled, handleRebuildError, + isLKELinode, linodeId, + linodeIsInDistributedRegion, linodeLabel, linodeRegion, onClose, passwordHelperText, + toggleDiskEncryptionEnabled, } = props; const { @@ -101,6 +113,13 @@ export const RebuildFromImage = (props: Props) => { false ); + const location = useLocation(); + const preselectedImageId = getQueryParamFromQueryString( + location.search, + REBUILD_LINODE_IMAGE_PARAM_NAME, + '' + ); + const handleUserDataChange = (userData: string) => { setUserData(userData); }; @@ -129,6 +148,7 @@ export const RebuildFromImage = (props: Props) => { const params: RebuildRequest = { authorized_users, + disk_encryption: diskEncryptionEnabled ? 'enabled' : 'disabled', image, metadata: { user_data: userData @@ -151,6 +171,12 @@ export const RebuildFromImage = (props: Props) => { delete params['metadata']; } + // if the linode is part of an LKE cluster or is in a Distributed region, the disk_encryption value + // cannot be changed, so omit it from the payload + if (isLKELinode || linodeIsInDistributedRegion) { + delete params['disk_encryption']; + } + // @todo: eventually this should be a dispatched action instead of a services library call rebuildLinode(linodeId, params) .then((_) => { @@ -182,7 +208,7 @@ export const RebuildFromImage = (props: Props) => { return ( { authorizedUsers={values.authorized_users} data-qa-access-panel disabled={disabled} + diskEncryptionEnabled={diskEncryptionEnabled} + displayDiskEncryption error={errors.root_pass} handleChange={(input) => setFieldValue('root_pass', input)} + isInRebuildFlow + isLKELinode={isLKELinode} + linodeIsInDistributedRegion={linodeIsInDistributedRegion} password={values.root_pass} passwordHelperText={passwordHelperText} + selectedRegion={linodeRegion} + toggleDiskEncryptionEnabled={toggleDiskEncryptionEnabled} /> {shouldDisplayUserDataAccordion ? ( <> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx index 90f8c6e06c9..64926320596 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx @@ -2,21 +2,48 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { reactRouterProps } from 'src/__data__/reactRouterProps'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; +import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; import { RebuildFromStackScript } from './RebuildFromStackScript'; const props = { disabled: false, + diskEncryptionEnabled: true, handleRebuildError: vi.fn(), + isLKELinode: false, linodeId: 1234, + linodeIsInDistributedRegion: false, onClose: vi.fn(), passwordHelperText: '', + toggleDiskEncryptionEnabled: vi.fn(), type: 'community' as const, ...reactRouterProps, }; +const diskEncryptionEnabledMock = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; +}); + describe('RebuildFromStackScript', () => { + vi.mock('src/components/DiskEncryption/utils.ts', async () => { + const actual = await vi.importActual( + 'src/components/DiskEncryption/utils.ts' + ); + return { + ...actual, + __esModule: true, + useIsDiskEncryptionFeatureEnabled: diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementation( + () => { + return { + isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent + }; + } + ), + }; + }); + it('renders a SelectImage panel', () => { const { queryByText } = render( wrapWithTheme() @@ -45,4 +72,29 @@ describe('RebuildFromStackScript', () => { {} ); }); + + // @TODO LDE: Remove feature flagging/conditionality once LDE is fully rolled out + it('does not render a "Disk Encryption" section when the Disk Encryption feature is disabled', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Encrypt Disk')).not.toBeInTheDocument(); + }); + + it('renders a "Disk Encryption" section when the Disk Encryption feature is enabled', () => { + diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce( + () => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + } + ); + + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Encrypt Disk')).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx index fe6641fa1ce..5639ab211c7 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx @@ -37,16 +37,22 @@ import { extendValidationSchema } from 'src/utilities/validatePassword'; interface Props { disabled: boolean; + diskEncryptionEnabled: boolean; handleRebuildError: (status: string) => void; + isLKELinode: boolean; linodeId: number; + linodeIsInDistributedRegion: boolean; linodeLabel?: string; + linodeRegion?: string; onClose: () => void; passwordHelperText: string; + toggleDiskEncryptionEnabled: () => void; type: 'account' | 'community'; } interface RebuildFromStackScriptForm { authorized_users: string[]; + disk_encryption: string | undefined; image: string; root_pass: string; stackscript_id: string; @@ -54,6 +60,7 @@ interface RebuildFromStackScriptForm { const initialValues: RebuildFromStackScriptForm = { authorized_users: [], + disk_encryption: 'enabled', image: '', root_pass: '', stackscript_id: '', @@ -61,11 +68,16 @@ const initialValues: RebuildFromStackScriptForm = { export const RebuildFromStackScript = (props: Props) => { const { + diskEncryptionEnabled, handleRebuildError, + isLKELinode, linodeId, + linodeIsInDistributedRegion, linodeLabel, + linodeRegion, onClose, passwordHelperText, + toggleDiskEncryptionEnabled, } = props; const { @@ -120,8 +132,18 @@ export const RebuildFromStackScript = (props: Props) => { ) => { setSubmitting(true); + // if the linode is part of an LKE cluster or is in a Distributed region, the disk_encryption value + // cannot be changed, so set it to undefined and the API will disregard it + const diskEncryptionPayloadValue = + isLKELinode || linodeIsInDistributedRegion + ? undefined + : diskEncryptionEnabled + ? 'enabled' + : 'disabled'; + rebuildLinode(linodeId, { authorized_users, + disk_encryption: diskEncryptionPayloadValue, image, root_pass, stackscript_data: ss.udf_data, @@ -307,10 +329,17 @@ export const RebuildFromStackScript = (props: Props) => { } authorizedUsers={values.authorized_users} data-qa-access-panel + diskEncryptionEnabled={diskEncryptionEnabled} + displayDiskEncryption error={errors.root_pass} handleChange={(value) => setFieldValue('root_pass', value)} + isInRebuildFlow + isLKELinode={isLKELinode} + linodeIsInDistributedRegion={linodeIsInDistributedRegion} password={values.root_pass} passwordHelperText={passwordHelperText} + selectedRegion={linodeRegion} + toggleDiskEncryptionEnabled={toggleDiskEncryptionEnabled} /> ({ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetail.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetail.tsx index db661dcdeea..51d3dea8521 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetail.tsx @@ -3,6 +3,7 @@ import { Redirect, Route, Switch, + useLocation, useParams, useRouteMatch, } from 'react-router-dom'; @@ -11,6 +12,7 @@ import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; const LinodesDetailHeader = React.lazy( () => import('./LinodesDetailHeader/LinodeDetailHeader') @@ -23,6 +25,9 @@ const CloneLanding = React.lazy(() => import('../CloneLanding/CloneLanding')); const LinodeDetail = () => { const { path, url } = useRouteMatch(); const { linodeId } = useParams<{ linodeId: string }>(); + const location = useLocation(); + + const queryParams = getQueryParamsFromQueryString(location.search); const id = Number(linodeId); @@ -46,11 +51,19 @@ const LinodeDetail = () => { have to reload all the configs, disks, etc. once we get to the CloneLanding page. */} - - - - - + {['resize', 'rescue', 'migrate', 'upgrade', 'rebuild'].map((path) => ( + + ))} ( diff --git a/packages/manager/src/features/Linodes/LinodesLanding/SortableTableHead.tsx b/packages/manager/src/features/Linodes/LinodesLanding/SortableTableHead.tsx index 5f802d9b583..0062283654a 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/SortableTableHead.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/SortableTableHead.tsx @@ -28,9 +28,7 @@ interface SortableTableHeadProps extends Props, Omit, 'data'> {} -export const SortableTableHead = ( - props: SortableTableHeadProps -) => { +export const SortableTableHead = (props: SortableTableHeadProps) => { const theme = useTheme(); const { diff --git a/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx b/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx index b35839852ff..5fa1e58287e 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx @@ -19,7 +19,7 @@ interface Props { interface TableWrapperProps extends Omit, 'data'>, Props {} -const TableWrapper = (props: TableWrapperProps) => { +const TableWrapper = (props: TableWrapperProps) => { const { dataLength, handleOrderChange, diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index a8f091afc80..607a1bb09a0 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -41,7 +41,7 @@ interface Props { handleSelectRegion: (id: string) => void; helperText?: string; linodeType: Linode['type']; - selectedRegion: null | string; + selectedRegion: string | undefined; } export type MigratePricePanelType = 'current' | 'new'; @@ -190,10 +190,11 @@ export const ConfigureForm = React.memo((props: Props) => { helperText, }} currentCapability="Linodes" + disableClearable errorText={errorText} - handleSelection={handleSelectRegion} label="New Region" - selectedId={selectedRegion} + onChange={(e, region) => handleSelectRegion(region.id)} + value={selectedRegion} /> {shouldDisplayPriceComparison && selectedRegion && ( { const { data: regionsData } = useRegionsQuery(); const flags = useFlags(); - const [selectedRegion, handleSelectRegion] = React.useState( - null - ); + const [selectedRegion, handleSelectRegion] = React.useState< + string | undefined + >(); const [ placementGroupSelection, setPlacementGroupSelection, @@ -116,7 +116,7 @@ export const MigrateLinode = React.memo((props: Props) => { agreements, profile, regions: regionsData, - selectedRegionId: selectedRegion ?? '', + selectedRegionId: selectedRegion, }); React.useEffect(() => { @@ -129,7 +129,7 @@ export const MigrateLinode = React.memo((props: Props) => { if (open) { reset(); setConfirmed(false); - handleSelectRegion(null); + handleSelectRegion(undefined); } }, [open]); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/RouteAccordion.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/RouteAccordion.tsx index 41dfcb5c9a7..02d15b11ef6 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/RouteAccordion.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/RouteAccordion.tsx @@ -32,6 +32,7 @@ export const RouteAccordion = ({ configIndex, route, routeIndex }: Props) => { sx={{ backgroundColor: '#f4f5f6', paddingLeft: 1, paddingRight: 1.4 }} > {/* TODO ACLB: Implement RulesTable */} + <>Todo { - this.props.queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - Number(nodeBalancerId), - 'configs', - ]); + this.props.queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(Number(nodeBalancerId)) + ._ctx.configurations.queryKey, + }); // update config data const newConfigs = clone(this.state.configs); newConfigs.splice(idxToDelete, 1); @@ -827,12 +825,10 @@ class NodeBalancerConfigurations extends React.Component< createNodeBalancerConfig(Number(nodeBalancerId), configPayload) .then((nodeBalancerConfig) => { - this.props.queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - Number(nodeBalancerId), - 'configs', - ]); + this.props.queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(Number(nodeBalancerId)) + ._ctx.configurations.queryKey, + }); // update config data const newConfigs = clone(this.state.configs); newConfigs[idx] = { ...nodeBalancerConfig, nodes: [] }; @@ -941,12 +937,10 @@ class NodeBalancerConfigurations extends React.Component< configPayload ) .then((nodeBalancerConfig) => { - this.props.queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - Number(nodeBalancerId), - 'configs', - ]); + this.props.queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(Number(nodeBalancerId)) + ._ctx.configurations.queryKey, + }); // update config data const newConfigs = clone(this.state.configs); newConfigs[idx] = { ...nodeBalancerConfig, nodes: [] }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index 8b4303b3dc5..f981ac37906 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -1,14 +1,10 @@ -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { useParams } from 'react-router-dom'; import PendingIcon from 'src/assets/icons/pending.svg'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; -import { - NodeBalancerConnectionsTimeData, - Point, -} from 'src/components/AreaChart/types'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -16,15 +12,22 @@ import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; import { formatBitsPerSecond } from 'src/features/Longview/shared/utilities'; import { - NODEBALANCER_STATS_NOT_READY_API_MESSAGE, useNodeBalancerQuery, - useNodeBalancerStats, + useNodeBalancerStatsQuery, } from 'src/queries/nodebalancers'; import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getUserTimezone } from 'src/utilities/getUserTimezone'; import { formatNumber, getMetrics } from 'src/utilities/statMetrics'; +import type { Theme } from '@mui/material/styles'; +import type { + NodeBalancerConnectionsTimeData, + Point, +} from 'src/components/AreaChart/types'; + +const NODEBALANCER_STATS_NOT_READY_API_MESSAGE = + 'Stats are unavailable at this time.'; const STATS_NOT_READY_TITLE = 'Stats for this NodeBalancer are not available yet'; @@ -36,9 +39,8 @@ export const TablesPanel = () => { const id = Number(nodeBalancerId); const { data: nodebalancer } = useNodeBalancerQuery(id); - const { data: stats, error, isLoading } = useNodeBalancerStats( - nodebalancer?.id ?? -1, - nodebalancer?.created + const { data: stats, error, isLoading } = useNodeBalancerStatsQuery( + nodebalancer?.id ?? -1 ); const statsErrorString = error diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts index 343dfce5fa9..6a8b06c3856 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts @@ -1,10 +1,12 @@ -import { Theme } from '@mui/material/styles'; +// TODO eventMessagesV2: cleanup unused non V2 components when flag is removed import { styled } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; import { GravatarByUsername } from 'src/components/GravatarByUsername'; +import type { Theme } from '@mui/material/styles'; + export const RenderEventStyledBox = styled(Box, { label: 'StyledBox', })(({ theme }) => ({ @@ -12,6 +14,7 @@ export const RenderEventStyledBox = styled(Box, { backgroundColor: theme.bg.app, }, color: theme.textColors.tableHeader, + display: 'flex', gap: 16, paddingBottom: 12, paddingLeft: '20px', @@ -27,6 +30,15 @@ export const RenderEventGravatar = styled(GravatarByUsername, { minWidth: 40, })); +export const RenderEventGravatarV2 = styled(GravatarByUsername, { + label: 'StyledGravatarByUsername', +})(() => ({ + height: 32, + marginTop: 2, + minWidth: 32, + width: 32, +})); + export const useRenderEventStyles = makeStyles()((theme: Theme) => ({ bar: { marginTop: theme.spacing(), @@ -35,4 +47,19 @@ export const useRenderEventStyles = makeStyles()((theme: Theme) => ({ color: theme.textColors.headlineStatic, textDecoration: 'none', }, + unseenEventV2: { + '&:after': { + backgroundColor: theme.palette.primary.main, + content: '""', + display: 'block', + height: '100%', + left: 0, + position: 'absolute', + top: 0, + width: 4, + }, + backgroundColor: theme.bg.offWhite, + borderBottom: `1px solid ${theme.bg.main}`, + position: 'relative', + }, })); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx index 1c9957f1f55..695adbd95df 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import { Event } from '@linode/api-v4/lib/account/types'; import * as React from 'react'; @@ -5,8 +6,6 @@ import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/HighlightedMarkdown'; import { Typography } from 'src/components/Typography'; -import { getEventMessage } from 'src/features/Events/utils'; -import { useFlags } from 'src/hooks/useFlags'; import { getEventTimestamp } from 'src/utilities/eventUtils'; import { getAllowedHTMLTags } from 'src/utilities/sanitizeHTML.utils'; @@ -23,11 +22,9 @@ interface RenderEventProps { } export const RenderEvent = React.memo((props: RenderEventProps) => { - const flags = useFlags(); const { classes, cx } = useRenderEventStyles(); const { event } = props; const { message } = useEventInfo(event); - const messageV2 = getEventMessage(event); const unseenEventClass = cx({ [classes.unseenEvent]: !event.seen }); @@ -47,37 +44,6 @@ export const RenderEvent = React.memo((props: RenderEventProps) => {
    ); - if (flags.eventMessagesV2) { - return ( - /** - * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). - * Filter these out so we don't display blank messages to the user. - * We have sentry events being logged for these cases, so we can always go back and add support for them as soon as aware. - */ - messageV2 ? ( - <> - - - - {messageV2} - - {getEventTimestamp(event).toRelative()} - {event.username && ` | ${event.username}`} - - - - - - ) : null - ); - } - return ( <> diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx new file mode 100644 index 00000000000..cb7a08af07b --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; + +import { eventFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RenderEventV2 } from './RenderEventV2'; + +describe('RenderEventV2', () => { + it('should render a finished event with the proper data', () => { + const event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + status: 'finished', + }); + + const { getByTestId, getByText } = renderWithTheme( + vi.fn()} /> + ); + + expect( + getByTestId('linode_create').textContent?.match( + /Linode test-linode has been created./ + ) + ); + expect( + getByText(/Started 1 second ago | prod-test-001/) + ).toBeInTheDocument(); + }); + + it('should redner an in progress event with the proper data', () => { + const event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + percent_complete: 50, + status: 'started', + }); + + const { getByTestId, getByText } = renderWithTheme( + vi.fn()} /> + ); + + expect( + getByTestId('linode_create').textContent?.match( + /Linode test-linode is being created./ + ) + ); + expect( + getByText(/Started 1 second ago | prod-test-001/) + ).toBeInTheDocument(); + expect(getByTestId('linear-progress')).toHaveAttribute( + 'aria-valuenow', + '50' + ); + }); +}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx new file mode 100644 index 00000000000..ca3ccf71217 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx @@ -0,0 +1,64 @@ +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, +} from 'src/features/Events/utils'; + +import { + RenderEventGravatarV2, + RenderEventStyledBox, + useRenderEventStyles, +} from './RenderEvent.styles'; + +import type { Event } from '@linode/api-v4/lib/account/types'; + +interface RenderEventProps { + event: Event; + onClose: () => void; +} + +export const RenderEventV2 = React.memo((props: RenderEventProps) => { + const { event } = props; + const { classes, cx } = useRenderEventStyles(); + const unseenEventClass = cx({ [classes.unseenEventV2]: !event.seen }); + const message = getEventMessage(event); + + /** + * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). + * Filter these out so we don't display blank messages to the user. + * We have Sentry events being logged for these cases, so we can always go back and add support for them as soon as we become aware. + */ + if (message === null) { + return null; + } + + const { progressEventDisplay, showProgress } = formatProgressEvent(event); + + return ( + + + + {message} + {showProgress && ( + + )} + + {progressEventDisplay} | {event.username ?? 'Linode'} + + + + ); +}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx index e8f38ccfcec..0eb115ada46 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx @@ -1,4 +1,4 @@ -import { Event } from '@linode/api-v4/lib/account/types'; +// TODO eventMessagesV2: delete when flag is removed import { Duration } from 'luxon'; import * as React from 'react'; @@ -16,11 +16,13 @@ import { extendTypesQueryResult } from 'src/utilities/extendType'; import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; import { - RenderEventGravatar, + RenderEventGravatarV2, RenderEventStyledBox, useRenderEventStyles, } from './RenderEvent.styles'; +import type { Event } from '@linode/api-v4/lib/account/types'; + interface Props { event: Event; onClose: () => void; @@ -58,10 +60,7 @@ export const RenderProgressEvent = (props: Props) => { return ( <> - + void; showMoreTarget?: string; showMoreText?: string; } @@ -63,6 +65,7 @@ export const NotificationSection = (props: NotificationSectionProps) => { emptyMessage, header, loading, + onCloseNotificationCenter, showMoreTarget, showMoreText, } = props; @@ -88,7 +91,11 @@ export const NotificationSection = (props: NotificationSectionProps) => { {header} {showMoreTarget && ( - + {showMoreText ?? 'View history'} @@ -161,6 +168,7 @@ const ContentBody = React.memo((props: BodyProps) => { <> {_content.map((thisItem) => ( diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx index 4a8a89be6c4..f145eaee71d 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx @@ -4,7 +4,7 @@ import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect import { useRegionsQuery } from 'src/queries/regions/regions'; import { sortByString } from 'src/utilities/sort-by'; -import type { RegionSelectOption } from 'src/components/RegionSelect/RegionSelect.types'; +import type { Region } from '@linode/api-v4'; interface Props { disabled?: boolean; @@ -15,7 +15,7 @@ interface Props { selectedRegion: string[]; } -const sortRegionOptions = (a: RegionSelectOption, b: RegionSelectOption) => { +const sortRegionOptions = (a: Region, b: Region) => { return sortByString(a.label, b.label, 'asc'); }; @@ -29,9 +29,7 @@ export const AccessKeyRegions = (props: Props) => { return ( { - onChange(ids); - }} + onChange={onChange} currentCapability="Object Storage" disabled={disabled} errorText={errorText} diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx index 197dc7e5122..50ff7ccd614 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx @@ -8,11 +8,11 @@ import { RemovableSelectionsList, } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; -import type { RegionSelectOption } from 'src/components/RegionSelect/RegionSelect.types'; +import type { Region } from '@linode/api-v4'; interface SelectedRegionsProps { onRemove: (region: string) => void; - selectedRegions: RegionSelectOption[]; + selectedRegions: Region[]; } interface LabelComponentProps { @@ -29,9 +29,9 @@ const SelectedRegion = ({ selection }: LabelComponentProps) => { }} > - + - {selection.label} + {selection.label} ({selection.id}) ); }; @@ -41,14 +41,12 @@ export const SelectedRegionsList = ({ selectedRegions, }: SelectedRegionsProps) => { const handleRemove = (item: RemovableItem) => { - onRemove(item.value); + onRemove(item.id as string); }; return ( { - return { ...item, id: index }; - })} + selectionData={selectedRegions} LabelComponent={SelectedRegion} headerText="" noDataText="" diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx new file mode 100644 index 00000000000..9ef213cf387 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx @@ -0,0 +1,97 @@ +import '@testing-library/jest-dom'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; + +import { objectStorageKeyFactory, regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { HostNameTableCell } from './HostNameTableCell'; + +describe('HostNameTableCell', () => { + it('should render "None" when there are no regions', () => { + const storageKeyData = objectStorageKeyFactory.build({ + regions: [], + }); + const { getByText } = renderWithTheme( + + ); + + expect(getByText('None')).toBeInTheDocument(); + }); + + test('should render "Regions/S3 Hostnames" cell when there are regions', async () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + id: 'us-east', + label: 'Newark, NJ', + }); + const storageKeyData = objectStorageKeyFactory.build({ + regions: [ + { + id: 'us-east', + s3_endpoint: 'alpha.test.com', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + const { findByText } = renderWithTheme( + + ); + + const hostname = await findByText('Newark, NJ: alpha.test.com'); + + await waitFor(() => expect(hostname).toBeInTheDocument()); + }); + test('should render all "Regions/S3 Hostnames" in the cell when there are multiple regions', async () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + id: 'us-east', + label: 'Newark, NJ', + }); + const storageKeyData = objectStorageKeyFactory.build({ + regions: [ + { + id: 'us-east', + s3_endpoint: 'alpha.test.com', + }, + { + id: 'us-south', + s3_endpoint: 'alpha.test.com', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + const { findByText } = renderWithTheme( + + ); + const hostname = await findByText('Newark, NJ: alpha.test.com'); + const moreButton = await findByText(/and\s+1\s+more\.\.\./); + await waitFor(() => expect(hostname).toBeInTheDocument()); + + await expect(moreButton).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index e5fb3ce88db..3bfbd4faf08 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -1,7 +1,3 @@ -import { - ObjectStorageKey, - RegionS3EndpointAndID, -} from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -11,6 +7,11 @@ import { TableCell } from 'src/components/TableCell'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getRegionsByRegionId } from 'src/utilities/regions'; +import type { + ObjectStorageKey, + RegionS3EndpointAndID, +} from '@linode/api-v4/lib/object-storage'; + type Props = { setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; setShowHostNamesDrawers: (show: boolean) => void; @@ -31,14 +32,14 @@ export const HostNameTableCell = ({ if (!regionsLookup || !regionsData || !regions || regions.length === 0) { return None; } + const label = regionsLookup[storageKeyData.regions[0].id]?.label; + const s3Endpoint = storageKeyData?.regions[0]?.s3_endpoint; return ( - {`${regionsLookup[storageKeyData.regions[0].id].label}: ${ - storageKeyData?.regions[0]?.s3_endpoint - } `} + {label}: {s3Endpoint} {storageKeyData?.regions?.length === 1 && ( - + )} {storageKeyData.regions.length > 1 && ( void; onChange: (value: string) => void; required?: boolean; - selectedRegion: null | string; + selectedRegion: string | undefined; } export const BucketRegions = (props: Props) => { @@ -23,16 +23,16 @@ export const BucketRegions = (props: Props) => { return ( onChange(id)} - isClearable={false} label="Region" onBlur={onBlur} + onChange={(e, region) => onChange(region.id)} placeholder="Select a Region" regions={regions ?? []} required={required} - selectedId={selectedRegion} + value={selectedRegion} /> ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index 5de99f11411..21d431ff761 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -46,16 +46,16 @@ export const ClusterSelect: React.FC = (props) => { onChange(id)} - isClearable={false} label="Region" onBlur={onBlur} + onChange={(e, region) => onChange(region.id)} placeholder="Select a Region" regions={regionOptions ?? []} required={required} - selectedId={selectedCluster} + value={selectedCluster} /> ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index 11c89b01580..71fb9116ef1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -15,6 +15,7 @@ import { useMutateAccountAgreements, } from 'src/queries/account/agreements'; import { useAccountSettings } from 'src/queries/account/settings'; +import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useCreateBucketMutation, useObjectStorageBuckets, @@ -77,12 +78,20 @@ export const CreateBucketDrawer = (props: Props) => { }); const { - data: types, - isError: isErrorTypes, - isLoading: isLoadingTypes, + data: objTypes, + isError: isErrorObjTypes, + isInitialLoading: isLoadingObjTypes, } = useObjectStorageTypesQuery(isOpen); - - const isInvalidPrice = !types || isErrorTypes; + const { + data: transferTypes, + isError: isErrorTransferTypes, + isInitialLoading: isLoadingTransferTypes, + } = useNetworkTransferPricesQuery(isOpen); + + const isErrorTypes = isErrorTransferTypes || isErrorObjTypes; + const isLoadingTypes = isLoadingTransferTypes || isLoadingObjTypes; + const isInvalidPrice = + !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; const { error, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 1438e669c81..4340b64e5f6 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -13,9 +13,11 @@ import { useMutateAccountAgreements, } from 'src/queries/account/agreements'; import { useAccountSettings } from 'src/queries/account/settings'; +import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useCreateBucketMutation, useObjectStorageBuckets, + useObjectStorageTypesQuery, } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -23,6 +25,7 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import { BucketRegions } from './BucketRegions'; @@ -58,6 +61,22 @@ export const OMC_CreateBucketDrawer = (props: Props) => { regions: regionsSupportingObjectStorage, }); + const { + data: objTypes, + isError: isErrorObjTypes, + isInitialLoading: isLoadingObjTypes, + } = useObjectStorageTypesQuery(isOpen); + const { + data: transferTypes, + isError: isErrorTransferTypes, + isInitialLoading: isLoadingTransferTypes, + } = useNetworkTransferPricesQuery(isOpen); + + const isErrorTypes = isErrorTransferTypes || isErrorObjTypes; + const isLoadingTypes = isLoadingTransferTypes || isLoadingObjTypes; + const isInvalidPrice = + !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; + const { error, isLoading, @@ -176,9 +195,15 @@ export const OMC_CreateBucketDrawer = (props: Props) => { 'data-testid': 'create-bucket-button', disabled: !formik.values.region || - (showGDPRCheckbox && !hasSignedAgreement), + (showGDPRCheckbox && !hasSignedAgreement) || + isErrorTypes, label: 'Create Bucket', - loading: isLoading, + loading: + isLoading || Boolean(formik.values.region && isLoadingTypes), + tooltipText: + !isLoadingTypes && isInvalidPrice + ? PRICES_RELOAD_ERROR_NOTICE_TEXT + : '', type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx index 43330dcaa49..c7d0c2eba23 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx @@ -2,14 +2,12 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; import { + distributedNetworkTransferPriceTypeFactory, + networkTransferPriceTypeFactory, objectStorageOverageTypeFactory, objectStorageTypeFactory, } from 'src/factories'; -import { - OBJ_STORAGE_PRICE, - UNKNOWN_PRICE, -} from 'src/utilities/pricing/constants'; -import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { @@ -23,7 +21,13 @@ const mockObjectStorageTypes = [ objectStorageOverageTypeFactory.build(), ]; +const mockNetworkTransferTypes = [ + distributedNetworkTransferPriceTypeFactory.build(), + networkTransferPriceTypeFactory.build(), +]; + const queryMocks = vi.hoisted(() => ({ + useNetworkTransferPricesQuery: vi.fn().mockReturnValue({}), useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), })); @@ -35,11 +39,22 @@ vi.mock('src/queries/objectStorage', async () => { }; }); +vi.mock('src/queries/networkTransfer', async () => { + const actual = await vi.importActual('src/queries/networkTransfer'); + return { + ...actual, + useNetworkTransferPricesQuery: queryMocks.useNetworkTransferPricesQuery, + }; +}); + describe('OveragePricing', async () => { beforeAll(() => { queryMocks.useObjectStorageTypesQuery.mockReturnValue({ data: mockObjectStorageTypes, }); + queryMocks.useNetworkTransferPricesQuery.mockReturnValue({ + data: mockNetworkTransferTypes, + }); }); it('Renders base overage pricing for a region without price increases', () => { @@ -49,7 +64,7 @@ describe('OveragePricing', async () => { getByText(`$${mockObjectStorageTypes[1].price.hourly?.toFixed(2)} per GB`, { exact: false, }); - getByText(`$${OBJ_STORAGE_PRICE.transfer_overage} per GB`, { + getByText(`$${mockNetworkTransferTypes[1].price.hourly} per GB`, { exact: false, }); }); @@ -60,7 +75,7 @@ describe('OveragePricing', async () => { exact: false, }); getByText( - `$${objectStoragePriceIncreaseMap['br-gru'].transfer_overage} per GB`, + `$${mockNetworkTransferTypes[1].region_prices[1].hourly} per GB`, { exact: false } ); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx index 7a277db53ec..fa139dac2a0 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx @@ -1,4 +1,3 @@ -import { Region } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -6,15 +5,12 @@ import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { TextTooltip } from 'src/components/TextTooltip'; import { Typography } from 'src/components/Typography'; +import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; -import { - OBJ_STORAGE_PRICE, - UNKNOWN_PRICE, -} from 'src/utilities/pricing/constants'; -import { - getDCSpecificPriceByType, - objectStoragePriceIncreaseMap, -} from 'src/utilities/pricing/dynamicPricing'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; + +import type { Region } from '@linode/api-v4'; interface Props { regionId: Region['id']; @@ -28,24 +24,44 @@ export const GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT = export const OveragePricing = (props: Props) => { const { regionId } = props; - const { data: types, isError, isLoading } = useObjectStorageTypesQuery(); + const { + data: objTypes, + isError: isErrorObjTypes, + isLoading: isLoadingObjTypes, + } = useObjectStorageTypesQuery(); + const { + data: transferTypes, + isError: isErrorTransferTypes, + isLoading: isLoadingTransferTypes, + } = useNetworkTransferPricesQuery(); - const overageType = types?.find( + const storageOverageType = objTypes?.find( (type) => type.id === 'objectstorage-overage' ); + const transferOverageType = transferTypes?.find( + (type) => type.id === 'network_transfer' + ); const storageOveragePrice = getDCSpecificPriceByType({ decimalPrecision: 3, interval: 'hourly', regionId, - type: overageType, + type: storageOverageType, + }); + const transferOveragePrice = getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId, + type: transferOverageType, }); - const isDcSpecificPricingRegion = objectStoragePriceIncreaseMap.hasOwnProperty( - regionId + const isDcSpecificPricingRegion = Boolean( + transferOverageType?.region_prices.find( + (region_price) => region_price.id === regionId + ) ); - return isLoading ? ( + return isLoadingObjTypes || isLoadingTransferTypes ? ( @@ -55,7 +71,7 @@ export const OveragePricing = (props: Props) => { For this region, additional storage costs{' '} $ - {storageOveragePrice && !isError + {storageOveragePrice && !isErrorObjTypes ? parseFloat(storageOveragePrice) : UNKNOWN_PRICE}{' '} per GB @@ -67,9 +83,9 @@ export const OveragePricing = (props: Props) => { Outbound transfer will cost{' '} $ - {isDcSpecificPricingRegion - ? objectStoragePriceIncreaseMap[regionId].transfer_overage - : OBJ_STORAGE_PRICE.transfer_overage}{' '} + {transferOveragePrice && !isErrorTransferTypes + ? parseFloat(transferOveragePrice) + : UNKNOWN_PRICE}{' '} per GB {' '} if it exceeds{' '} diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts index e447e0246f6..2999f25419f 100644 --- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -2472,7 +2472,7 @@ export const oneClickApps: Record = { related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/couchbase/', + 'https://www.linode.com/docs/products/tools/marketplace/guides/couchbase-cluster/', title: 'Deploy a Couchbase Enterprise Server cluster through the Linode Marketplace', }, @@ -2495,7 +2495,7 @@ export const oneClickApps: Record = { related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-kafka/', + 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-kafka-cluster/', title: 'Deploy an Apache Kafka cluster through the Linode Marketplace', }, ], diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index ab4e75fcf48..cfaef030495 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -34,6 +34,7 @@ import { import type { PlacementGroupsCreateDrawerProps } from './types'; import type { CreatePlacementGroupPayload, Region } from '@linode/api-v4'; import type { FormikHelpers } from 'formik'; +import type { DisableRegionOption } from 'src/components/RegionSelect/RegionSelect.types'; export const PlacementGroupsCreateDrawer = ( props: PlacementGroupsCreateDrawerProps @@ -136,6 +137,34 @@ export const PlacementGroupsCreateDrawer = ( selectedRegion )}`; + const disabledRegions = regions?.reduce>( + (acc, region) => { + const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity({ + allPlacementGroups: allPlacementGroupsInRegion, + region, + }); + if (isRegionAtCapacity) { + acc[region.id] = { + reason: ( + <> + + You’ve reached the limit of placement groups you can create in + this region. + + + {MAXIMUM_NUMBER_OF_PLACEMENT_GROUPS_IN_REGION}{' '} + {getMaxPGsPerCustomer(region)} + + + ), + tooltipWidth: 300, + }; + } + return acc; + }, + {} + ); + return ( { - const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity( - { - allPlacementGroups: allPlacementGroupsInRegion, - region, - } - ); - - return { - disabled: isRegionAtCapacity, - reason: ( - <> - - You’ve reached the limit of placement groups you can - create in this region. - - - {MAXIMUM_NUMBER_OF_PLACEMENT_GROUPS_IN_REGION}{' '} - {getMaxPGsPerCustomer(region)} - - - ), - tooltipWidth: 300, - }; - }} - handleSelection={(selection) => { - handleRegionSelect(selection); - }} currentCapability="Placement Group" + disableClearable + disabledRegions={disabledRegions} helperText={values.region && pgRegionLimitHelperText} + onChange={(e, region) => handleRegionSelect(region.id)} regions={regions ?? []} - selectedId={selectedRegionId ?? values.region} tooltipText="Only Linode data center regions that support placement groups are listed." + value={selectedRegionId ?? values.region} /> )} { + // Very basic unit - the functionality is tested in the integration test + it('should render', () => { + const { getByRole } = renderWithTheme(); + + expect(getByRole('button', { name: 'Notifications' })).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx new file mode 100644 index 00000000000..a83eb52ef4c --- /dev/null +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx @@ -0,0 +1,182 @@ +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 bc5c34fcdc1..f26072d765d 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -8,11 +8,13 @@ import { IconButton } from 'src/components/IconButton'; import { Toolbar } from 'src/components/Toolbar'; import { Typography } from 'src/components/Typography'; import { useAuthentication } from 'src/hooks/useAuthentication'; +import { useFlags } from 'src/hooks/useFlags'; import { AddNewMenu } from './AddNewMenu/AddNewMenu'; import { Community } from './Community'; import { Help } from './Help'; import { NotificationMenu } from './NotificationMenu/NotificationMenu'; +import { NotificationMenuV2 } from './NotificationMenu/NotificationMenuV2'; import SearchBar from './SearchBar/SearchBar'; import { TopMenuTooltip } from './TopMenuTooltip'; import { UserMenu } from './UserMenu'; @@ -30,6 +32,8 @@ export interface TopMenuProps { */ export const TopMenu = React.memo((props: TopMenuProps) => { const { desktopMenuToggle, isSideMenuOpen, openSideMenu, username } = props; + // TODO eventMessagesV2: delete when flag is removed + const flags = useFlags(); const { loggedInAsCustomer } = useAuthentication(); @@ -92,7 +96,11 @@ export const TopMenu = React.memo((props: TopMenuProps) => { - + {flags.eventMessagesV2 ? ( + + ) : ( + + )} diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index b12626ffe3b..11bd0655a1f 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -58,10 +58,9 @@ export const VPCTopSectionContent = (props: Props) => { currentCapability="VPCs" disabled={isDrawer ? true : disabled} errorText={errors.region} - handleSelection={(region: string) => onChangeField('region', region)} - isClearable + onChange={(e, region) => onChangeField('region', region?.id ?? '')} regions={regions} - selectedId={values.region} + value={values.region} /> ) => diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index edcd25709f5..104da2923f8 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -1,4 +1,3 @@ -import { UpdateVPCPayload, VPC } from '@linode/api-v4/lib/vpcs/types'; import { updateVPCSchema } from '@linode/validation/lib/vpcs.schema'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -13,6 +12,8 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { useUpdateVPCMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { UpdateVPCPayload, VPC } from '@linode/api-v4/lib/vpcs/types'; + interface Props { onClose: () => void; open: boolean; @@ -120,10 +121,10 @@ export const VPCEditDrawer = (props: Props) => { currentCapability="VPCs" disabled // the Region field will not be editable during beta errorText={(regionsError && regionsError[0].reason) || undefined} - handleSelection={() => null} helperText={REGION_HELPER_TEXT} + onChange={() => null} regions={regionsData} - selectedId={vpc?.region ?? null} + value={vpc?.region} /> )} { /> { - setFieldValue('region', value); + onChange={(e, region) => { + setFieldValue('region', region?.id ?? null); setFieldValue('linode_id', null); }} currentCapability="Block Storage" disabled={doesNotHavePermission} errorText={touched.region ? errors.region : undefined} - isClearable label="Region" onBlur={handleBlur} regions={regions ?? []} - selectedId={values.region} + value={values.region} width={400} /> {renderSelectTooltip( diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index 662cb870e29..120dea0dccb 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -1,9 +1,13 @@ +import CloseIcon from '@mui/icons-material/Close'; import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import { debounce } from 'throttle-debounce'; import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; +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'; @@ -11,7 +15,9 @@ 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 { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TextField } from 'src/components/TextField'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useVolumesQuery } from 'src/queries/volumes/volumes'; @@ -31,13 +37,14 @@ import { VolumeTableRow } from './VolumeTableRow'; import type { Volume } from '@linode/api-v4'; const preferenceKey = 'volumes'; +const searchQueryKey = 'query'; export const VolumesLanding = () => { const history = useHistory(); - const location = useLocation<{ volume: Volume | undefined }>(); - const pagination = usePagination(1, preferenceKey); + const queryParams = new URLSearchParams(location.search); + const volumeLabelFromParam = queryParams.get(searchQueryKey) ?? ''; const { handleOrderChange, order, orderBy } = useOrder( { @@ -52,14 +59,17 @@ export const VolumesLanding = () => { ['+order_by']: orderBy, }; - const { data: volumes, error, isLoading } = useVolumesQuery( + if (volumeLabelFromParam) { + filter['label'] = { '+contains': volumeLabelFromParam }; + } + + const { data: volumes, error, isFetching, isLoading } = useVolumesQuery( { page: pagination.page, page_size: pagination.pageSize, }, filter ); - const [selectedVolumeId, setSelectedVolumeId] = React.useState(); const [isDetailsDrawerOpen, setIsDetailsDrawerOpen] = React.useState( Boolean(location.state?.volume) @@ -114,6 +124,17 @@ export const VolumesLanding = () => { setIsUpgradeDialogOpen(true); }; + const resetSearch = () => { + queryParams.delete(searchQueryKey); + history.push({ search: queryParams.toString() }); + }; + + const onSearch = (e: React.ChangeEvent) => { + queryParams.delete('page'); + queryParams.set(searchQueryKey, e.target.value); + history.push({ search: queryParams.toString() }); + }; + if (isLoading) { return ; } @@ -128,7 +149,7 @@ export const VolumesLanding = () => { ); } - if (volumes?.results === 0) { + if (volumes?.results === 0 && !volumeLabelFromParam) { return ; } @@ -136,11 +157,41 @@ export const VolumesLanding = () => { <> history.push('/volumes/create')} title="Volumes" /> + + {isFetching && } + + + + + + ), + }} + onChange={debounce(400, (e) => { + onSearch(e); + })} + hideLabel + label="Search" + placeholder="Search Volumes" + sx={{ mb: 2 }} + value={volumeLabelFromParam} + /> @@ -174,6 +225,9 @@ export const VolumesLanding = () => { + {volumes?.data.length === 0 && ( + + )} {volumes?.data.map((volume) => ( event.action.startsWith('nodebalancer'), - handler: nodebalanacerEventHandler, + handler: nodebalancerEventHandler, }, { filter: (event) => event.action.startsWith('oauth_client'), diff --git a/packages/manager/src/hooks/useOrder.test.tsx b/packages/manager/src/hooks/useOrder.test.tsx index 5a31d40d4fe..4451588aa48 100644 --- a/packages/manager/src/hooks/useOrder.test.tsx +++ b/packages/manager/src/hooks/useOrder.test.tsx @@ -1,6 +1,4 @@ -import { QueryClient } from '@tanstack/react-query'; import { act, renderHook, waitFor } from '@testing-library/react'; - import { HttpResponse, http, server } from 'src/mocks/testServer'; import { queryClientFactory } from 'src/queries/base'; import { usePreferences } from 'src/queries/profile/preferences'; @@ -77,8 +75,7 @@ describe('useOrder hook', () => { }); it('use preferences are used when there are no query params', async () => { - const queryClient = new QueryClient(); - + const queryClient = queryClientFactory(); server.use( http.get('*/profile/preferences', () => { return HttpResponse.json({ diff --git a/packages/manager/src/index.tsx b/packages/manager/src/index.tsx index dd965472ab0..06a0153ff9d 100644 --- a/packages/manager/src/index.tsx +++ b/packages/manager/src/index.tsx @@ -21,7 +21,7 @@ import './index.css'; import { LinodeThemeWrapper } from './LinodeThemeWrapper'; import { queryClientFactory } from './queries/base'; -const queryClient = queryClientFactory(); +const queryClient = queryClientFactory('longLived'); const store = storeFactory(); setupInterceptors(store); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index f9511523f12..f0ad8f8fc6d 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1,11 +1,3 @@ -import { - NotificationType, - ObjectStorageKeyRequest, - SecurityQuestionsPayload, - TokenRequest, - User, - VolumeStatus, -} from '@linode/api-v4'; import { DateTime } from 'luxon'; import { HttpResponse, http } from 'msw'; @@ -55,6 +47,8 @@ import { linodeStatsFactory, linodeTransferFactory, linodeTypeFactory, + lkeHighAvailabilityTypeFactory, + lkeStandardAvailabilityTypeFactory, loadbalancerEndpointHealthFactory, loadbalancerFactory, longviewActivePlanFactory, @@ -108,6 +102,15 @@ import { grantFactory, grantsFactory } from 'src/factories/grants'; import { pickRandom } from 'src/utilities/random'; import { getStorage } from 'src/utilities/storage'; +import type { + NotificationType, + ObjectStorageKeyRequest, + SecurityQuestionsPayload, + TokenRequest, + User, + VolumeStatus, +} from '@linode/api-v4'; + export const makeResourcePage = ( e: T[], override: { page: number; pages: number; results?: number } = { @@ -604,7 +607,7 @@ export const handlers = [ http.get('*/regions', async () => { return HttpResponse.json(makeResourcePage(regions)); }), - http.get('*/images', async () => { + http.get('*/images', async ({ request }) => { const privateImages = imageFactory.buildList(5, { status: 'available', type: 'manual', @@ -624,6 +627,16 @@ export const handlers = [ status: 'available', type: 'manual', }); + const multiRegionsImage = imageFactory.build({ + id: 'multi-regions-test-image', + label: 'multi-regions-test-image', + regions: [ + { region: 'us-southeast', status: 'available' }, + { region: 'us-east', status: 'pending' }, + ], + status: 'available', + type: 'manual', + }); const creatingImages = imageFactory.buildList(2, { status: 'creating', type: 'manual', @@ -637,16 +650,31 @@ export const handlers = [ type: 'automatic', }); const publicImages = imageFactory.buildList(4, { is_public: true }); + const distributedImage = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-images'], + label: 'distributed-image', + regions: [{ region: 'us-east', status: 'available' }], + }); const images = [ cloudinitCompatableDistro, cloudinitCompatableImage, + multiRegionsImage, + distributedImage, ...automaticImages, ...privateImages, ...publicImages, ...pendingImages, ...creatingImages, ]; - return HttpResponse.json(makeResourcePage(images)); + return HttpResponse.json( + makeResourcePage( + images.filter((image) => + request.headers.get('x-filter')?.includes('manual') + ? image.type == 'manual' + : image.type == 'automatic' + ) + ) + ); }), http.get('*/linode/types', () => { @@ -832,6 +860,13 @@ export const handlers = [ const clusters = kubernetesAPIResponse.buildList(10); return HttpResponse.json(makeResourcePage(clusters)); }), + http.get('*/lke/types', async () => { + const lkeTypes = [ + lkeStandardAvailabilityTypeFactory.build(), + lkeHighAvailabilityTypeFactory.build(), + ]; + return HttpResponse.json(makeResourcePage(lkeTypes)); + }), http.get('*/lke/versions', async () => { const versions = kubernetesVersionFactory.buildList(1); return HttpResponse.json(makeResourcePage(versions)); diff --git a/packages/manager/src/queries/aclb/requests.ts b/packages/manager/src/queries/aclb/requests.ts new file mode 100644 index 00000000000..197d4f0d2af --- /dev/null +++ b/packages/manager/src/queries/aclb/requests.ts @@ -0,0 +1,14 @@ +import { Filter, Loadbalancer, Params, getLoadbalancers } from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +export const getAllLoadbalancers = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getLoadbalancers( + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); diff --git a/packages/manager/src/queries/base.ts b/packages/manager/src/queries/base.ts index 072b0035700..b4157095922 100644 --- a/packages/manager/src/queries/base.ts +++ b/packages/manager/src/queries/base.ts @@ -31,9 +31,22 @@ export const queryPresets = { }, }; -export const queryClientFactory = () => { +/** + * Creates and returns a new TanStack Query query client instance. + * + * Allows the query client behavior to be configured by specifying a preset. The + * 'longLived' preset is most suitable for production use, while 'oneTimeFetch' is + * preferred for tests. + * + * @param preset - Optional query preset for client. Either 'longLived' or 'oneTimeFetch'. + * + * @returns New `QueryClient` instance. + */ +export const queryClientFactory = ( + preset: 'longLived' | 'oneTimeFetch' = 'oneTimeFetch' +) => { return new QueryClient({ - defaultOptions: { queries: queryPresets.longLived }, + defaultOptions: { queries: queryPresets[preset] }, }); }; diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts new file mode 100644 index 00000000000..b8437f577f2 --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -0,0 +1,53 @@ +import { Filter, Params } from '@linode/api-v4'; +import { useQuery } from '@tanstack/react-query'; + +import { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; + +import { getAllLoadbalancers } from '../aclb/requests'; +import { getAllLinodesRequest } from '../linodes/requests'; +import { volumeQueries } from '../volumes/volumes'; + +// in this we don't need to define our own query factory, we will reuse existing query factory implementation from services like in volumes.ts, linodes.ts etc +export const QueryFactoryByResources = ( + resourceType: string | undefined, + params?: Params, + filters?: Filter +) => { + switch (resourceType) { + case 'linode': + return { + queryFn: () => getAllLinodesRequest(params, filters), // since we don't have query factory implementation, in linodes.ts, once it is ready we will reuse that, untill then we will use same query keys + queryKey: ['linodes', params, filters], + }; + case 'volumes': + return volumeQueries.lists._ctx.all(params, filters); // in this we don't need to define our own query factory, we will reuse existing implementation in volumes.ts + case 'aclb': + return { + queryFn: () => getAllLoadbalancers(params, filters), // since we don't have query factory implementation, in loadbalancer.ts, once it is ready we will reuse that, untill then we will use same query keys + queryKey: ['loadbalancers', params, filters], + }; + default: + return volumeQueries.lists._ctx.all(params, filters); // default to volumes + } +}; + +export const useResourcesQuery = ( + enabled = false, + resourceType: string | undefined, + params?: Params, + filters?: Filter +) => + useQuery({ + ...QueryFactoryByResources(resourceType, params, filters), + enabled, + select: (resources) => { + return resources.map((resource) => { + return { + id: resource.id, + label: resource.label, + region: resource.region, + regions: resource.regions ? resource.regions : [], + }; + }); + }, + }); diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index fb21b5289fe..41ab63b8be9 100644 --- a/packages/manager/src/queries/domains.ts +++ b/packages/manager/src/queries/domains.ts @@ -1,10 +1,4 @@ import { - CloneDomainPayload, - CreateDomainPayload, - Domain, - DomainRecord, - ImportZonePayload, - UpdateDomainPayload, cloneDomain, createDomain, deleteDomain, @@ -13,87 +7,156 @@ import { getDomains, importZone, updateDomain, -} from '@linode/api-v4/lib/domains'; -import { +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { getAll } from 'src/utilities/getAll'; + +import { profileQueries } from './profile/profile'; + +import type { APIError, + CloneDomainPayload, + CreateDomainPayload, + Domain, + DomainRecord, Filter, + ImportZonePayload, Params, ResourcePage, -} from '@linode/api-v4/lib/types'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + UpdateDomainPayload, +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { getAll } from 'src/utilities/getAll'; +export const getAllDomains = () => + getAll((params) => getDomains(params))().then((data) => data.data); -import { profileQueries } from './profile/profile'; +const getAllDomainRecords = (domainId: number) => + getAll((params) => getDomainRecords(domainId, params))().then( + ({ data }) => data + ); -export const queryKey = 'domains'; +const domainQueries = createQueryKeys('domains', { + domain: (id: number) => ({ + contextQueries: { + records: { + queryFn: () => getAllDomainRecords(id), + queryKey: null, + }, + }, + queryFn: () => getDomain(id), + queryKey: [id], + }), + domains: { + contextQueries: { + all: { + queryFn: getAllDomains, + queryKey: null, + }, + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getDomains(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); export const useDomainsQuery = (params: Params, filter: Filter) => - useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getDomains(params, filter), - { keepPreviousData: true } - ); + useQuery, APIError[]>({ + ...domainQueries.domains._ctx.paginated(params, filter), + keepPreviousData: true, + }); export const useAllDomainsQuery = (enabled: boolean = false) => - useQuery([queryKey, 'all'], getAllDomains, { + useQuery({ + ...domainQueries.domains._ctx.all, enabled, }); export const useDomainQuery = (id: number) => - useQuery([queryKey, 'domain', id], () => getDomain(id)); + useQuery(domainQueries.domain(id)); export const useDomainRecordsQuery = (id: number) => - useQuery( - [queryKey, 'domain', id, 'records'], - () => getAllDomainRecords(id) - ); + useQuery(domainQueries.domain(id)._ctx.records); export const useCreateDomainMutation = () => { const queryClient = useQueryClient(); - return useMutation(createDomain, { - onSuccess: (domain) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'domain', domain.id], domain); + return useMutation({ + mutationFn: createDomain, + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain + ); + // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); + queryClient.invalidateQueries({ + queryKey: profileQueries.grants.queryKey, + }); }, }); }; export const useCloneDomainMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => cloneDomain(id, data), - { - onSuccess: (domain) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'domain', domain.id], domain); - }, - } - ); + return useMutation({ + mutationFn: (data) => cloneDomain(id, data), + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain + ); + }, + }); }; export const useImportZoneMutation = () => { const queryClient = useQueryClient(); - return useMutation( - (data) => importZone(data), - { - onSuccess: (domain) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'domain', domain.id], domain); - }, - } - ); + return useMutation({ + mutationFn: importZone, + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain + ); + }, + }); }; export const useDeleteDomainMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteDomain(id), { - onSuccess: () => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.removeQueries([queryKey, 'domain', id]); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteDomain(id), + onSuccess() { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Remove domain (and its sub-queries) from the cache + queryClient.removeQueries({ + queryKey: domainQueries.domain(id).queryKey, + }); }, }); }; @@ -104,33 +167,48 @@ interface UpdateDomainPayloadWithId extends UpdateDomainPayload { export const useUpdateDomainMutation = () => { const queryClient = useQueryClient(); - return useMutation( - (data) => { - const { id, ...rest } = data; - return updateDomain(id, rest); + return useMutation({ + mutationFn: ({ id, ...data }) => updateDomain(id, data), + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Update domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain + ); }, - { - onSuccess: (domain) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData( - [queryKey, 'domain', domain.id], - domain - ); - }, - } - ); + }); }; -export const domainEventsHandler = ({ queryClient }: EventHandlerData) => { - // Invalidation is agressive beacuse it will invalidate on every domain event, but - // it is worth it for the UX benefits. We can fine tune this later if we need to. - queryClient.invalidateQueries([queryKey]); +export const domainEventsHandler = ({ + event, + queryClient, +}: EventHandlerData) => { + const domainId = event.entity?.id; + + if (!domainId) { + return; + } + + if (event.action.startsWith('domain_record')) { + // Invalidate the domain's records because they may have changed + queryClient.invalidateQueries({ + queryKey: domainQueries.domain(domainId)._ctx.records.queryKey, + }); + } else { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Invalidate the domain's details + queryClient.invalidateQueries({ + exact: true, + queryKey: domainQueries.domain(domainId).queryKey, + }); + } }; - -export const getAllDomains = () => - getAll((params) => getDomains(params))().then((data) => data.data); - -const getAllDomainRecords = (domainId: number) => - getAll((params) => getDomainRecords(domainId, params))().then( - ({ data }) => data - ); diff --git a/packages/manager/src/queries/events/event.helpers.test.ts b/packages/manager/src/queries/events/event.helpers.test.ts index bb8a45b671c..8b156961ec6 100644 --- a/packages/manager/src/queries/events/event.helpers.test.ts +++ b/packages/manager/src/queries/events/event.helpers.test.ts @@ -161,7 +161,12 @@ describe('requestFilters', () => { it('generates a simple filter when pollIDs is empty', () => { const result = generatePollingFilter(timestamp, []); - expect(result).toEqual({ created: { '+gte': timestamp } }); + expect(result).toEqual({ + '+order': 'desc', + '+order_by': 'id', + action: { '+neq': 'profile_update' }, + created: { '+gte': timestamp }, + }); }); it('handles "in" IDs', () => { @@ -174,12 +179,18 @@ describe('requestFilters', () => { { id: 2 }, { id: 3 }, ], + '+order': 'desc', + '+order_by': 'id', + action: { '+neq': 'profile_update' }, }); }); it('handles "+neq" IDs', () => { const result = generatePollingFilter(timestamp, [], [1, 2, 3]); expect(result).toEqual({ + '+order': 'desc', + '+order_by': 'id', + action: { '+neq': 'profile_update' }, '+and': [ { created: { '+gte': timestamp } }, { id: { '+neq': 1 } }, @@ -192,6 +203,9 @@ describe('requestFilters', () => { it('handles "in" and "+neq" IDs together', () => { const result = generatePollingFilter(timestamp, [1, 2, 3], [4, 5, 6]); expect(result).toEqual({ + '+order': 'desc', + '+order_by': 'id', + action: { '+neq': 'profile_update' }, '+or': [ { '+and': [ diff --git a/packages/manager/src/queries/events/event.helpers.ts b/packages/manager/src/queries/events/event.helpers.ts index fe1e643d8eb..b82a64a2b50 100644 --- a/packages/manager/src/queries/events/event.helpers.ts +++ b/packages/manager/src/queries/events/event.helpers.ts @@ -1,4 +1,6 @@ -import { Event, EventAction, Filter } from '@linode/api-v4'; +import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; + +import type { Event, EventAction, Filter } from '@linode/api-v4'; export const isInProgressEvent = (event: Event) => { if (event.percent_complete === null) { @@ -103,8 +105,10 @@ export const generatePollingFilter = ( timestamp: string, inIds: number[] = [], neqIds: number[] = [] -) => { - let filter: Filter = { created: { '+gte': timestamp } }; +): Filter => { + let filter: Filter = { + created: { '+gte': timestamp }, + }; if (neqIds.length > 0) { filter = { @@ -118,7 +122,12 @@ export const generatePollingFilter = ( }; } - return filter; + return { + ...filter, + ...EVENTS_LIST_FILTER, + '+order': 'desc', + '+order_by': 'id', + }; }; /** diff --git a/packages/manager/src/queries/events/events.ts b/packages/manager/src/queries/events/events.ts index 5e8554cd756..62af8422b0e 100644 --- a/packages/manager/src/queries/events/events.ts +++ b/packages/manager/src/queries/events/events.ts @@ -1,17 +1,15 @@ import { getEvents, markEventSeen } from '@linode/api-v4'; -import { DateTime } from 'luxon'; -import { useRef } from 'react'; import { - InfiniteData, - QueryClient, - QueryKey, useInfiniteQuery, useMutation, useQuery, useQueryClient, } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; +import { useRef } from 'react'; import { ISO_DATETIME_NO_TZ_FORMAT, POLLING_INTERVALS } from 'src/constants'; +import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; import { useEventHandlers } from 'src/hooks/useEventHandlers'; import { useToastNotifications } from 'src/hooks/useToastNotifications'; import { @@ -22,6 +20,11 @@ import { } from 'src/queries/events/event.helpers'; import type { APIError, Event, Filter, ResourcePage } from '@linode/api-v4'; +import type { + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query'; /** * Gets an infinitely scrollable list of all Events @@ -35,13 +38,18 @@ import type { APIError, Event, Filter, ResourcePage } from '@linode/api-v4'; * We are doing this as opposed to page based pagination because we need an accurate way to get * the next set of events when the items returned by the server may have shifted. */ -export const useEventsInfiniteQuery = (filter?: Filter) => { +export const useEventsInfiniteQuery = (filter: Filter = EVENTS_LIST_FILTER) => { const query = useInfiniteQuery, APIError[]>( ['events', 'infinite', filter], ({ pageParam }) => getEvents( {}, - { ...filter, id: pageParam ? { '+lt': pageParam } : undefined } + { + ...filter, + '+order': 'desc', + '+order_by': 'id', + id: pageParam ? { '+lt': pageParam } : undefined, + } ), { cacheTime: Infinity, @@ -124,7 +132,7 @@ export const useEventsPoller = () => { const data = queryClient.getQueryData>>([ 'events', 'infinite', - undefined, + EVENTS_LIST_FILTER, ]); const events = data?.pages.reduce( (events, page) => [...events, ...page.data], @@ -199,8 +207,8 @@ export const useMarkEventsAsSeen = () => { (eventId) => markEventSeen(eventId), { onSuccess: (_, eventId) => { - queryClient.setQueryData>>( - ['events', 'infinite', undefined], + queryClient.setQueriesData>>( + ['events', 'infinite'], (prev) => { if (!prev) { return { @@ -311,6 +319,11 @@ export const updateEventsQuery = ( if (newEvents.length > 0) { // For all events, that remain, append them to the top of the events list prev.pages[0].data = [...newEvents, ...prev.pages[0].data]; + + // Update the `results` value for all pages so it is up to date + for (const page of prev.pages) { + page.results += newEvents.length; + } } return { diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index a328021b773..e1b2b92eea8 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -1,9 +1,4 @@ import { - CreateFirewallPayload, - Firewall, - FirewallDevice, - FirewallDevicePayload, - FirewallRules, addFirewallDevice, createFirewall, deleteFirewall, @@ -14,48 +9,187 @@ import { updateFirewall, updateFirewallRules, } from '@linode/api-v4/lib/firewalls'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; -import { updateInPaginatedStore } from './base'; +import { nodebalancerQueries } from './nodebalancers'; import { profileQueries } from './profile/profile'; -export const queryKey = 'firewall'; +import type { + APIError, + CreateFirewallPayload, + Filter, + Firewall, + FirewallDevice, + FirewallDevicePayload, + FirewallRules, + Params, + ResourcePage, +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; + +const getAllFirewallDevices = ( + id: number, + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getFirewallDevices( + id, + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); + +const getAllFirewallsRequest = () => + getAll((passedParams, passedFilter) => + getFirewalls(passedParams, passedFilter) + )().then((data) => data.data); + +export const firewallQueries = createQueryKeys('firewalls', { + firewall: (id: number) => ({ + contextQueries: { + devices: { + queryFn: () => getAllFirewallDevices(id), + queryKey: null, + }, + }, + queryFn: () => getFirewall(id), + queryKey: [id], + }), + firewalls: { + contextQueries: { + all: { + queryFn: getAllFirewallsRequest, + queryKey: null, + }, + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getFirewalls(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); export const useAllFirewallDevicesQuery = (id: number) => useQuery( - [queryKey, 'firewall', id, 'devices'], - () => getAllFirewallDevices(id) + firewallQueries.firewall(id)._ctx.devices ); export const useAddFirewallDeviceMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => addFirewallDevice(id, data), - { - onSuccess(data) { - // Refresh the cached device list - queryClient.invalidateQueries([queryKey, 'firewall', id, 'devices']); - - // Refresh the cached result of the linode-specific firewalls query - queryClient.invalidateQueries([ - linodesQueryKey, - 'linode', - data.entity.id, - 'firewalls', - ]); - }, - } - ); + return useMutation({ + mutationFn: (data) => addFirewallDevice(id, data), + onSuccess(firewallDevice) { + // Append the new entity to the Firewall object in the paginated store + queryClient.setQueriesData>( + firewallQueries.firewalls._ctx.paginated._def, + (page) => { + if (!page) { + return undefined; + } + + const indexOfFirewall = page.data.findIndex( + (firewall) => firewall.id === id + ); + + // If the firewall does not exist on this page, don't change anything + if (indexOfFirewall === -1) { + return page; + } + + const firewall = page.data[indexOfFirewall]; + + const newData = [...page.data]; + + newData[indexOfFirewall] = { + ...firewall, + entities: [...firewall.entities, firewallDevice.entity], + }; + return { ...page, data: newData }; + } + ); + + // Append the new entity to the Firewall object in the "all firewalls" store + queryClient.setQueryData( + firewallQueries.firewalls._ctx.all.queryKey, + (firewalls) => { + if (!firewalls) { + return undefined; + } + + const indexOfFirewall = firewalls.findIndex( + (firewall) => firewall.id === id + ); + + // If the firewall does not exist in the list, don't do anything + if (indexOfFirewall === -1) { + return firewalls; + } + + const newFirewalls = [...firewalls]; + + const firewall = firewalls[indexOfFirewall]; + + newFirewalls[indexOfFirewall] = { + ...firewall, + entities: [...firewall.entities, firewallDevice.entity], + }; + + return newFirewalls; + } + ); + + // Append the new entity to the Firewall object + queryClient.setQueryData( + firewallQueries.firewall(id).queryKey, + (oldFirewall) => { + if (!oldFirewall) { + return undefined; + } + return { + ...oldFirewall, + entities: [...oldFirewall.entities, firewallDevice.entity], + }; + } + ); + + // Add device to the dedicated devices store + queryClient.setQueryData( + firewallQueries.firewall(id)._ctx.devices.queryKey, + (existingFirewallDevices) => { + if (!existingFirewallDevices) { + return [firewallDevice]; + } + return [...existingFirewallDevices, firewallDevice]; + } + ); + + // Refresh the cached result of the linode-specific firewalls query + if (firewallDevice.entity.type === 'linode') { + queryClient.invalidateQueries({ + queryKey: [ + linodesQueryKey, + 'linode', + firewallDevice.entity.id, + 'firewalls', + ], + }); + } + + // Refresh the cached result of the nodebalancer-specific firewalls query + if (firewallDevice.entity.type === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(firewallDevice.entity.id) + ._ctx.firewalls.queryKey, + }); + } + }, + }); }; export const useRemoveFirewallDeviceMutation = ( @@ -64,131 +198,281 @@ export const useRemoveFirewallDeviceMutation = ( ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteFirewallDevice(firewallId, deviceId), - { - onSuccess() { - queryClient.setQueryData( - [queryKey, 'firewall', firewallId, 'devices'], - (oldData) => { - return oldData?.filter((device) => device.id !== deviceId) ?? []; - } - ); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteFirewallDevice(firewallId, deviceId), + onSuccess() { + // Invalidate firewall lists because GET /v4/firewalls returns all entities for each firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Invalidate the firewall because the firewall objects has all entities and we want them to be in sync + queryClient.invalidateQueries({ + exact: true, + queryKey: firewallQueries.firewall(firewallId).queryKey, + }); + + // Remove device from the firewall's dedicaed devices store + queryClient.setQueryData( + firewallQueries.firewall(firewallId)._ctx.devices.queryKey, + (oldData) => { + return oldData?.filter((device) => device.id !== deviceId) ?? []; + } + ); + }, + }); }; export const useFirewallsQuery = (params?: Params, filter?: Filter) => { - return useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getFirewalls(params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...firewallQueries.firewalls._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; -export const useFirewallQuery = (id: number) => { - return useQuery([queryKey, 'firewall', id], () => - getFirewall(id) - ); -}; +export const useFirewallQuery = (id: number) => + useQuery(firewallQueries.firewall(id)); export const useAllFirewallsQuery = (enabled: boolean = true) => { - return useQuery( - [queryKey, 'all'], - getAllFirewallsRequest, - { enabled } - ); + return useQuery({ + ...firewallQueries.firewalls._ctx.all, + enabled, + }); }; export const useMutateFirewall = (id: number) => { const queryClient = useQueryClient(); - return useMutation>( - (data) => updateFirewall(id, data), - { - onSuccess(firewall) { - queryClient.setQueryData([queryKey, 'firewall', id], firewall); - queryClient.invalidateQueries([queryKey, 'paginated']); - }, - } - ); + return useMutation>({ + mutationFn: (data) => updateFirewall(id, data), + onSuccess(firewall) { + // Update the firewall in the store + queryClient.setQueryData( + firewallQueries.firewall(firewall.id).queryKey, + firewall + ); + + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + }, + }); }; export const useCreateFirewall = () => { const queryClient = useQueryClient(); - return useMutation( - (data) => createFirewall(data), - { - onSuccess(firewall) { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'firewall', firewall.id], firewall); - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); - }, - } - ); + return useMutation({ + mutationFn: createFirewall, + onSuccess(firewall) { + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Set the firewall in the store + queryClient.setQueryData( + firewallQueries.firewall(firewall.id).queryKey, + firewall + ); + + // If a restricted user creates an entity, we must make sure grants are up to date. + queryClient.invalidateQueries({ + queryKey: profileQueries.grants.queryKey, + }); + + // For each entity attached to the firewall upon creation, invalidate + // the entity's firewall query so that firewalls are up to date + // on the entity's details/settings page. + for (const entity of firewall.entities) { + if (entity.type === 'linode') { + queryClient.invalidateQueries({ + queryKey: [linodesQueryKey, 'linode', entity.id, 'firewalls'], + }); + } + if (entity.type === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(entity.id)._ctx.firewalls + .queryKey, + }); + } + } + }, + }); }; export const useDeleteFirewall = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteFirewall(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteFirewall(id), onSuccess() { - queryClient.removeQueries([queryKey, 'firewall', id]); - queryClient.invalidateQueries([queryKey, 'paginated']); + // Remove firewall and its subqueries from the cache + queryClient.removeQueries({ + queryKey: firewallQueries.firewall(id).queryKey, + }); + + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); }, }); }; export const useUpdateFirewallRulesMutation = (firewallId: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => updateFirewallRules(firewallId, data), - { - onSuccess(updatedRules) { - // Update rules on specific firewall - queryClient.setQueryData( - [queryKey, 'firewall', firewallId], - (oldData) => { - if (!oldData) { - return undefined; - } - return { ...oldData, rules: updatedRules }; + return useMutation({ + mutationFn: (data) => updateFirewallRules(firewallId, data), + onSuccess(updatedRules) { + // Update rules on specific firewall + queryClient.setQueryData( + firewallQueries.firewall(firewallId).queryKey, + (oldData) => { + if (!oldData) { + return undefined; + } + return { ...oldData, rules: updatedRules }; + } + ); + + // Update the Firewall object in the paginated store + queryClient.setQueriesData>( + firewallQueries.firewalls._ctx.paginated._def, + (page) => { + if (!page) { + return undefined; + } + + const indexOfFirewall = page.data.findIndex( + (firewall) => firewall.id === firewallId + ); + + // If the firewall does not exist on this page, don't change anything + if (indexOfFirewall === -1) { + return page; + } + + const firewall = page.data[indexOfFirewall]; + + const newData = [...page.data]; + + newData[indexOfFirewall] = { + ...firewall, + rules: updatedRules, + }; + return { ...page, data: newData }; + } + ); + + // Update the the Firewall object in the "all firewalls" store + queryClient.setQueryData( + firewallQueries.firewalls._ctx.all.queryKey, + (firewalls) => { + if (!firewalls) { + return undefined; } - ); - // update our paginated store with new rules - updateInPaginatedStore( - [queryKey, 'paginated'], - firewallId, - { - id: firewallId, + + const indexOfFirewall = firewalls.findIndex( + (firewall) => firewall.id === firewallId + ); + + // If the firewall does not exist in the list, don't do anything + if (indexOfFirewall === -1) { + return firewalls; + } + + const newFirewalls = [...firewalls]; + + const firewall = firewalls[indexOfFirewall]; + + newFirewalls[indexOfFirewall] = { + ...firewall, rules: updatedRules, - }, - queryClient - ); - }, - } - ); + }; + + return newFirewalls; + } + ); + }, + }); }; -const getAllFirewallDevices = ( - id: number, - passedParams: Params = {}, - passedFilter: Filter = {} -) => - getAll((params, filter) => - getFirewallDevices( - id, - { ...params, ...passedParams }, - { ...filter, ...passedFilter } - ) - )().then((data) => data.data); +export const firewallEventsHandler = ({ + event, + queryClient, +}: EventHandlerData) => { + if (!event.entity) { + // Ignore any events that don't have an associated entity + return; + } -const getAllFirewallsRequest = () => - getAll((passedParams, passedFilter) => - getFirewalls(passedParams, passedFilter) - )().then((data) => data.data); + switch (event.action) { + case 'firewall_delete': + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Remove firewall from the cache + queryClient.removeQueries({ + queryKey: firewallQueries.firewall(event.entity.id).queryKey, + }); + case 'firewall_create': + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + case 'firewall_device_add': + case 'firewall_device_remove': + // For a firewall device event, the primary entity is the fireall and + // the secondary entity is the device that is added/removed + + // If a Linode is added or removed as a firewall device, invalidate it's firewalls + if (event.secondary_entity && event.secondary_entity.type === 'linode') { + queryClient.invalidateQueries({ + queryKey: [ + 'linodes', + 'linode', + event.secondary_entity.id, + 'firewalls', + ], + }); + } + + // If a NodeBalancer is added or removed as a firewall device, invalidate it's firewalls + if ( + event.secondary_entity && + event.secondary_entity.type === 'nodebalancer' + ) { + queryClient.invalidateQueries({ + queryKey: [ + 'nodebalancers', + 'nodebalancer', + event.secondary_entity.id, + 'firewalls', + ], + }); + } + + // Invalidate the firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(event.entity.id).queryKey, + }); -export const firewallEventsHandler = ({ queryClient }: EventHandlerData) => { - // We will over-fetch a little bit, bit this ensures Cloud firewalls are *always* up to date - queryClient.invalidateQueries([queryKey]); + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + case 'firewall_disable': + case 'firewall_enable': + case 'firewall_rules_update': + case 'firewall_update': + // invalidate the firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(event.entity.id).queryKey, + }); + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + } }; diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 2318ef0deee..93d2717850b 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -83,7 +83,7 @@ export const useUpdateImageMutation = () => { { description?: string; imageId: string; label?: string; tags?: string[] } >({ mutationFn: ({ description, imageId, label, tags }) => - updateImage(imageId, label, description, tags), + updateImage(imageId, { description, label, tags }), onSuccess(image) { queryClient.invalidateQueries(imageQueries.paginated._def); queryClient.setQueryData( diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index e9573ad5283..39048c38627 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -1,12 +1,4 @@ import { - CreateKubeClusterPayload, - CreateNodePoolData, - KubeNodePoolResponse, - KubernetesCluster, - KubernetesDashboardResponse, - KubernetesEndpointResponse, - KubernetesVersion, - UpdateNodePoolData, createKubernetesCluster, createNodePool, deleteKubernetesCluster, @@ -16,6 +8,7 @@ import { getKubernetesClusterDashboard, getKubernetesClusterEndpoints, getKubernetesClusters, + getKubernetesTypes, getKubernetesVersions, getNodePools, recycleAllNodes, @@ -25,12 +18,6 @@ import { updateKubernetesCluster, updateNodePool, } from '@linode/api-v4'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -39,6 +26,24 @@ import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; import { profileQueries } from './profile/profile'; +import type { + CreateKubeClusterPayload, + CreateNodePoolData, + KubeNodePoolResponse, + KubernetesCluster, + KubernetesDashboardResponse, + KubernetesEndpointResponse, + KubernetesVersion, + UpdateNodePoolData, +} from '@linode/api-v4'; +import type { + APIError, + Filter, + Params, + PriceType, + ResourcePage, +} from '@linode/api-v4/lib/types'; + export const kubernetesQueries = createQueryKeys('kubernetes', { cluster: (id: number) => ({ contextQueries: { @@ -78,6 +83,10 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }, queryKey: null, }, + types: { + queryFn: () => getAllKubernetesTypes(), + queryKey: null, + }, versions: { queryFn: () => getAllKubernetesVersions(), queryKey: null, @@ -312,3 +321,14 @@ const getAllAPIEndpointsForCluster = (clusterId: number) => getAll((params, filters) => getKubernetesClusterEndpoints(clusterId, params, filters) )().then((data) => data.data); + +const getAllKubernetesTypes = () => + getAll((params) => getKubernetesTypes(params))().then( + (results) => results.data + ); + +export const useKubernetesTypesQuery = () => + useQuery({ + ...queryPresets.oneTimeFetch, + ...kubernetesQueries.types, + }); diff --git a/packages/manager/src/queries/linodes/events.ts b/packages/manager/src/queries/linodes/events.ts index 0e9aaafcca0..01c19913bf3 100644 --- a/packages/manager/src/queries/linodes/events.ts +++ b/packages/manager/src/queries/linodes/events.ts @@ -1,10 +1,10 @@ -import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { queryKey as firewallsQueryKey } from 'src/queries/firewalls'; import { accountQueries } from '../account/queries'; +import { firewallQueries } from '../firewalls'; +import { volumeQueries } from '../volumes/volumes'; import { queryKey } from './linodes'; import type { Event } from '@linode/api-v4'; -import { volumeQueries } from '../volumes/volumes'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; /** * Event handler for Linode events @@ -94,7 +94,7 @@ export const linodeEventsHandler = ({ queryClient.invalidateQueries([queryKey, 'infinite']); // A Linode made have been on a Firewall's device list, but now that it is deleted, // it will no longer be listed as a device on that firewall. Here, we invalidate outdated firewall data. - queryClient.invalidateQueries([firewallsQueryKey]); + queryClient.invalidateQueries({ queryKey: firewallQueries._def }); // A Linode may have been attached to a Volume, but deleted. We need to refetch volumes data so that // the Volumes table does not show a Volume attached to a non-existant Linode. queryClient.invalidateQueries(volumeQueries.lists.queryKey); diff --git a/packages/manager/src/queries/linodes/firewalls.ts b/packages/manager/src/queries/linodes/firewalls.ts index 47e16d087a4..1e0f60a86cc 100644 --- a/packages/manager/src/queries/linodes/firewalls.ts +++ b/packages/manager/src/queries/linodes/firewalls.ts @@ -1,17 +1,12 @@ -import { - APIError, - Firewall, - ResourcePage, - getLinodeFirewalls, -} from '@linode/api-v4'; +import { getLinodeFirewalls } from '@linode/api-v4'; import { useQuery } from '@tanstack/react-query'; -import { queryPresets } from '../base'; import { queryKey } from './linodes'; +import type { APIError, Firewall, ResourcePage } from '@linode/api-v4'; + export const useLinodeFirewallsQuery = (linodeID: number) => useQuery, APIError[]>( [queryKey, 'linode', linodeID, 'firewalls'], - () => getLinodeFirewalls(linodeID), - queryPresets.oneTimeFetch + () => getLinodeFirewalls(linodeID) ); diff --git a/packages/manager/src/queries/networkTransfer.ts b/packages/manager/src/queries/networkTransfer.ts new file mode 100644 index 00000000000..29f9ed3b6ae --- /dev/null +++ b/packages/manager/src/queries/networkTransfer.ts @@ -0,0 +1,23 @@ +import { getNetworkTransferPrices } from '@linode/api-v4'; +import { useQuery } from '@tanstack/react-query'; + +import { getAll } from 'src/utilities/getAll'; + +import { queryPresets } from './base'; + +import type { APIError, PriceType } from '@linode/api-v4'; + +export const queryKey = 'network-transfer'; + +const getAllNetworkTransferPrices = () => + getAll((params) => getNetworkTransferPrices(params))().then( + (data) => data.data + ); + +export const useNetworkTransferPricesQuery = (enabled = true) => + useQuery({ + queryFn: getAllNetworkTransferPrices, + queryKey: [queryKey, 'prices'], + ...queryPresets.oneTimeFetch, + enabled, + }); diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index 0cfc7afdc16..b48c977d1c5 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -1,10 +1,4 @@ import { - CreateNodeBalancerConfig, - CreateNodeBalancerPayload, - Firewall, - NodeBalancer, - NodeBalancerConfig, - NodeBalancerStats, createNodeBalancer, createNodeBalancerConfig, deleteNodeBalancer, @@ -25,126 +19,194 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; -import { DateTime } from 'luxon'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { queryKey as firewallsQueryKey } from 'src/queries/firewalls'; -import { parseAPIDate } from 'src/utilities/date'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; -import { itemInListCreationHandler, itemInListMutationHandler } from './base'; +import { firewallQueries } from './firewalls'; import { profileQueries } from './profile/profile'; import type { APIError, + CreateNodeBalancerConfig, + CreateNodeBalancerPayload, Filter, + Firewall, + NodeBalancer, + NodeBalancerConfig, + NodeBalancerStats, Params, PriceType, ResourcePage, -} from '@linode/api-v4/lib/types'; - -export const queryKey = 'nodebalancers'; - -export const NODEBALANCER_STATS_NOT_READY_API_MESSAGE = - 'Stats are unavailable at this time.'; +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; const getAllNodeBalancerTypes = () => getAll((params) => getNodeBalancerTypes(params))().then( (results) => results.data ); -export const typesQueries = createQueryKeys('types', { +export const getAllNodeBalancerConfigs = (id: number) => + getAll((params) => + getNodeBalancerConfigs(id, params) + )().then((data) => data.data); + +export const getAllNodeBalancers = () => + getAll((params) => getNodeBalancers(params))().then( + (data) => data.data + ); + +export const nodebalancerQueries = createQueryKeys('nodebalancers', { + nodebalancer: (id: number) => ({ + contextQueries: { + configurations: { + queryFn: () => getAllNodeBalancerConfigs(id), + queryKey: null, + }, + firewalls: { + queryFn: () => getNodeBalancerFirewalls(id), + queryKey: null, + }, + stats: { + queryFn: () => getNodeBalancerStats(id), + queryKey: null, + }, + }, + queryFn: () => getNodeBalancer(id), + queryKey: [id], + }), nodebalancers: { + contextQueries: { + all: { + queryFn: getAllNodeBalancers, + queryKey: null, + }, + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getNodeBalancers({ page: pageParam, page_size: 25 }, filter), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getNodeBalancers(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + types: { queryFn: getAllNodeBalancerTypes, queryKey: null, }, }); -const getIsTooEarlyForStats = (created?: string) => { - if (!created) { - return false; - } - - return parseAPIDate(created) > DateTime.local().minus({ minutes: 5 }); -}; - -export const useNodeBalancerStats = (id: number, created?: string) => { - return useQuery( - [queryKey, 'nodebalancer', id, 'stats'], - getIsTooEarlyForStats(created) - ? () => - Promise.reject([{ reason: NODEBALANCER_STATS_NOT_READY_API_MESSAGE }]) - : () => getNodeBalancerStats(id), - // We need to disable retries because the API will - // error if stats are not ready. If the default retry policy - // is used, a "stats not ready" state can't be shown because the - // query is still trying to request. - { refetchInterval: 20000, retry: false } - ); +export const useNodeBalancerStatsQuery = (id: number) => { + return useQuery({ + ...nodebalancerQueries.nodebalancer(id)._ctx.stats, + refetchInterval: 20000, + retry: false, + }); }; export const useNodeBalancersQuery = (params: Params, filter: Filter) => - useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getNodeBalancers(params, filter), - { keepPreviousData: true } - ); + useQuery, APIError[]>({ + ...nodebalancerQueries.nodebalancers._ctx.paginated(params, filter), + keepPreviousData: true, + }); export const useNodeBalancerQuery = (id: number, enabled = true) => - useQuery( - [queryKey, 'nodebalancer', id], - () => getNodeBalancer(id), - { enabled } - ); + useQuery({ + ...nodebalancerQueries.nodebalancer(id), + enabled, + }); export const useNodebalancerUpdateMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation>( - (data) => updateNodeBalancer(id, data), - { - onSuccess(data) { - queryClient.invalidateQueries([queryKey]); - queryClient.setQueryData([queryKey, 'nodebalancer', id], data); - }, - } - ); + return useMutation>({ + mutationFn: (data) => updateNodeBalancer(id, data), + onSuccess(nodebalancer) { + // Invalidate paginated stores + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); + // Update the NodeBalancer store + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(id).queryKey, + nodebalancer + ); + }, + }); }; export const useNodebalancerDeleteMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteNodeBalancer(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteNodeBalancer(id), onSuccess() { - queryClient.removeQueries([queryKey, 'nodebalancer', id]); - queryClient.invalidateQueries([queryKey]); + // Remove NodeBalancer queries for this specific NodeBalancer + queryClient.removeQueries({ + queryKey: nodebalancerQueries.nodebalancer(id).queryKey, + }); + // Invalidate paginated stores + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); }, }); }; export const useNodebalancerCreateMutation = () => { const queryClient = useQueryClient(); - return useMutation( - createNodeBalancer, - { - onSuccess(data) { - queryClient.invalidateQueries([queryKey]); - queryClient.setQueryData([queryKey, 'nodebalancer', data.id], data); - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); - }, - } - ); + return useMutation({ + mutationFn: createNodeBalancer, + onSuccess(nodebalancer, variables) { + // Invalidate paginated stores + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); + // Prime the cache for this specific NodeBalancer + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(nodebalancer.id).queryKey, + nodebalancer + ); + // If a restricted user creates an entity, we must make sure grants are up to date. + queryClient.invalidateQueries({ + queryKey: profileQueries.grants.queryKey, + }); + + // If a NodeBalancer is assigned to a firewall upon creation, make sure we invalidate that firewall + // so it reflects the new entity. + if (variables.firewall_id) { + // Invalidate the paginated list of firewalls because GET /v4/networking/firewalls returns all firewall entities + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Invalidate the affected firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(variables.firewall_id).queryKey, + }); + } + }, + }); }; export const useNodebalancerConfigCreateMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createNodeBalancerConfig(id, data), - itemInListCreationHandler( - [queryKey, 'nodebalancer', id, 'configs'], - queryClient - ) - ); + return useMutation({ + mutationFn: (data) => createNodeBalancerConfig(id, data), + onSuccess(config) { + // Append new config to the configurations list + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(id)._ctx.configurations.queryKey, + (previousData) => { + if (!previousData) { + return [config]; + } + return [...previousData, config]; + } + ); + }, + }); }; interface CreateNodeBalancerConfigWithConfig @@ -158,109 +220,121 @@ export const useNodebalancerConfigUpdateMutation = (nodebalancerId: number) => { NodeBalancerConfig, APIError[], CreateNodeBalancerConfigWithConfig - >( - ({ configId, ...data }) => + >({ + mutationFn: ({ configId, ...data }) => updateNodeBalancerConfig(nodebalancerId, configId, data), - itemInListMutationHandler( - [queryKey, 'nodebalancer', nodebalancerId, 'configs'], - queryClient - ) - ); + onSuccess(config) { + // Update the config within the configs list + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(nodebalancerId)._ctx.configurations + .queryKey, + (previousData) => { + if (!previousData) { + return [config]; + } + const indexOfConfig = previousData.findIndex( + (c) => c.id === config.id + ); + if (indexOfConfig === -1) { + return [...previousData, config]; + } + const newConfigs = [...previousData]; + newConfigs[indexOfConfig] = config; + return newConfigs; + } + ); + }, + }); }; export const useNodebalancerConfigDeleteMutation = (nodebalancerId: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], { configId: number }>( - ({ configId }) => deleteNodeBalancerConfig(nodebalancerId, configId), - { - onSuccess(_, vars) { - queryClient.setQueryData( - [queryKey, 'nodebalancer', nodebalancerId, 'configs'], - (oldData) => { - return (oldData ?? []).filter( - (config) => config.id !== vars.configId - ); - } - ); - }, - } - ); + return useMutation<{}, APIError[], { configId: number }>({ + mutationFn: ({ configId }) => + deleteNodeBalancerConfig(nodebalancerId, configId), + onSuccess(_, vars) { + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(nodebalancerId)._ctx.configurations + .queryKey, + (oldData) => { + return (oldData ?? []).filter( + (config) => config.id !== vars.configId + ); + } + ); + }, + }); }; export const useAllNodeBalancerConfigsQuery = (id: number) => - useQuery( - [queryKey, 'nodebalanacer', id, 'configs'], - () => getAllNodeBalancerConfigs(id), - { refetchInterval: 20000 } - ); - -export const getAllNodeBalancerConfigs = (id: number) => - getAll((params) => - getNodeBalancerConfigs(id, params) - )().then((data) => data.data); - -export const getAllNodeBalancers = () => - getAll((params) => getNodeBalancers(params))().then( - (data) => data.data - ); + useQuery({ + ...nodebalancerQueries.nodebalancer(id)._ctx.configurations, + refetchInterval: 20000, + }); // Please don't use export const useAllNodeBalancersQuery = (enabled = true) => - useQuery([queryKey, 'all'], getAllNodeBalancers, { + useQuery({ + ...nodebalancerQueries.nodebalancers._ctx.all, enabled, }); export const useInfiniteNodebalancersQuery = (filter: Filter) => - useInfiniteQuery, APIError[]>( - [queryKey, 'infinite', filter], - ({ pageParam }) => - getNodeBalancers({ page: pageParam, page_size: 25 }, filter), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); - -export const nodebalanacerEventHandler = ({ - event, - queryClient, -}: EventHandlerData) => { - if (event.action.startsWith('nodebalancer_config')) { - queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - event.entity!.id, - 'configs', - ]); - } else if (event.action.startsWith('nodebalancer_delete')) { - queryClient.invalidateQueries([firewallsQueryKey]); - } else { - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'infinite']); - if (event.entity?.id) { - queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - event.entity.id, - ]); - } - } -}; + useInfiniteQuery, APIError[]>({ + ...nodebalancerQueries.nodebalancers._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); export const useNodeBalancersFirewallsQuery = (nodebalancerId: number) => useQuery, APIError[]>( - [queryKey, 'nodebalancer', nodebalancerId, 'firewalls'], - () => getNodeBalancerFirewalls(nodebalancerId), - queryPresets.oneTimeFetch + nodebalancerQueries.nodebalancer(nodebalancerId)._ctx.firewalls ); export const useNodeBalancerTypesQuery = () => useQuery({ ...queryPresets.oneTimeFetch, - ...typesQueries.nodebalancers, + ...nodebalancerQueries.types, }); + +export const nodebalancerEventHandler = ({ + event, + queryClient, +}: EventHandlerData) => { + const nodebalancerId = event.entity?.id; + + if (event.action.startsWith('nodebalancer_node')) { + // We don't store NodeBalancer nodes is React Query currently, so just skip these events + return; + } + + if (nodebalancerId === undefined) { + // Ignore events that don't have an associated NodeBalancer + return; + } + + if (event.action.startsWith('nodebalancer_config')) { + // If the event is about a NodeBalancer's configs, just invalidate the configs + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(nodebalancerId)._ctx + .configurations.queryKey, + }); + } else { + // If we've made it here, the event is about a NodeBalancer + + // Invalidate the specific NodeBalancer + queryClient.invalidateQueries({ + exact: true, + queryKey: nodebalancerQueries.nodebalancer(nodebalancerId).queryKey, + }); + + // Invalidate all paginated lists + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); + } +}; diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index 6e29a7ad7f6..cdd707c4631 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -1,19 +1,21 @@ -import { Domain } from '@linode/api-v4/lib/domains'; -import { Image } from '@linode/api-v4/lib/images'; -import { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; -import { Linode } from '@linode/api-v4/lib/linodes'; -import { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; -import { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; -import { Region } from '@linode/api-v4/lib/regions'; -import { Volume } from '@linode/api-v4/lib/volumes'; - import { getDescriptionForCluster } from 'src/features/Kubernetes/kubeUtils'; import { displayType } from 'src/features/Linodes/presentation'; -import { SearchableItem } from 'src/features/Search/search.interfaces'; -import { ExtendedType } from 'src/utilities/extendType'; import { getLinodeDescription } from 'src/utilities/getLinodeDescription'; import { readableBytes } from 'src/utilities/unitConversions'; +import type { + Domain, + Image, + KubernetesCluster, + Linode, + NodeBalancer, + ObjectStorageBucket, + Region, + Volume, +} from '@linode/api-v4'; +import type { SearchableItem } from 'src/features/Search/search.interfaces'; +import type { ExtendedType } from 'src/utilities/extendType'; + export const getLinodeIps = (linode: Linode): string[] => { const { ipv4, ipv6 } = linode; return ipv4.concat([ipv6 || '']); @@ -65,7 +67,7 @@ export const volumeToSearchableItem = (volume: Volume): SearchableItem => ({ created: volume.created, description: volume.size + ' GB', icon: 'volume', - path: `/volumes/${volume.id}`, + path: `/volumes?query=${volume.label}`, region: volume.region, tags: volume.tags, }, @@ -83,10 +85,9 @@ export const imageToSearchableItem = (image: Image): SearchableItem => ({ data: { created: image.created, description: image.description || '', - /* TODO: Update this with the Images icon! */ - icon: 'volume', + icon: 'image', /* TODO: Choose a real location for this to link to */ - path: `/images`, + path: `/images?query=${image.label}`, tags: [], }, entityType: 'image', diff --git a/packages/manager/src/store/store.helpers.test.ts b/packages/manager/src/store/store.helpers.test.ts deleted file mode 100644 index 89a5c371126..00000000000 --- a/packages/manager/src/store/store.helpers.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - addMany, - createDefaultState, - getAddRemoved, - onError, - onGetAllSuccess, - onStart, - removeMany, - updateInPlace, -} from './store.helpers'; - -describe('store.helpers', () => { - describe('getAddRemoved', () => { - const existingList = [{ id: '1' }, { id: 2 }, { id: 3 }]; - const newList = [{ id: 1 }, { id: '3' }, { id: 4 }]; - const result = getAddRemoved(existingList, newList); - - it('should return a list of new and removed items', () => { - const added = [{ id: 4 }]; - const removed = [{ id: 2 }]; - expect(result).toEqual([added, removed]); - }); - }); - - describe('createDefaultState', () => { - const result = createDefaultState(); - it('should return the unmodified defaultState', () => { - expect(result).toEqual({ - error: undefined, - items: [], - itemsById: {}, - lastUpdated: 0, - loading: false, - }); - }); - }); - - describe('removeMany', () => { - const state = createDefaultState({ - items: ['1', '2', '3'], - itemsById: { 1: { id: 1 }, 2: { id: 2 }, 3: { id: 3 } }, - }); - const result = removeMany(['2', '3'], state); - - it('should remove the object with the provided ID', () => { - expect(result).toEqual({ - ...state, - items: ['1'], - itemsById: { 1: { id: 1 } }, - }); - }); - }); - - describe('addMany', () => { - const state = createDefaultState({ - items: ['1', '2', '3'], - itemsById: { 1: { id: 1 }, 2: { id: 2 }, 3: { id: 3 } }, - }); - const result = addMany([{ id: 99 }, { id: 66 }], state); - - it('should remove the object with the provided ID', () => { - expect(result).toEqual({ - ...state, - items: ['1', '2', '3', '66', '99'], - itemsById: { - 1: { id: 1 }, - 2: { id: 2 }, - 3: { id: 3 }, - 66: { id: 66 }, - 99: { id: 99 }, - }, - }); - }); - }); - - describe('onError', () => { - const state = createDefaultState(); - const result = onError([{ reason: 'Something bad happened.' }], state); - - it('should update state with error and complete loading', () => { - expect(result).toEqual({ - ...createDefaultState(), - error: [{ reason: 'Something bad happened.' }], - loading: false, - }); - }); - }); - - describe('onGetAllSuccess', () => { - const state = createDefaultState(); - const result = onGetAllSuccess([{ id: 1 }, { id: 2 }], state); - - it('should finish loading', () => { - expect(result).toHaveProperty('loading', false); - }); - - it('should set items list', () => { - expect(result).toHaveProperty('items', ['1', '2']); - }); - - it('should set itemsById map', () => { - expect(result).toHaveProperty('itemsById', { - 1: { id: 1 }, - 2: { id: 2 }, - }); - }); - }); - - describe('onStart', () => { - const state = createDefaultState(); - const result = onStart(state); - - it('should set to true', () => { - expect(result).toHaveProperty('loading', true); - }); - }); - - describe('updateInPlace', () => { - interface TestEntity { - id: number; - status: 'active' | 'resizing'; - } - - const state = createDefaultState({ - items: ['1', '2', '3'], - itemsById: { - 1: { id: 1, status: 'active' }, - 2: { id: 2, status: 'active' }, - 3: { id: 3, status: 'active' }, - }, - }); - - const updateFn = (existing: TestEntity) => ({ - ...existing, - status: 'resizing', - }); - - it('should update the item when it exists in state', () => { - const updated = updateInPlace(1, updateFn, state); - expect(updated.itemsById[1].status).toBe('resizing'); - }); - - it('should not affect unspecified properties', () => { - const updated = updateInPlace(2, updateFn, state); - expect(updated.itemsById[2].id).toBe(2); - }); - - it('should return state as-is if the item with the given ID is not found', () => { - const updated = updateInPlace(4, updateFn, state); - expect(updated).toEqual(state); - }); - }); -}); diff --git a/packages/manager/src/store/store.helpers.tmp.ts b/packages/manager/src/store/store.helpers.tmp.ts deleted file mode 100644 index c9189e7cef6..00000000000 --- a/packages/manager/src/store/store.helpers.tmp.ts +++ /dev/null @@ -1,213 +0,0 @@ -// @todo rename this file to store.helpers when all reducers are using MappedEntityState2 -import { APIError } from '@linode/api-v4/lib/types'; -import { assoc, omit } from 'ramda'; -import { AsyncActionCreators } from 'typescript-fsa'; - -import { - Entity, - EntityError, - EntityMap, - MappedEntityState2 as MappedEntityState, - ThunkActionCreator, -} from 'src/store/types'; - -export const addEntityRecord = ( - result: EntityMap, - current: T -): EntityMap => assoc(String(current.id), current, result); - -export const onStart = (state: S) => - Object.assign({}, state, { error: { read: undefined }, loading: true }); - -export const onGetAllSuccess = ( - items: E[], - state: S, - results: number, - update: (e: E) => E = (i) => i -): S => - Object.assign({}, state, { - itemsById: items.reduce( - (itemsById, item) => ({ ...itemsById, [item.id]: update(item) }), - {} - ), - lastUpdated: Date.now(), - loading: false, - results, - }); - -export const setError = ( - error: EntityError, - state: MappedEntityState -) => { - return Object.assign({}, state, { error: { ...state.error, ...error } }); -}; - -export const onError = ( - error: E, - state: S -) => Object.assign({}, state, { error, loading: false }); - -export const createDefaultState = ( - override: Partial> = {}, - defaultError: O = {} as O -): MappedEntityState => ({ - error: defaultError as O, // @todo decide on better approach to error typing - itemsById: {}, - lastUpdated: 0, - loading: false, - results: 0, - ...override, -}); - -export const onDeleteSuccess = ( - id: number | string, - state: MappedEntityState -): MappedEntityState => { - return removeMany([String(id)], state); -}; - -export const onCreateOrUpdate = ( - entity: E, - state: MappedEntityState -): MappedEntityState => { - return addMany([entity], state); -}; - -export const removeMany = ( - list: string[], - state: MappedEntityState -): MappedEntityState => { - const itemsById = omit(list, state.itemsById); - - return { - ...state, - itemsById, - results: Object.keys(itemsById).length, - }; -}; - -export const addMany = ( - list: E[], - state: MappedEntityState, - results?: number -): MappedEntityState => { - const itemsById = list.reduce( - (map, item) => ({ ...map, [item.id]: item }), - state.itemsById - ); - - return { - ...state, - itemsById, - results: results ?? Object.keys(itemsById).length, - }; -}; - -/** - * Generates a list of entities added to an existing list, and a list of entities removed from an existing list. - */ -export const getAddRemoved = ( - existingList: E[] = [], - newList: E[] = [] -) => { - const existingIds = existingList.map(({ id }) => String(id)); - const newIds = newList.map(({ id }) => String(id)); - - const added = newList.filter(({ id }) => !existingIds.includes(String(id))); - - const removed = existingList.filter(({ id }) => !newIds.includes(String(id))); - - return [added, removed]; -}; - -export const onGetPageSuccess = ( - items: E[], - state: MappedEntityState, - results: number -): MappedEntityState => { - const isFullRequest = results === items.length; - const newState = addMany(items, state, results); - return isFullRequest - ? { - ...newState, - lastUpdated: Date.now(), - loading: false, - } - : { ...newState, loading: false }; -}; - -export const createRequestThunk = ( - actions: AsyncActionCreators, - request: (params: Req) => Promise -): ThunkActionCreator, Req> => { - return (params: Req) => async (dispatch) => { - const { done, failed, started } = actions; - - dispatch(started(params)); - - try { - const result = await request(params); - const doneAction = done({ params, result }); - dispatch(doneAction); - return result; - } catch (error) { - const failAction = failed({ error, params }); - dispatch(failAction); - return Promise.reject(error); - } - }; -}; - -export const updateInPlace = ( - id: number | string, - update: (e: E) => E, - state: MappedEntityState -) => { - const { itemsById } = state; - - // If this entity cannot be found in state, return the state as-is. - if (!itemsById[id]) { - return state; - } - - // Return the state as-is EXCEPT replacing the original entity with the updated entity. - const updated = update(itemsById[id]); - return { - ...state, - itemsById: { - ...itemsById, - [id]: updated, - }, - }; -}; - -// Given a nested state and an ID, ensures that MappedEntityState exists at the -// provided key. If the nested state already exists, return the state untouched. -// If it doesn't exist, initialize the state with `createDefaultState()`. -export const ensureInitializedNestedState = ( - state: Record, - id: number, - override: any = {} -) => { - if (!state[id]) { - state[id] = createDefaultState({ ...override, error: {} }); - } - return state; -}; - -export const apiResponseToMappedState = (data: T[]) => { - return data.reduce((acc, thisEntity) => { - acc[thisEntity.id] = thisEntity; - return acc; - }, {}); -}; - -export const onGetOneSuccess = ( - entity: E, - state: MappedEntityState -): MappedEntityState => - Object.assign({}, state, { - itemsById: { ...state.itemsById, [entity.id]: entity }, - loading: false, - results: Object.keys(state.itemsById).length, - }); diff --git a/packages/manager/src/store/store.helpers.ts b/packages/manager/src/store/store.helpers.ts index e0a0d9ac71d..702905afb04 100644 --- a/packages/manager/src/store/store.helpers.ts +++ b/packages/manager/src/store/store.helpers.ts @@ -1,121 +1,7 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { assoc, omit } from 'ramda'; -import { AsyncActionCreators } from 'typescript-fsa'; +import type { ThunkActionCreator } from 'src/store/types'; +import type { AsyncActionCreators } from 'typescript-fsa'; -import { - Entity, - EntityMap, - MappedEntityState, - ThunkActionCreator, -} from 'src/store/types'; - -/** ID's are all mapped to string. */ -export const mapIDs = (e: { id: number | string }) => String(e.id); -const keys = Object.keys; - -export const addEntityRecord = ( - result: EntityMap, - current: T -): EntityMap => assoc(String(current.id), current, result); - -export const onStart = (state: S) => - Object.assign({}, state, { error: undefined, loading: true }); - -export const onGetAllSuccess = ( - items: E[], - state: S, - update: (e: E) => E = (i) => i -): S => - Object.assign({}, state, { - items: items.map(mapIDs), - itemsById: items.reduce( - (itemsById, item) => ({ ...itemsById, [item.id]: update(item) }), - {} - ), - lastUpdated: Date.now(), - loading: false, - }); - -export const onError = ( - error: E, - state: S -) => Object.assign({}, state, { error, loading: false }); - -export const createDefaultState = < - E extends Entity, - O = APIError[] | undefined ->( - override: Partial> = {} -): MappedEntityState => ({ - error: undefined, - items: [], - itemsById: {}, - lastUpdated: 0, - loading: false, - ...override, -}); - -export const onDeleteSuccess = ( - id: number | string, - state: MappedEntityState -): MappedEntityState => { - return removeMany([String(id)], state); -}; - -export const onCreateOrUpdate = ( - entity: E, - state: MappedEntityState -): MappedEntityState => { - return addMany([entity], state); -}; - -export const removeMany = ( - list: string[], - state: MappedEntityState -): MappedEntityState => { - const itemsById = omit(list, state.itemsById); - - return { - ...state, - items: keys(itemsById), - itemsById, - }; -}; - -export const addMany = ( - list: E[], - state: MappedEntityState -): MappedEntityState => { - const itemsById = list.reduce( - (map, item) => ({ ...map, [item.id]: item }), - state.itemsById - ); - - return { - ...state, - items: keys(itemsById), - itemsById, - }; -}; - -/** - * Generates a list of entities added to an existing list, and a list of entities removed from an existing list. - */ -export const getAddRemoved = ( - existingList: E[] = [], - newList: E[] = [] -) => { - const existingIds = existingList.map(({ id }) => String(id)); - const newIds = newList.map(({ id }) => String(id)); - - const added = newList.filter(({ id }) => !existingIds.includes(String(id))); - - const removed = existingList.filter(({ id }) => !newIds.includes(String(id))); - - return [added, removed]; -}; - -export const createRequestThunk = ( +export const createRequestThunk = ( actions: AsyncActionCreators, request: (params: Req) => Promise ): ThunkActionCreator, Req> => { @@ -136,47 +22,3 @@ export const createRequestThunk = ( } }; }; - -export const updateInPlace = ( - id: number | string, - update: (e: E) => E, - state: MappedEntityState -) => { - const { itemsById } = state; - - // If this entity cannot be found in state, return the state as-is. - if (!itemsById[id]) { - return state; - } - - // Return the state as-is EXCEPT replacing the original entity with the updated entity. - const updated = update(itemsById[id]); - return { - ...state, - itemsById: { - ...itemsById, - [id]: updated, - }, - }; -}; - -// Given a nested state and an ID, ensures that MappedEntityState exists at the -// provided key. If the nested state already exists, return the state untouched. -// If it doesn't exist, initialize the state with `createDefaultState()`. -export const ensureInitializedNestedState = ( - state: Record, - id: number, - override: any = {} -) => { - if (!state[id]) { - state[id] = createDefaultState({ ...override, error: {} }); - } - return state; -}; - -export const apiResponseToMappedState = (data: T[]) => { - return data.reduce((acc, thisEntity) => { - acc[thisEntity.id] = thisEntity; - return acc; - }, {}); -}; diff --git a/packages/manager/src/store/types.ts b/packages/manager/src/store/types.ts index 43e680b2565..e56031924a2 100644 --- a/packages/manager/src/store/types.ts +++ b/packages/manager/src/store/types.ts @@ -34,73 +34,6 @@ export type ThunkDispatch = _ThunkDispatch; export type MapState = _MapStateToProps; -export interface HasStringID { - id: string; -} - -export interface HasNumericID { - id: number; -} - -export type Entity = HasNumericID | HasStringID; - -export type TypeOfID = T extends HasNumericID ? number : string; - -export type EntityMap = Record; - -export interface MappedEntityState< - T extends Entity, - E = APIError[] | undefined -> { - error?: E; - items: string[]; - itemsById: EntityMap; - lastUpdated: number; - loading: boolean; -} - -// NOTE: These 2 interfaces are as of 2/26/2020 what we intend to consolidate around -export interface MappedEntityState2 { - error: E; - itemsById: Record; - lastUpdated: number; - loading: boolean; - results: number; -} - -export type RelationalMappedEntityState = Record< - number | string, - MappedEntityState2 ->; - -export interface EntityState { - entities: T[]; - error?: E; - lastUpdated: number; - loading: boolean; - results: TypeOfID[]; -} - -export interface RequestableData { - data?: D; - error?: E; - lastUpdated: number; - loading: boolean; -} - -// Rename to RequestableData and delete above when all components are using this pattern -export interface RequestableDataWithEntityError { - data?: D; - error: EntityError; - lastUpdated: number; - loading: boolean; - results?: number; -} - -export interface RequestableRequiredData extends RequestableData { - data: D; -} - export type EventHandler = ( event: EntityEvent, dispatch: Dispatch, diff --git a/packages/manager/src/utilities/formatRegion.ts b/packages/manager/src/utilities/formatRegion.ts index f1c2317c2a9..a2f7046b93b 100644 --- a/packages/manager/src/utilities/formatRegion.ts +++ b/packages/manager/src/utilities/formatRegion.ts @@ -2,9 +2,9 @@ import { CONTINENT_CODE_TO_CONTINENT, COUNTRY_CODE_TO_CONTINENT_CODE, } from '@linode/api-v4'; -import { Region } from '@linode/api-v4'; import type { Agreements, Country, Profile } from '@linode/api-v4'; +import type { Region } from '@linode/api-v4'; interface GDPRConfiguration { /** The user's agreements */ @@ -14,7 +14,7 @@ interface GDPRConfiguration { /** The list of regions */ regions: Region[] | undefined; /** The ID of the selected region (e.g. 'eu-west') */ - selectedRegionId: string; + selectedRegionId: string | undefined; } export const getRegionCountryGroup = (region: Region | undefined) => { @@ -23,7 +23,9 @@ export const getRegionCountryGroup = (region: Region | undefined) => { } const continentCode = - COUNTRY_CODE_TO_CONTINENT_CODE[region.country.toUpperCase() as Country]; + COUNTRY_CODE_TO_CONTINENT_CODE[ + region.country.toUpperCase() as Uppercase + ]; return continentCode ? CONTINENT_CODE_TO_CONTINENT[continentCode] ?? 'Other' @@ -32,14 +34,14 @@ export const getRegionCountryGroup = (region: Region | undefined) => { export const getSelectedRegion = ( regions: Region[], - selectedRegionId: string + selectedRegionId: string | undefined ): Region | undefined => { return regions.find((thisRegion) => selectedRegionId === thisRegion.id); }; export const getSelectedRegionGroup = ( regions: Region[], - selectedRegionId: string + selectedRegionId: string | undefined ): string | undefined => { const selectedRegion = getSelectedRegion(regions, selectedRegionId); diff --git a/packages/manager/src/utilities/pricing/constants.ts b/packages/manager/src/utilities/pricing/constants.ts index 190b1b7d824..f7a667ca1fa 100644 --- a/packages/manager/src/utilities/pricing/constants.ts +++ b/packages/manager/src/utilities/pricing/constants.ts @@ -1,21 +1,10 @@ -export interface ObjStoragePriceObject { - monthly: number; - storage_overage: number; - transfer_overage: number; -} - -// These values will eventually come from the API, but for now they are hardcoded and -// used to generate the region based dynamic pricing. -export const LKE_HA_PRICE = 60; -export const OBJ_STORAGE_PRICE: ObjStoragePriceObject = { - monthly: 5.0, - storage_overage: 0.02, - transfer_overage: 0.005, -}; export const UNKNOWN_PRICE = '--.--'; export const PRICE_ERROR_TOOLTIP_TEXT = 'There was an error loading the price.'; export const PRICES_RELOAD_ERROR_NOTICE_TEXT = 'There was an error retrieving prices. Please reload and try again.'; +export const HA_UPGRADE_PRICE_ERROR_MESSAGE = + 'Upgrading to HA is not available at this time. Try again later.'; +export const HA_PRICE_ERROR_MESSAGE = `The cost for HA control plane is not available at this time.`; // Other constants export const PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE = diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts index 56c73482029..f8641ae2afd 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts @@ -1,4 +1,5 @@ import { + lkeHighAvailabilityTypeFactory, nodeBalancerTypeFactory, volumeTypeFactory, } from 'src/factories/types'; @@ -49,6 +50,7 @@ describe('getDCSpecificPricingDisplay', () => { describe('getDCSpecificPricingByType', () => { const mockNodeBalancerType = nodeBalancerTypeFactory.build(); const mockVolumeType = volumeTypeFactory.build(); + const mockLKEHighAvailabilityType = lkeHighAvailabilityTypeFactory.build(); it('calculates dynamic pricing for a region without an increase', () => { expect( @@ -57,6 +59,13 @@ describe('getDCSpecificPricingByType', () => { type: mockNodeBalancerType, }) ).toBe('10.00'); + + expect( + getDCSpecificPriceByType({ + regionId: 'us-east', + type: mockLKEHighAvailabilityType, + }) + ).toBe('60.00'); }); it('calculates dynamic pricing for a region with an increase', () => { @@ -73,6 +82,20 @@ describe('getDCSpecificPricingByType', () => { type: mockNodeBalancerType, }) ).toBe('14.00'); + + expect( + getDCSpecificPriceByType({ + regionId: 'id-cgk', + type: mockLKEHighAvailabilityType, + }) + ).toBe('72.00'); + + expect( + getDCSpecificPriceByType({ + regionId: 'br-gru', + type: mockLKEHighAvailabilityType, + }) + ).toBe('84.00'); }); it('calculates dynamic pricing for a region without an increase on an hourly interval to the specified decimal', () => { diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.ts b/packages/manager/src/utilities/pricing/dynamicPricing.ts index b190b0c5d64..b6e17da0b60 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.ts @@ -53,17 +53,6 @@ export const priceIncreaseMap = { 'id-cgk': 0.2, // Jakarta }; -export const objectStoragePriceIncreaseMap = { - 'br-gru': { - storage_overage: 0.028, - transfer_overage: 0.007, - }, - 'id-cgk': { - storage_overage: 0.024, - transfer_overage: 0.015, - }, -}; - /** * This function is used to calculate the dynamic pricing for a given entity, based on potential region increased costs. * @example diff --git a/packages/manager/src/utilities/pricing/kubernetes.test.tsx b/packages/manager/src/utilities/pricing/kubernetes.test.tsx index 42f9225c829..6a76f0329bb 100644 --- a/packages/manager/src/utilities/pricing/kubernetes.test.tsx +++ b/packages/manager/src/utilities/pricing/kubernetes.test.tsx @@ -1,6 +1,5 @@ import { linodeTypeFactory, nodePoolFactory } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; import { getKubernetesMonthlyPrice, getTotalClusterPrice } from './kubernetes'; @@ -23,6 +22,7 @@ describe('helper functions', () => { type: 'not-a-real-type', }); const region = 'us_east'; + const LKE_HA_PRICE = 60; describe('getMonthlyPrice', () => { it('should multiply node price by node count', () => { diff --git a/packages/manager/src/utilities/pricing/kubernetes.ts b/packages/manager/src/utilities/pricing/kubernetes.ts index 1532281a180..ab753c5a15d 100644 --- a/packages/manager/src/utilities/pricing/kubernetes.ts +++ b/packages/manager/src/utilities/pricing/kubernetes.ts @@ -1,6 +1,7 @@ +import { getLinodeRegionPrice } from './linodes'; + import type { KubeNodePoolResponse, Region } from '@linode/api-v4/lib'; import type { ExtendedType } from 'src/utilities/extendType'; -import { getLinodeRegionPrice } from './linodes'; interface MonthlyPriceOptions { count: number; diff --git a/packages/manager/src/utilities/sort-by.test.ts b/packages/manager/src/utilities/sort-by.test.ts new file mode 100644 index 00000000000..dd1760f419e --- /dev/null +++ b/packages/manager/src/utilities/sort-by.test.ts @@ -0,0 +1,38 @@ +import { sortByVersion } from './sort-by'; + +describe('sortByVersion', () => { + it('should identify the later major version as greater', () => { + const result = sortByVersion('2.0.0', '1.0.0', 'asc'); + expect(result).toBeGreaterThan(0); + }); + + it('should identify the later minor version as greater', () => { + const result = sortByVersion('1.2.0', '1.1.0', 'asc'); + expect(result).toBeGreaterThan(0); + }); + + it('should identify the later patch version as greater', () => { + const result = sortByVersion('1.1.2', '1.1.1', 'asc'); + expect(result).toBeGreaterThan(0); + }); + + it('should identify the later minor version with differing number of digits', () => { + const result = sortByVersion('1.30', '1.3', 'asc'); + expect(result).toBeGreaterThan(0); + }); + + it('should return negative when the first version is earlier in ascending order', () => { + const result = sortByVersion('1.0.0', '2.0.0', 'asc'); + expect(result).toBeLessThan(0); + }); + + it('should return positive when the first version is earlier in descending order', () => { + const result = sortByVersion('1.0.0', '2.0.0', 'desc'); + expect(result).toBeGreaterThan(0); + }); + + it('should return zero when versions are equal', () => { + const result = sortByVersion('1.2.3', '1.2.3', 'asc'); + expect(result).toEqual(0); + }); +}); diff --git a/packages/manager/src/utilities/sort-by.ts b/packages/manager/src/utilities/sort-by.ts index 51753c9e809..8724e5ffbd5 100644 --- a/packages/manager/src/utilities/sort-by.ts +++ b/packages/manager/src/utilities/sort-by.ts @@ -45,3 +45,56 @@ export const sortByArrayLength = (a: any[], b: any[], order: SortOrder) => { return order === 'asc' ? result : -result; }; + +/** + * Compares two semantic version strings based on the specified order. + * + * This function splits each version string into its constituent parts (major, minor, patch), + * compares them numerically, and returns a positive number, zero, or a negative number + * based on the specified sorting order. If components are missing in either version, + * they are treated as zero. + * + * @param {string} a - The first version string to compare. + * @param {string} b - The second version string to compare. + * @param {SortOrder} order - The order to sort by, can be 'asc' for ascending or 'desc' for descending. + * @returns {number} Returns a positive number if version `a` is greater than `b` according to the sort order, + * zero if they are equal, and a negative number if `b` is greater than `a`. + * + * @example + * // returns a positive number + * sortByVersion('1.2.3', '1.2.2', 'asc'); + * + * @example + * // returns zero + * sortByVersion('1.2.3', '1.2.3', 'asc'); + * + * @example + * // returns a negative number + * sortByVersion('1.2.3', '1.2.4', 'asc'); + */ + +export const sortByVersion = ( + a: string, + b: string, + order: SortOrder +): number => { + const aParts = a.split('.'); + const bParts = b.split('.'); + + const result = (() => { + for (let i = 0; i < Math.max(aParts.length, bParts.length); i += 1) { + // If one version has a part and another doesn't (e.g. 3.1 vs 3.1.1), + // treat the missing part as 0. + const aNumber = Number(aParts[i]) || 0; + const bNumber = Number(bParts[i]) || 0; + const diff = aNumber - bNumber; + + if (diff !== 0) { + return diff; + } + } + return 0; + })(); + + return order === 'asc' ? result : -result; +}; diff --git a/packages/manager/tsconfig.json b/packages/manager/tsconfig.json index c1f82661284..d0ea28fb501 100644 --- a/packages/manager/tsconfig.json +++ b/packages/manager/tsconfig.json @@ -33,9 +33,12 @@ "noImplicitThis": true, "noUnusedLocals": true, "strictNullChecks": true, - "suppressImplicitAnyIndexErrors": true, "types": ["vitest/globals", "@testing-library/jest-dom"], + /* Goodluck... */ + "ignoreDeprecations": "5.0", + "suppressImplicitAnyIndexErrors": true, + /* Completeness */ "skipLibCheck": true, diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index 614c59091d3..4b1d85d1f14 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ 'src/**/*.utils.{js,jsx,ts,tsx}', ], }, + pool: 'forks', environment: 'jsdom', globals: true, setupFiles: './src/testSetup.ts', diff --git a/yarn.lock b/yarn.lock index dcc8ddb93b5..603a8dfc998 100644 --- a/yarn.lock +++ b/yarn.lock @@ -131,6 +131,13 @@ dependencies: default-browser-id "3.0.0" +"@babel/code-frame@7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + dependencies: + "@babel/highlight" "^7.10.4" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244" @@ -139,7 +146,7 @@ "@babel/highlight" "^7.23.4" chalk "^2.4.2" -"@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": +"@babel/code-frame@^7.24.2": version "7.24.2" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== @@ -147,6 +154,14 @@ "@babel/highlight" "^7.24.2" picocolors "^1.0.0" +"@babel/code-frame@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" + integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== + dependencies: + "@babel/highlight" "^7.24.7" + picocolors "^1.0.0" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.5.tgz#ffb878728bb6bdcb6f4510aa51b1be9afb8cfd98" @@ -209,16 +224,6 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" -"@babel/generator@^7.24.1": - version "7.24.1" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.1.tgz#e67e06f68568a4ebf194d1c6014235344f0476d0" - integrity sha512-DfCRfZsBcrPEHUfuBMgbJ1Ut01Y/itOs+hY2nFLgqsqXd52/iSiVq5TITtUasIUgm+IIKdY2/1I7auiQOEeC9A== - dependencies: - "@babel/types" "^7.24.0" - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - jsesc "^2.5.1" - "@babel/generator@^7.24.4", "@babel/generator@^7.24.5": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.5.tgz#e5afc068f932f05616b66713e28d0f04e99daeb3" @@ -229,6 +234,16 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" +"@babel/generator@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.7.tgz#1654d01de20ad66b4b4d99c135471bc654c55e6d" + integrity sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA== + dependencies: + "@babel/types" "^7.24.7" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882" @@ -309,6 +324,13 @@ resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== +"@babel/helper-environment-visitor@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz#4b31ba9551d1f90781ba83491dd59cf9b269f7d9" + integrity sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-function-name@^7.22.5", "@babel/helper-function-name@^7.23.0": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" @@ -317,6 +339,14 @@ "@babel/template" "^7.22.15" "@babel/types" "^7.23.0" +"@babel/helper-function-name@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz#75f1e1725742f39ac6584ee0b16d94513da38dd2" + integrity sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA== + dependencies: + "@babel/template" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-hoist-variables@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" @@ -324,6 +354,13 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-hoist-variables@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz#b4ede1cde2fd89436397f30dc9376ee06b0f25ee" + integrity sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-member-expression-to-functions@^7.22.15", "@babel/helper-member-expression-to-functions@^7.23.0": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz#9263e88cc5e41d39ec18c9a3e0eced59a3e7d366" @@ -453,6 +490,13 @@ dependencies: "@babel/types" "^7.24.5" +"@babel/helper-split-export-declaration@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz#83949436890e07fa3d6873c61a96e3bbf692d856" + integrity sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-string-parser@^7.23.4": version "7.23.4" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83" @@ -463,6 +507,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz#f99c36d3593db9540705d0739a1f10b5e20c696e" integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ== +"@babel/helper-string-parser@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2" + integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg== + "@babel/helper-validator-identifier@^7.22.20": version "7.22.20" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" @@ -473,6 +522,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== +"@babel/helper-validator-identifier@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" + integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== + "@babel/helper-validator-option@^7.22.15", "@babel/helper-validator-option@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307" @@ -505,6 +559,16 @@ "@babel/traverse" "^7.24.5" "@babel/types" "^7.24.5" +"@babel/highlight@^7.10.4", "@babel/highlight@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" + integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.7" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + "@babel/highlight@^7.23.4": version "7.23.4" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b" @@ -534,16 +598,16 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.0.tgz#26a3d1ff49031c53a97d03b604375f028746a9ac" integrity sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg== -"@babel/parser@^7.24.1": - version "7.24.1" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.1.tgz#1e416d3627393fab1cb5b0f2f1796a100ae9133a" - integrity sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg== - "@babel/parser@^7.24.4", "@babel/parser@^7.24.5": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.5.tgz#4a4d5ab4315579e5398a82dcf636ca80c3392790" integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg== +"@babel/parser@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85" + integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw== + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.5": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.5.tgz#4c3685eb9cd790bcad2843900fe0250c91ccf895" @@ -1333,19 +1397,28 @@ "@babel/parser" "^7.24.0" "@babel/types" "^7.24.0" -"@babel/traverse@^7.18.9", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.9", "@babel/traverse@^7.24.1", "@babel/traverse@^7.24.5", "@babel/traverse@^7.7.0": - version "7.24.1" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.1.tgz#d65c36ac9dd17282175d1e4a3c49d5b7988f530c" - integrity sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ== +"@babel/template@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" + integrity sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig== dependencies: - "@babel/code-frame" "^7.24.1" - "@babel/generator" "^7.24.1" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.24.1" - "@babel/types" "^7.24.0" + "@babel/code-frame" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/types" "^7.24.7" + +"@babel/traverse@^7.18.9", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.9", "@babel/traverse@^7.24.1", "@babel/traverse@^7.24.5", "@babel/traverse@^7.7.0": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" + integrity sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.24.7" + "@babel/helper-environment-visitor" "^7.24.7" + "@babel/helper-function-name" "^7.24.7" + "@babel/helper-hoist-variables" "^7.24.7" + "@babel/helper-split-export-declaration" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/types" "^7.24.7" debug "^4.3.1" globals "^11.1.0" @@ -1376,6 +1449,15 @@ "@babel/helper-validator-identifier" "^7.24.5" to-fast-properties "^2.0.0" +"@babel/types@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.7.tgz#6027fe12bc1aa724cd32ab113fb7f1988f1f66f2" + integrity sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q== + dependencies: + "@babel/helper-string-parser" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" + to-fast-properties "^2.0.0" + "@base2/pretty-print-object@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" @@ -1910,18 +1992,38 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc" integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ== -"@eslint-community/eslint-utils@^4.2.0": +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== dependencies: eslint-visitor-keys "^3.3.0" +"@eslint-community/regexpp@^4.5.1": + version "4.10.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.1.tgz#361461e5cb3845d874e61731c11cfedd664d83a0" + integrity sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA== + "@eslint-community/regexpp@^4.6.1": version "4.10.0" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== +"@eslint/eslintrc@^0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" + integrity sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw== + dependencies: + ajv "^6.12.4" + debug "^4.1.1" + espree "^7.3.0" + globals "^13.9.0" + ignore "^4.0.6" + import-fresh "^3.2.1" + js-yaml "^3.13.1" + minimatch "^3.0.4" + strip-json-comments "^3.1.1" + "@eslint/eslintrc@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" @@ -1988,11 +2090,25 @@ debug "^4.3.1" minimatch "^3.0.5" +"@humanwhocodes/config-array@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" + integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg== + dependencies: + "@humanwhocodes/object-schema" "^1.2.0" + debug "^4.1.1" + minimatch "^3.0.4" + "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== +"@humanwhocodes/object-schema@^1.2.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== + "@humanwhocodes/object-schema@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" @@ -2099,7 +2215,7 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": +"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": version "0.3.22" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz#72a621e5de59f5f1ef792d0793a82ee20f645e4c" integrity sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw== @@ -2107,7 +2223,7 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": +"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== @@ -3559,20 +3675,6 @@ lz-string "^1.5.0" pretty-format "^27.0.2" -"@testing-library/dom@^9.0.0": - version "9.3.4" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" - integrity sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/runtime" "^7.12.5" - "@types/aria-query" "^5.0.1" - aria-query "5.1.3" - chalk "^4.1.0" - dom-accessibility-api "^0.5.9" - lz-string "^1.5.0" - pretty-format "^27.0.2" - "@testing-library/jest-dom@~6.4.2": version "6.4.2" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz#38949f6b63722900e2d75ba3c6d9bf8cffb3300e" @@ -3587,14 +3689,12 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@~14.2.1": - version "14.2.1" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.2.1.tgz#bf69aa3f71c36133349976a4a2da3687561d8310" - integrity sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A== +"@testing-library/react@~16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.0.0.tgz#0a1e0c7a3de25841c3591b8cb7fb0cf0c0a27321" + integrity sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ== dependencies: "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^9.0.0" - "@types/react-dom" "^18.0.0" "@testing-library/user-event@^14.5.2": version "14.5.2" @@ -3913,11 +4013,6 @@ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== -"@types/istanbul-lib-coverage@^2.0.1": - version "2.0.6" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" - integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== - "@types/jsdom@^21.1.4": version "21.1.6" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.6.tgz#bcbc7b245787ea863f3da1ef19aa1dcfb9271a1b" @@ -3927,7 +4022,7 @@ "@types/tough-cookie" "*" parse5 "^7.0.0" -"@types/json-schema@^7.0.3", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.9": +"@types/json-schema@^7.0.12", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -4134,7 +4229,7 @@ dependencies: "@types/react" "*" -"@types/react-dom@*", "@types/react-dom@^18.0.0", "@types/react-dom@^18.2.18": +"@types/react-dom@*", "@types/react-dom@^18.2.18": version "18.2.19" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.19.tgz#b84b7c30c635a6c26c6a6dfbb599b2da9788be58" integrity sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA== @@ -4238,6 +4333,11 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== +"@types/semver@^7.5.0": + version "7.5.8" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" + integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== + "@types/send@*": version "0.17.4" resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" @@ -4334,31 +4434,22 @@ resolved "https://registry.yarnpkg.com/@types/zxcvbn/-/zxcvbn-4.4.4.tgz#987f5fcd87e957097433c476c3a1c91a54f53131" integrity sha512-Tuk4q7q0DnpzyJDI4aMeghGuFu2iS1QAdKpabn8JfbtfGmVDUgvZv1I7mEjP61Bvnp3ljKCC8BE6YYSTNxmvRQ== -"@typescript-eslint/eslint-plugin@^4.1.1": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz#c24dc7c8069c7706bc40d99f6fa87edcb2005276" - integrity sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg== +"@typescript-eslint/eslint-plugin@^6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz#30830c1ca81fd5f3c2714e524c4303e0194f9cd3" + integrity sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA== dependencies: - "@typescript-eslint/experimental-utils" "4.33.0" - "@typescript-eslint/scope-manager" "4.33.0" - debug "^4.3.1" - functional-red-black-tree "^1.0.1" - ignore "^5.1.8" - regexpp "^3.1.0" - semver "^7.3.5" - tsutils "^3.21.0" - -"@typescript-eslint/experimental-utils@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.33.0.tgz#6f2a786a4209fa2222989e9380b5331b2810f7fd" - integrity sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q== - dependencies: - "@types/json-schema" "^7.0.7" - "@typescript-eslint/scope-manager" "4.33.0" - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/typescript-estree" "4.33.0" - eslint-scope "^5.1.1" - eslint-utils "^3.0.0" + "@eslint-community/regexpp" "^4.5.1" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/type-utils" "6.21.0" + "@typescript-eslint/utils" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + graphemer "^1.4.0" + ignore "^5.2.4" + natural-compare "^1.4.0" + semver "^7.5.4" + ts-api-utils "^1.0.1" "@typescript-eslint/experimental-utils@^3.10.1": version "3.10.1" @@ -4371,23 +4462,16 @@ eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^4.1.1": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.33.0.tgz#dfe797570d9694e560528d18eecad86c8c744899" - integrity sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA== +"@typescript-eslint/parser@^6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b" + integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== dependencies: - "@typescript-eslint/scope-manager" "4.33.0" - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/typescript-estree" "4.33.0" - debug "^4.3.1" - -"@typescript-eslint/scope-manager@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.33.0.tgz#d38e49280d983e8772e29121cf8c6e9221f280a3" - integrity sha512-5IfJHpgTsTZuONKbODctL4kKuQje/bzBRkwHE8UOZ4f89Zeddg+EGZs8PD8NcN4LdM3ygHWYB3ukPAYjvl/qbQ== - dependencies: - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/visitor-keys" "4.33.0" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/typescript-estree" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" "@typescript-eslint/scope-manager@5.62.0": version "5.62.0" @@ -4397,21 +4481,39 @@ "@typescript-eslint/types" "5.62.0" "@typescript-eslint/visitor-keys" "5.62.0" +"@typescript-eslint/scope-manager@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1" + integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg== + dependencies: + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + +"@typescript-eslint/type-utils@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz#6473281cfed4dacabe8004e8521cee0bd9d4c01e" + integrity sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag== + dependencies: + "@typescript-eslint/typescript-estree" "6.21.0" + "@typescript-eslint/utils" "6.21.0" + debug "^4.3.4" + ts-api-utils "^1.0.1" + "@typescript-eslint/types@3.10.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== -"@typescript-eslint/types@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.33.0.tgz#a1e59036a3b53ae8430ceebf2a919dc7f9af6d72" - integrity sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ== - "@typescript-eslint/types@5.62.0", "@typescript-eslint/types@^5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== +"@typescript-eslint/types@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" + integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== + "@typescript-eslint/typescript-estree@3.10.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz#fd0061cc38add4fad45136d654408569f365b853" @@ -4426,19 +4528,6 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/typescript-estree@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.33.0.tgz#0dfb51c2908f68c5c08d82aefeaf166a17c24609" - integrity sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA== - dependencies: - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/visitor-keys" "4.33.0" - debug "^4.3.1" - globby "^11.0.3" - is-glob "^4.0.1" - semver "^7.3.5" - tsutils "^3.21.0" - "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -4452,6 +4541,33 @@ semver "^7.3.7" tsutils "^3.21.0" +"@typescript-eslint/typescript-estree@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46" + integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ== + dependencies: + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "9.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/utils@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.21.0.tgz#4714e7a6b39e773c1c8e97ec587f520840cd8134" + integrity sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@types/json-schema" "^7.0.12" + "@types/semver" "^7.5.0" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/typescript-estree" "6.21.0" + semver "^7.5.4" + "@typescript-eslint/utils@^5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -4473,14 +4589,6 @@ dependencies: eslint-visitor-keys "^1.1.0" -"@typescript-eslint/visitor-keys@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz#2a22f77a41604289b7a186586e9ec48ca92ef1dd" - integrity sha512-uqi/2aSz9g2ftcHWf8uLPJA70rUv6yuMW5Bohw+bwcuzaxQIHaKFZCKGoGXIrc9vkTJ3+0txM73K0Hq3d5wgIg== - dependencies: - "@typescript-eslint/types" "4.33.0" - eslint-visitor-keys "^2.0.0" - "@typescript-eslint/visitor-keys@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" @@ -4489,6 +4597,14 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" +"@typescript-eslint/visitor-keys@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47" + integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A== + dependencies: + "@typescript-eslint/types" "6.21.0" + eslint-visitor-keys "^3.4.1" + "@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" @@ -4501,24 +4617,24 @@ dependencies: "@swc/core" "^1.3.107" -"@vitest/coverage-v8@^1.0.4": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-1.2.2.tgz#681f4f76de896d0d2484cca32285477e288fec3a" - integrity sha512-IHyKnDz18SFclIEEAHb9Y4Uxx0sPKC2VO1kdDCs1BF6Ip4S8rQprs971zIsooLUn7Afs71GRxWMWpkCGZpRMhw== +"@vitest/coverage-v8@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz#2f54ccf4c2d9f23a71294aba7f95b3d2e27d14e7" + integrity sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew== dependencies: "@ampproject/remapping" "^2.2.1" "@bcoe/v8-coverage" "^0.2.3" debug "^4.3.4" istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.0.1" - istanbul-lib-source-maps "^4.0.1" + istanbul-lib-source-maps "^5.0.4" istanbul-reports "^3.1.6" magic-string "^0.30.5" magicast "^0.3.3" picocolors "^1.0.0" std-env "^3.5.0" + strip-literal "^2.0.0" test-exclude "^6.0.0" - v8-to-istanbul "^9.2.0" "@vitest/expect@1.6.0": version "1.6.0" @@ -4554,12 +4670,12 @@ dependencies: tinyspy "^2.2.0" -"@vitest/ui@^1.0.4": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-1.2.2.tgz#62dddb1ec12bdc5c186e7f2425490bb8b5080695" - integrity sha512-CG+5fa8lyoBr+9i+UZGS31Qw81v33QlD10uecHxN2CLJVN+jLnqx4pGzGvFFeJ7jSnUCT0AlbmVWY6fU6NJZmw== +"@vitest/ui@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-1.6.0.tgz#ffcc97ebcceca7fec840c29ab68632d0cd01db93" + integrity sha512-k3Lyo+ONLOgylctiGovRKy7V4+dIN2yxstX3eY5cWFXH6WP+ooVX79YSyi0GagdTQzLmT43BF27T0s6dOIPBXA== dependencies: - "@vitest/utils" "1.2.2" + "@vitest/utils" "1.6.0" fast-glob "^3.3.2" fflate "^0.8.1" flatted "^3.2.9" @@ -4567,16 +4683,6 @@ picocolors "^1.0.0" sirv "^2.0.4" -"@vitest/utils@1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.2.2.tgz#94b5a1bd8745ac28cf220a99a8719efea1bcfc83" - integrity sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g== - dependencies: - diff-sequences "^29.6.3" - estree-walker "^3.0.3" - loupe "^2.3.7" - pretty-format "^29.7.0" - "@vitest/utils@1.6.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.6.0.tgz#5c5675ca7d6f546a7b4337de9ae882e6c57896a1" @@ -4648,7 +4754,7 @@ acorn-walk@^8.1.1, acorn-walk@^8.3.2: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== -acorn@^7.1.1, acorn@^7.4.1: +acorn@^7.1.1, acorn@^7.4.0, acorn@^7.4.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== @@ -4698,6 +4804,16 @@ ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.0.1: + version "8.16.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.16.0.tgz#22e2a92b94f005f7e0f9c9d39652ef0b8f6f0cb4" + integrity sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw== + dependencies: + fast-deep-equal "^3.1.3" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.4.1" + algoliasearch@^4.14.3: version "4.22.1" resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.22.1.tgz#f10fbecdc7654639ec20d62f109c1b3a46bc6afc" @@ -4845,13 +4961,6 @@ aria-hidden@^1.1.1: dependencies: tslib "^2.0.0" -aria-query@5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" - integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== - dependencies: - deep-equal "^2.0.5" - aria-query@5.3.0, aria-query@^5.0.0, aria-query@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" @@ -6291,30 +6400,6 @@ deep-eql@^4.1.3: dependencies: type-detect "^4.0.0" -deep-equal@^2.0.5: - version "2.2.3" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1" - integrity sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA== - dependencies: - array-buffer-byte-length "^1.0.0" - call-bind "^1.0.5" - es-get-iterator "^1.1.3" - get-intrinsic "^1.2.2" - is-arguments "^1.1.1" - is-array-buffer "^3.0.2" - is-date-object "^1.0.5" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.2" - isarray "^2.0.5" - object-is "^1.1.5" - object-keys "^1.1.1" - object.assign "^4.1.4" - regexp.prototype.flags "^1.5.1" - side-channel "^1.0.4" - which-boxed-primitive "^1.0.2" - which-collection "^1.0.1" - which-typed-array "^1.1.13" - deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -6601,7 +6686,7 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -enquirer@^2.3.6: +enquirer@^2.3.5, enquirer@^2.3.6: version "2.4.1" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.4.1.tgz#93334b3fbd74fc7097b224ab4a8fb7e40bf4ae56" integrity sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== @@ -6681,21 +6766,6 @@ es-errors@^1.0.0, es-errors@^1.1.0, es-errors@^1.2.1, es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -es-get-iterator@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" - integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.3" - has-symbols "^1.0.3" - is-arguments "^1.1.1" - is-map "^2.0.2" - is-set "^2.0.2" - is-string "^1.0.7" - isarray "^2.0.5" - stop-iteration-iterator "^1.0.0" - es-iterator-helpers@^1.0.12, es-iterator-helpers@^1.0.15: version "1.0.15" resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz#bd81d275ac766431d19305923707c3efd9f1ae40" @@ -7021,21 +7091,14 @@ eslint-utils@^1.4.3: dependencies: eslint-visitor-keys "^1.1.0" -eslint-utils@^2.0.0: +eslint-utils@^2.0.0, eslint-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== dependencies: eslint-visitor-keys "^1.1.0" -eslint-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" - integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== - dependencies: - eslint-visitor-keys "^2.0.0" - -eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0: +eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== @@ -7137,6 +7200,52 @@ eslint@^6.8.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +eslint@^7.1.0: + version "7.32.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" + integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== + dependencies: + "@babel/code-frame" "7.12.11" + "@eslint/eslintrc" "^0.4.3" + "@humanwhocodes/config-array" "^0.5.0" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.0.1" + doctrine "^3.0.0" + enquirer "^2.3.5" + escape-string-regexp "^4.0.0" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^2.0.0" + espree "^7.3.1" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.1.2" + globals "^13.6.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^3.13.1" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.0.4" + natural-compare "^1.4.0" + optionator "^0.9.1" + progress "^2.0.0" + regexpp "^3.1.0" + semver "^7.2.1" + strip-ansi "^6.0.0" + strip-json-comments "^3.1.0" + table "^6.0.9" + text-table "^0.2.0" + v8-compile-cache "^2.0.3" + espree@^6.1.2: version "6.2.1" resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" @@ -7146,6 +7255,15 @@ espree@^6.1.2: acorn-jsx "^5.2.0" eslint-visitor-keys "^1.1.0" +espree@^7.3.0, espree@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" + integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== + dependencies: + acorn "^7.4.0" + acorn-jsx "^5.3.1" + eslint-visitor-keys "^1.3.0" + espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" @@ -7160,7 +7278,7 @@ esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.0.1, esquery@^1.4.2: +esquery@^1.0.1, esquery@^1.4.0, esquery@^1.4.2: version "1.5.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== @@ -7952,7 +8070,7 @@ globals@^12.1.0: dependencies: type-fest "^0.8.1" -globals@^13.19.0, globals@^13.20.0: +globals@^13.19.0, globals@^13.20.0, globals@^13.6.0, globals@^13.9.0: version "13.24.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== @@ -8297,7 +8415,7 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.1.1, ignore@^5.1.8, ignore@^5.2.0, ignore@^5.2.4: +ignore@^5.1.1, ignore@^5.2.0, ignore@^5.2.4: version "5.3.1" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== @@ -8380,7 +8498,7 @@ inquirer@^7.0.0: strip-ansi "^6.0.0" through "^2.3.6" -internal-slot@^1.0.4, internal-slot@^1.0.5: +internal-slot@^1.0.5: version "1.0.7" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== @@ -8426,7 +8544,7 @@ is-absolute-url@^4.0.0: resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-4.0.1.tgz#16e4d487d4fded05cfe0685e53ec86804a5e94dc" integrity sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A== -is-arguments@^1.0.4, is-arguments@^1.1.1: +is-arguments@^1.0.4: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== @@ -8593,7 +8711,7 @@ is-interactive@^1.0.0: resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== -is-map@^2.0.1, is-map@^2.0.2: +is-map@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" integrity sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg== @@ -8673,7 +8791,7 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-set@^2.0.1, is-set@^2.0.2: +is-set@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.2.tgz#90755fa4c2562dc1c5d4024760d6119b94ca18ec" integrity sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g== @@ -8815,14 +8933,14 @@ istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== +istanbul-lib-source-maps@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz#1947003c72a91b6310efeb92d2a91be8804d92c2" + integrity sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw== dependencies: + "@jridgewell/trace-mapping" "^0.3.23" debug "^4.1.1" istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" istanbul-reports@^3.1.6: version "3.1.6" @@ -9343,6 +9461,11 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== +lodash.truncate@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" + integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== + lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -9415,13 +9538,6 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - lru-cache@^7.5.1: version "7.18.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" @@ -10044,6 +10160,13 @@ minimatch@3.1.2, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch dependencies: brace-expansion "^1.1.7" +minimatch@9.0.3, minimatch@^9.0.1, minimatch@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + minimatch@^5.0.1: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" @@ -10051,13 +10174,6 @@ minimatch@^5.0.1: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.1, minimatch@^9.0.3: - version "9.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== - dependencies: - brace-expansion "^2.0.1" - minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -10484,6 +10600,18 @@ optionator@^0.8.3: type-check "~0.3.2" word-wrap "~1.2.3" +optionator@^0.9.1: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + optionator@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" @@ -12035,12 +12163,10 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3: - version "7.6.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" - integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== - dependencies: - lru-cache "^6.0.0" +"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: + version "7.6.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" + integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== send@0.18.0: version "0.18.0" @@ -12386,13 +12512,6 @@ std-env@^3.5.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== -stop-iteration-iterator@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" - integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ== - dependencies: - internal-slot "^1.0.4" - store2@^2.14.2: version "2.14.2" resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068" @@ -12612,7 +12731,7 @@ strip-indent@^4.0.0: dependencies: min-indent "^1.0.1" -strip-json-comments@^3.0.1, strip-json-comments@^3.1.1: +strip-json-comments@^3.0.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -12717,6 +12836,17 @@ table@^5.2.3: slice-ansi "^2.1.0" string-width "^3.0.0" +table@^6.0.9: + version "6.8.2" + resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58" + integrity sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA== + dependencies: + ajv "^8.0.1" + lodash.truncate "^4.4.2" + slice-ansi "^4.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + tar-fs@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" @@ -12958,6 +13088,11 @@ trough@^2.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== +ts-api-utils@^1.0.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" + integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== + ts-dedent@^2.0.0, ts-dedent@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" @@ -13170,15 +13305,15 @@ typescript-fsa@^3.0.0: resolved "https://registry.yarnpkg.com/typescript-fsa/-/typescript-fsa-3.0.0.tgz#3ad1cb915a67338e013fc21f67c9b3e0e110c912" integrity sha512-xiXAib35i0QHl/+wMobzPibjAH5TJLDj+qGq5jwVLG9qR4FUswZURBw2qihBm0m06tHoyb3FzpnJs1GRhRwVag== -typescript@^4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.4.5: + version "5.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" + integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: - version "0.7.37" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832" - integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA== + version "0.7.38" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.38.tgz#f497d8a4dc1fec6e854e5caa4b2f9913422ef054" + integrity sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA== uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" @@ -13335,7 +13470,7 @@ update-check@1.5.4: registry-auth-token "3.3.2" registry-url "3.1.0" -uri-js@^4.2.2: +uri-js@^4.2.2, uri-js@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== @@ -13423,15 +13558,6 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz#cdada8bec61e15865f05d097c5f4fd30e94dc128" integrity sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw== -v8-to-istanbul@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" - integrity sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA== - dependencies: - "@jridgewell/trace-mapping" "^0.3.12" - "@types/istanbul-lib-coverage" "^2.0.1" - convert-source-map "^2.0.0" - validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -13527,7 +13653,7 @@ vite@^5.0.0, vite@^5.1.7: optionalDependencies: fsevents "~2.3.3" -vitest@^1.3.1: +vitest@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.6.0.tgz#9d5ad4752a3c451be919e412c597126cffb9892f" integrity sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA== @@ -13733,7 +13859,7 @@ widest-line@^4.0.1: dependencies: string-width "^5.0.1" -word-wrap@^1.2.4, word-wrap@~1.2.3: +word-wrap@^1.2.4, word-wrap@^1.2.5, word-wrap@~1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== @@ -13863,9 +13989,9 @@ yallist@^4.0.0: integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== yaml@2.3.1, yaml@^1.10.0, yaml@^1.7.2, yaml@^2.2.2, yaml@^2.3.0, yaml@^2.3.4: - version "2.4.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed" - integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg== + version "2.4.5" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.5.tgz#60630b206dd6d84df97003d33fc1ddf6296cca5e" + integrity sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg== yargs-parser@^11.1.1, yargs-parser@^21.1.1: version "21.1.1"