diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ab595b6619..9be0e917dca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,21 @@ jobs: - run: yarn --frozen-lockfile - run: yarn workspace linode-manager run test + test-search: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "18.14" + - uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn --frozen-lockfile + - run: yarn workspace @linode/search run test + typecheck-manager: runs-on: ubuntu-latest needs: diff --git a/.gitignore b/.gitignore index 9344cdda1b3..933adffc825 100644 --- a/.gitignore +++ b/.gitignore @@ -141,7 +141,4 @@ packages/manager/bundle_analyzer_report.html **/manager/src/dev-tools/*.local.* # vitepress -docs/.vitepress/cache - -# vitest-preview -.vitest-preview \ No newline at end of file +docs/.vitepress/cache \ No newline at end of file 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..129cbb844e8 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), - - install it via `brew`: https://cli.github.com/manual/installation or upgrade with `brew upgrade gh` + - 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://github.com/cli/cli#installation or upgrade with `brew upgrade gh` - Once installed, run `gh repo set-default` and pick `linode/manager` (only > 2.21.0) - You can also just create the changeset manually, in this case make sure to use the proper formatting for it. - Run `yarn changeset`from the root, choose the package to create a changeset for, and provide a description for the change. 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/05-fetching-data.md b/docs/development-guide/05-fetching-data.md index f5780d41405..e1313ed83c4 100644 --- a/docs/development-guide/05-fetching-data.md +++ b/docs/development-guide/05-fetching-data.md @@ -244,6 +244,104 @@ console.log(errorMap); } ``` +#### Scrolling to errors + +For deep forms, we provide a utility that will scroll to the first error encountered within a defined container. We do this to improve error visibility, because the user can be unaware of an error that isn't in the viewport. +An error can be a notice (API error) or a Formik field error. In order to implement this often needed functionality, we must declare a form or form container via ref, then pass it to the `scrollErrorIntoViewV2` util (works both for class & functional components). + +Note: the legacy `scrollErrorIntoView` is deprecated in favor of `scrollErrorIntoViewV2`. + +Since Cloud Manager uses different ways of handling forms and validation, the `scrollErrorIntoViewV2` util should be implemented using the following patterns to ensure consistency. + +##### Formik +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const { + values, + // other handlers + } = useFormik({ + initialValues: {}, + onSubmit: mySubmitFormHandler, + validate: () => { + scrollErrorIntoViewV2(formRef); + }, + validationSchema: myValidationSchema, + }); + + return ( +
+ + {/* form fields */} + + + ); +}; +``` + +##### React Hook Forms +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const methods = useForm({ + defaultValues, + mode: 'onBlur', + resolver: myResolvers, + // other methods + }); + + return ( + +
scrollErrorIntoViewV2(formRef))} + ref={formContainerRef} + > + + {/* form fields */} + + + + ); +}; +``` + +##### Uncontrolled forms +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const handleSubmit = () => { + try { + // form submission logic + } catch { + scrollErrorIntoViewV2(formContainerRef); + } + }; + + return ( +
+ + {/* form fields */} + + + ); +}; +``` + ### Toast / Event Message Punctuation **Best practice:** - If a message is a sentence or a sentence fragment with a subject and a verb, add punctuation. Otherwise, leave punctuation off. diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index d0794f08194..febe5b53519 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -43,29 +43,6 @@ yarn workspace linode-manager run test:debug Test execution will stop at the debugger statement, and you will be able to use Chrome's normal debugger to step through the tests (open `chrome://inspect/#devices` in Chrome). -### Visual debugging - -Using `vite-preview`, you can view a preview of the tested component in the browser. - -First, add the following lines to your test: - -``` -import { debug } from 'vitest-preview'; - -// Inside your tests -describe('my test', () => { - render(); - debug(); // šŸ‘ˆ Add this line -} -``` - -Start the `vitest-preview` server: -``` -yarn vitest-preview -``` - -Finally, run the test to view the component in the browser. - ### React Testing Library This library provides a set of tools to render React components from within the Vitest environment. The library's philosophy is that components should be tested as closely as possible to how they are used. @@ -209,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/13-coding-standards.md b/docs/development-guide/13-coding-standards.md index 76928a24558..159281d7989 100644 --- a/docs/development-guide/13-coding-standards.md +++ b/docs/development-guide/13-coding-standards.md @@ -11,7 +11,7 @@ We use [ESLint](https://eslint.org/) to enforce coding and formatting standards. - **prettier** (code formatting) - **scanjs** (security) -If you are using VSCode it is highly recommended to use the ESlint extension. The Prettier extension is also recommended, as it can be configured to format your code on save. +If you are using VSCode it is **highly** recommended to use the [ESlint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). The [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) is also recommended, as it can be configured to format your code on save. ## React diff --git a/docs/development-guide/15-api-events.md b/docs/development-guide/15-api-events.md new file mode 100644 index 00000000000..7a7f9f3cc0f --- /dev/null +++ b/docs/development-guide/15-api-events.md @@ -0,0 +1,108 @@ +# API Events + +In order to display Events, Cloud Manager polls the [account/events](https://www.linode.com/docs/api/account/#events-list) endpoint at a 16 second interval, or every 2 seconds if there are ā€œin-progressā€ events. + +In order to display these messages in the application (Notification Center, /events page), we compose messages according to the Event key (`EventAction`). Each key requires an entry and set of custom messages for each status (`EventStatus`), dictated by API specs. Not every Status is required for a given Action. + +## 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 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'; + +import type { PartialEventMap } from '../types'; + +export const linode: PartialEventMap<'linode'> = { + linode_addip: { + notification: (e) => ( + <> + An IP address has been added to Linode{' '} + . + + ), + }, +}; +``` + +The convention to compose the message is as follows: +- Use the `` component for linking `entity` or `secondary_entity`. This component includes a lookup util to format the link `href` according to the feature. +- The bolding should only be applied to: + - 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 + +We can leverage the Event Message factories in order to display events in snackbars/toasts when a given action gets triggered via APIv4. + +```Typescript +const { enqueueSnackbar } = useSnackbar(); + +try { + const successMessage = getEventMessage({ + action: 'image_upload', + entity: { + label: 'Entity', + url: '/image/123', + }, + status: 'notification', + }); + + const showToast = (variant: any) => + enqueueSnackbar(successMessage, { + 'success', + }); +}, catch { + const failureMessage = getEventMessage({ + action: 'image_upload', + // in this case we don't add an entity since we know we can't link to it + status: 'failed', + }); + + const showToast = (variant: any) => + enqueueSnackbar(failureMessage, { + 'error', + }); +} +``` + +Both `action` and `status` are required. The `entity` and `secondary_entity` can optionally be passed to allow for linking. **Note**: it is possible the Event Message linking will be missing if the action status message expects either value but isn't not provided by the instance call. + +If a corresponding status does not exist (ex: "failed"), it's encouraged to add it to the Action. Event if not triggered by the API, it can be useful to have a reusable Event Message to use through the App. diff --git a/package.json b/package.json index 1ee6cef250a..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": { @@ -33,7 +33,6 @@ "start:manager:ci": "yarn workspace linode-manager start:ci", "clean": "rm -rf node_modules && rm -rf packages/@linode/api-v4/node_modules && rm -rf packages/manager/node_modules && rm -rf packages/@linode/validation/node_modules", "test": "yarn workspace linode-manager test", - "vitest-preview": "yarn workspace linode-manager vitest-preview", "package-versions": "node ./scripts/package-versions/index.js", "storybook": "yarn workspace linode-manager storybook", "cy:run": "yarn workspace linode-manager cy:run", @@ -46,6 +45,7 @@ "coverage": "yarn workspace linode-manager coverage", "coverage:summary": "yarn workspace linode-manager coverage:summary", "junit:summary": "ts-node scripts/junit-summary/index.ts", + "generate-tod": "ts-node scripts/tod-payload/index.ts", "docs": "bunx vitepress@1.0.0-rc.44 dev docs" }, "resolutions": { diff --git a/packages/api-v4/.changeset/pr-10443-added-1715882636050.md b/packages/api-v4/.changeset/pr-10443-added-1715882636050.md deleted file mode 100644 index 4ae253140ec..00000000000 --- a/packages/api-v4/.changeset/pr-10443-added-1715882636050.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -New LKE events in `EventAction` type ([#10443](https://github.com/linode/manager/pull/10443)) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 4060db60e8f..9af57c6599f 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,5 +1,50 @@ -## [2024-05-13] - v0.117.0 +## [2024-07-08] - v0.121.0 + +### Changed: + +- Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]` ([#10617](https://github.com/linode/manager/pull/10617)) + +### Upcoming Features: + +- Added types needed for DashboardSelect component ([#10589](https://github.com/linode/manager/pull/10589)) + +## [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: + +- `tags` field in `Image` type ([#10466](https://github.com/linode/manager/pull/10466)) +- New endpoint for `object-storage/types` ([#10468](https://github.com/linode/manager/pull/10468)) +- `members` to `DatabaseInstance` and `Database` types ([#10503](https://github.com/linode/manager/pull/10503)) +- New event `tax_id_invalid` for account tax id ([#10512](https://github.com/linode/manager/pull/10512)) + +### Changed: + +- Update return type of `updateDatabase` to be `Database` ([#10503](https://github.com/linode/manager/pull/10503)) +- Add lke_cluster_id to Linode interface ([#10537](https://github.com/linode/manager/pull/10537)) + +### Upcoming Features: + +- Update images endpoints to reflect the image service API spec ([#10541](https://github.com/linode/manager/pull/10541)) + +## [2024-05-28] - v0.118.0 + +### Added: +- New LKE events in `EventAction` type ([#10443](https://github.com/linode/manager/pull/10443)) + +### Changed: + +- 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: @@ -15,17 +60,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 96d71f158c9..792784321bb 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.117.0", + "version": "0.121.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.0.1" + "vitest": "^1.6.0" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 7f2aecd62b1..02e3cbf3ec8 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -63,6 +63,8 @@ export type AccountCapability = | 'Akamai Cloud Load Balancer' | 'Block Storage' | 'Cloud Firewall' + | 'CloudPulse' + | 'Disk Encryption' | 'Kubernetes' | 'Linodes' | 'LKE HA Control Planes' @@ -70,6 +72,7 @@ export type AccountCapability = | 'Managed Databases' | 'NodeBalancers' | 'Object Storage Access Key Regions' + | 'Object Storage Endpoint Types' | 'Object Storage' | 'Placement Group' | 'Support Ticket Severity' @@ -277,139 +280,189 @@ export interface Entity { url: string; } -export type EventAction = - | 'account_settings_update' - | 'account_update' - | 'backups_cancel' - | 'backups_enable' - | 'backups_restore' - | 'community_like' - | 'community_mention' - | 'community_question_reply' - | 'credit_card_updated' - | 'database_low_disk_space' - | 'database_resize' - | 'database_resize_create' - | 'database_backup_restore' - | 'database_create' - | 'database_credentials_reset' - | 'database_delete' - | 'database_update_failed' - | 'database_update' - | 'disk_create' - | 'disk_delete' - | 'disk_duplicate' - | 'disk_imagize' - | 'disk_resize' - | 'disk_update' - | 'domain_create' - | 'domain_delete' - | 'domain_record_create' - | 'domain_record_delete' - | 'domain_record_updated' - | 'domain_update' - | 'entity_transfer_accept' - | 'entity_transfer_cancel' - | 'entity_transfer_create' - | 'entity_transfer_fail' - | 'entity_transfer_stale' - | 'firewall_create' - | 'firewall_delete' - | 'firewall_device_add' - | 'firewall_device_remove' - | 'firewall_disable' - | 'firewall_enable' - | 'firewall_update' - | 'host_reboot' - | 'image_delete' - | 'image_update' - | 'image_upload' - | 'lassie_reboot' - | 'linode_addip' - | 'linode_boot' - | 'linode_clone' - | 'linode_config_create' - | 'linode_config_delete' - | 'linode_config_update' - | 'linode_create' - | 'linode_delete' - | 'linode_deleteip' - | 'linode_migrate_datacenter_create' - | 'linode_migrate_datacenter' - | 'linode_migrate' - | 'linode_mutate_create' - | 'linode_mutate' - | 'linode_reboot' - | 'linode_rebuild' - | 'linode_resize_create' - | 'linode_resize_warm_create' - | 'linode_resize' - | 'linode_shutdown' - | 'linode_snapshot' - | 'linode_update' - | 'lke_node_create' - | 'lke_node_recycle' - | 'lke_cluster_create' - | 'lke_cluster_update' - | 'lke_cluster_delete' - | 'lke_cluster_regenerate' - | 'lke_cluster_recycle' - | 'lke_control_plane_acl_create' - | 'lke_control_plane_acl_update' - | 'lke_control_plane_acl_delete' - | 'lke_kubeconfig_regenerate' - | 'lke_token_rotate' - | 'lke_pool_create' - | 'lke_pool_delete' - | 'lke_pool_recycle' - | 'longviewclient_create' - | 'longviewclient_delete' - | 'longviewclient_update' - | 'nodebalancer_config_create' - | 'nodebalancer_config_delete' - | 'nodebalancer_config_update' - | 'nodebalancer_create' - | 'nodebalancer_delete' - | 'nodebalancer_update' - | 'password_reset' - | 'placement_group_assign' - | 'placement_group_became_non_compliant' - | 'placement_group_became_compliant' - | 'placement_group_create' - | 'placement_group_unassign' - | 'placement_group_update' - | 'placement_group_delete' - | 'profile_update' - | 'stackscript_create' - | 'stackscript_delete' - | 'stackscript_publicize' - | 'stackscript_revise' - | 'stackscript_update' - | 'subnet_create' - | 'subnet_delete' - | 'subnet_update' - | 'tfa_disabled' - | 'tfa_enabled' - | 'ticket_attachment_upload' - | 'ticket_update' - | 'token_create' - | 'token_delete' - | 'token_update' - | 'user_ssh_key_add' - | 'user_ssh_key_delete' - | 'user_ssh_key_update' - | 'volume_attach' - | 'volume_clone' - | 'volume_create' - | 'volume_delete' - | 'volume_detach' - | 'volume_migrate_scheduled' - | 'volume_migrate' - | 'volume_resize' - | 'volume_update' - | 'vpc_create' - | 'vpc_delete' - | 'vpc_update'; +export const EventActionKeys = [ + 'account_agreement_eu_model', + 'account_promo_apply', + 'account_settings_update', + 'account_update', + 'backups_cancel', + 'backups_enable', + 'backups_restore', + 'community_like', + 'community_mention', + 'community_question_reply', + 'credit_card_updated', + 'database_backup_create', + 'database_backup_delete', + 'database_backup_restore', + 'database_create', + 'database_credentials_reset', + 'database_degraded', + 'database_delete', + 'database_failed', + 'database_low_disk_space', + 'database_resize_create', + 'database_resize', + 'database_scale', + 'database_update_failed', + 'database_update', + 'database_upgrade', + 'disk_create', + 'disk_delete', + 'disk_duplicate', + 'disk_imagize', + 'disk_resize', + 'disk_update', + 'dns_record_create', + 'dns_record_delete', + 'dns_zone_create', + 'dns_zone_delete', + 'domain_create', + 'domain_delete', + 'domain_import', + 'domain_record_create', + 'domain_record_delete', + 'domain_record_update', + 'domain_record_updated', + 'domain_update', + 'entity_transfer_accept_recipient', + 'entity_transfer_accept', + 'entity_transfer_cancel', + 'entity_transfer_create', + 'entity_transfer_fail', + 'entity_transfer_stale', + 'firewall_apply', + 'firewall_create', + 'firewall_delete', + 'firewall_device_add', + 'firewall_device_remove', + 'firewall_disable', + 'firewall_enable', + 'firewall_rules_update', + 'firewall_update', + 'host_reboot', + 'image_delete', + 'image_update', + 'image_upload', + 'ipaddress_update', + 'ipv6pool_add', + 'ipv6pool_delete', + 'lassie_reboot', + 'linode_addip', + 'linode_boot', + 'linode_clone', + 'linode_config_create', + 'linode_config_delete', + 'linode_config_update', + 'linode_create', + 'linode_delete', + 'linode_deleteip', + 'linode_migrate_datacenter_create', + 'linode_migrate_datacenter', + 'linode_migrate', + 'linode_mutate_create', + 'linode_mutate', + 'linode_reboot', + 'linode_rebuild', + 'linode_resize_create', + 'linode_resize_warm_create', + 'linode_resize', + 'linode_shutdown', + 'linode_snapshot', + 'linode_update', + 'lish_boot', + 'lke_cluster_create', + 'lke_cluster_delete', + 'lke_cluster_recycle', + 'lke_cluster_regenerate', + 'lke_cluster_update', + 'lke_control_plane_acl_create', + 'lke_control_plane_acl_delete', + 'lke_control_plane_acl_update', + 'lke_kubeconfig_regenerate', + 'lke_node_create', + 'lke_node_recycle', + 'lke_pool_create', + 'lke_pool_delete', + 'lke_pool_recycle', + 'lke_token_rotate', + 'longviewclient_create', + 'longviewclient_delete', + 'longviewclient_update', + 'managed_enabled', + 'managed_service_create', + 'managed_service_delete', + 'nodebalancer_config_create', + 'nodebalancer_config_delete', + 'nodebalancer_config_update', + 'nodebalancer_create', + 'nodebalancer_delete', + 'nodebalancer_node_create', + 'nodebalancer_node_delete', + 'nodebalancer_node_update', + 'nodebalancer_update', + 'oauth_client_create', + 'oauth_client_delete', + 'oauth_client_secret_reset', + 'oauth_client_update', + 'obj_access_key_create', + 'obj_access_key_delete', + 'obj_access_key_update', + 'password_reset', + 'payment_method_add', + 'payment_submitted', + 'placement_group_assign', + 'placement_group_became_compliant', + 'placement_group_became_non_compliant', + 'placement_group_create', + 'placement_group_delete', + 'placement_group_unassign', + 'placement_group_update', + 'profile_update', + 'reserved_ip_assign', + 'reserved_ip_create', + 'reserved_ip_delete', + 'reserved_ip_unassign', + 'stackscript_create', + 'stackscript_delete', + 'stackscript_publicize', + 'stackscript_revise', + 'stackscript_update', + 'subnet_create', + 'subnet_delete', + 'subnet_update', + 'tag_create', + 'tag_delete', + 'tax_id_invalid', + 'tfa_disabled', + 'tfa_enabled', + 'ticket_attachment_upload', + 'ticket_create', + 'ticket_update', + 'token_create', + 'token_delete', + 'token_update', + 'user_create', + 'user_delete', + 'user_ssh_key_add', + 'user_ssh_key_delete', + 'user_ssh_key_update', + 'user_update', + 'volume_attach', + 'volume_clone', + 'volume_create', + 'volume_delete', + 'volume_detach', + 'volume_migrate_scheduled', + 'volume_migrate', + 'volume_resize', + 'volume_update', + 'vpc_create', + 'vpc_delete', + 'vpc_update', +] as const; + +export type EventAction = typeof EventActionKeys[number]; export type EventStatus = | 'scheduled' diff --git a/packages/api-v4/src/cloudpulse/dashboards.ts b/packages/api-v4/src/cloudpulse/dashboards.ts new file mode 100644 index 00000000000..46755363f44 --- /dev/null +++ b/packages/api-v4/src/cloudpulse/dashboards.ts @@ -0,0 +1,11 @@ +import { ResourcePage } from 'src/types'; +import Request, { setMethod, setURL } from '../request'; +import { Dashboard } from './types'; +import { API_ROOT } from 'src/constants'; + +//Returns the list of all the dashboards available +export const getDashboards = () => + Request>( + setURL(`${API_ROOT}/monitor/services/linode/dashboards`), + setMethod('GET') + ); diff --git a/packages/api-v4/src/cloudpulse/index.ts b/packages/api-v4/src/cloudpulse/index.ts new file mode 100644 index 00000000000..25a3879e494 --- /dev/null +++ b/packages/api-v4/src/cloudpulse/index.ts @@ -0,0 +1,3 @@ +export * from './types'; + +export * from './dashboards'; diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts new file mode 100644 index 00000000000..6f917202959 --- /dev/null +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -0,0 +1,45 @@ +export interface Dashboard { + id: number; + label: string; + widgets: Widgets[]; + created: string; + updated: string; + time_duration: TimeDuration; + service_type: string; +} + +export interface TimeGranularity { + unit: string; + value: number; +} + +export interface TimeDuration { + unit: string; + value: number; +} + +export interface Widgets { + label: string; + metric: string; + aggregate_function: string; + group_by: string; + region_id: number; + namespace_id: number; + color: string; + size: number; + chart_type: string; + y_label: string; + filters: Filters[]; + serviceType: string; + service_type: string; + resource_id: string[]; + time_granularity: TimeGranularity; + time_duration: TimeDuration; + unit: string; +} + +export interface Filters { + key: string; + operator: string; + value: string; +} diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index 255d05138c7..32f6c12b1fc 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -22,7 +22,6 @@ import { Engine, SSLFields, UpdateDatabasePayload, - UpdateDatabaseResponse, } from './types'; /** @@ -153,7 +152,7 @@ export const updateDatabase = ( databaseID: number, data: UpdateDatabasePayload ) => - Request( + Request( setURL( `${API_ROOT}/databases/${encodeURIComponent( engine diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 1e079d38c8d..71c526be374 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -29,14 +29,14 @@ export interface DatabaseEngine { export type DatabaseStatus = | 'provisioning' + | 'resizing' | 'active' | 'suspending' | 'suspended' | 'resuming' | 'restoring' | 'failed' - | 'degraded' - | 'resizing'; + | 'degraded'; export type DatabaseBackupType = 'snapshot' | 'auto'; @@ -61,6 +61,8 @@ export interface SSLFields { ca_certificate: string; } +type MemberType = 'primary' | 'failover'; + // DatabaseInstance is the interface for the shape of data returned by the /databases/instances endpoint. export interface DatabaseInstance { id: number; @@ -75,6 +77,10 @@ export interface DatabaseInstance { created: string; instance_uri: string; hosts: DatabaseHosts; + /** + * A key/value object where the key is an IP address and the value is a member type. + */ + members: Record; } export type ClusterSize = 1 | 3; @@ -139,6 +145,10 @@ export interface BaseDatabase { * It may not be defined. */ used_disk_size_gb?: number; + /** + * A key/value object where the key is an IP address and the value is a member type. + */ + members: Record; } export interface MySQLDatabase extends BaseDatabase { @@ -182,8 +192,3 @@ export interface UpdateDatabasePayload { updates?: UpdatesSchedule; type?: string; } - -export interface UpdateDatabaseResponse { - label: string; - allow_list: string[]; -} diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index f6acfd3418a..110f19158ed 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -1,5 +1,6 @@ import { createImageSchema, + updateImageRegionsSchema, updateImageSchema, uploadImageSchema, } from '@linode/validation/lib/images.schema'; @@ -11,8 +12,15 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { CreateImagePayload, Image, ImageUploadPayload, UploadImageResponse } from './types'; +import type { Filter, Params, ResourcePage as Page } from '../types'; +import type { + CreateImagePayload, + Image, + ImageUploadPayload, + UpdateImagePayload, + UpdateImageRegionsPayload, + UploadImageResponse, +} from './types'; /** * Get information about a single Image. @@ -52,19 +60,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 -) => { - const data = { - ...(label && { label }), - ...(description && { description }), - }; - +export const updateImage = (imageId: string, data: UpdateImagePayload) => { return Request( setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}`), setMethod('PUT'), @@ -100,3 +98,19 @@ export const uploadImage = (data: ImageUploadPayload) => { setData(data, uploadImageSchema) ); }; + +/** + * updateImageRegions + * + * Selects the regions to which this image will be replicated. + */ +export const updateImageRegions = ( + imageId: string, + data: UpdateImageRegionsPayload +) => { + return Request( + setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}/regions`), + setMethod('POST'), + setData(data, updateImageRegionsSchema) + ); +}; diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index ed7e97c0080..cd3b34db673 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,24 +4,114 @@ export type ImageStatus = | 'deleted' | 'pending_upload'; -type ImageCapabilities = 'cloud-init'; +export type ImageCapabilities = 'cloud-init' | 'distributed-images'; + +type ImageType = 'manual' | 'automatic'; + +export type ImageRegionStatus = + | 'creating' + | 'pending' + | 'available' + | 'pending deletion' + | 'pending replication' + | 'replicating' + | 'timedout'; + +export interface ImageRegion { + region: string; + status: ImageRegionStatus; +} export interface Image { + /** + * An optional timestamp of this image's planned end-of-life. + */ eol: string | null; + + /** + * The unique ID of the this image. + */ id: string; + + /** + * A short description of this image. + */ label: string; + + /** + * A detailed description of this image. + */ description: string | null; + + /** + * The timestamp of when this image was created. + */ created: string; + + /** + * The timestamp of when this image was last updated. + */ updated: string; - type: string; + + /** + * Indicates the method of this image's creation. + */ + type: ImageType; + + /** + * Whether this image is marked for public distribution. + */ is_public: boolean; + + /** + * The minimum size in MB needed to deploy this image. + */ size: number; + + /** + * The total storage consumed by this image across its regions. + */ + total_size: number; + + /** + * The name of the user who created this image or 'linode' for public images. + */ created_by: null | string; + + /** + * The distribution author. + */ vendor: string | null; + + /** + * Whether this is a public image that is deprecated. + */ deprecated: boolean; + + /** + * A timestamp of when this image will expire if it was automatically captured. + */ expiry: null | string; + + /** + * The current status of this image. + */ status: ImageStatus; + + /** + * A list of the capabilities of this image. + */ capabilities: ImageCapabilities[]; + + /** + * A list of the regions in which this image is available. + */ + regions: ImageRegion[]; + + /** + * A list of tags added to this image. + */ + tags: string[]; } export interface UploadImageResponse { @@ -58,7 +148,16 @@ export interface CreateImagePayload extends BaseImagePayload { disk_id: number; } +export type UpdateImagePayload = Omit; + export interface ImageUploadPayload extends BaseImagePayload { label: string; region: string; } + +export interface UpdateImageRegionsPayload { + /** + * An array of region ids + */ + regions: string[]; +} diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index b62ab50d817..ae104c76a8b 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -2,6 +2,8 @@ export * from './account'; export * from './aclb'; +export * from './cloudpulse'; + export * from './databases'; export * from './domains'; @@ -22,6 +24,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/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index c8d25118e35..8e2d176572c 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -1,4 +1,4 @@ -import type { EncryptionStatus } from 'src/linodes'; +import type { EncryptionStatus } from '../linodes'; export interface KubernetesCluster { created: string; diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 05f52e39d96..c3e1f6be296 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -27,6 +27,7 @@ export interface Linode { ipv4: string[]; ipv6: string | null; label: string; + lke_cluster_id: number | null; placement_group?: PlacementGroupPayload; // If not in a placement group, this will be excluded from the response. type: string | null; status: LinodeStatus; 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/object-storage/index.ts b/packages/api-v4/src/object-storage/index.ts index f4a9bdf8d18..e2985222d3f 100644 --- a/packages/api-v4/src/object-storage/index.ts +++ b/packages/api-v4/src/object-storage/index.ts @@ -9,3 +9,5 @@ export * from './objects'; export * from './objectStorageKeys'; export * from './types'; + +export * from './prices'; diff --git a/packages/api-v4/src/object-storage/prices.ts b/packages/api-v4/src/object-storage/prices.ts new file mode 100644 index 00000000000..2907a6a110c --- /dev/null +++ b/packages/api-v4/src/object-storage/prices.ts @@ -0,0 +1,16 @@ +import { Params, PriceType, ResourcePage } from 'src/types'; +import { API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL } from '../request'; + +/** + * getObjectStorageTypes + * + * Return a paginated list of available Object Storage types; used for pricing. + * This endpoint does not require authentication. + */ +export const getObjectStorageTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/object-storage/types`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 01a7bd31e64..ab102674177 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -5,6 +5,8 @@ export type Capabilities = | 'Block Storage' | 'Block Storage Migrations' | 'Cloud Firewall' + | 'Disk Encryption' + | 'Distributed Plans' | 'GPU Linodes' | 'Kubernetes' | 'Linodes' @@ -24,7 +26,7 @@ export interface DNSResolvers { export type RegionStatus = 'ok' | 'outage'; -export type RegionSite = 'core' | 'edge'; +export type RegionSite = 'core' | 'distributed' | 'edge'; export interface Region { id: string; @@ -46,6 +48,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-10392-tech-stories-1715207755092.md b/packages/manager/.changeset/pr-10392-tech-stories-1715207755092.md deleted file mode 100644 index d5765fe5f99..00000000000 --- a/packages/manager/.changeset/pr-10392-tech-stories-1715207755092.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Add analytics form tracking for Linode Create flow (v1) ([#10392](https://github.com/linode/manager/pull/10392)) diff --git a/packages/manager/.changeset/pr-10443-added-1715882541919.md b/packages/manager/.changeset/pr-10443-added-1715882541919.md deleted file mode 100644 index b61255dad42..00000000000 --- a/packages/manager/.changeset/pr-10443-added-1715882541919.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Event message handling for new LKE event types ([#10443](https://github.com/linode/manager/pull/10443)) diff --git a/packages/manager/.changeset/pr-10446-tests-1715197487403.md b/packages/manager/.changeset/pr-10446-tests-1715197487403.md deleted file mode 100644 index c587975864e..00000000000 --- a/packages/manager/.changeset/pr-10446-tests-1715197487403.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Placement Group populated landing page UI tests ([#10446](https://github.com/linode/manager/pull/10446)) diff --git a/packages/manager/.changeset/pr-10449-tests-1715196877025.md b/packages/manager/.changeset/pr-10449-tests-1715196877025.md deleted file mode 100644 index 0ee2d478858..00000000000 --- a/packages/manager/.changeset/pr-10449-tests-1715196877025.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Placement Group Linode assignment UI tests ([#10449](https://github.com/linode/manager/pull/10449)) diff --git a/packages/manager/.changeset/pr-10455-upcoming-features-1715611895814.md b/packages/manager/.changeset/pr-10455-upcoming-features-1715611895814.md deleted file mode 100644 index ae5404dcd5b..00000000000 --- a/packages/manager/.changeset/pr-10455-upcoming-features-1715611895814.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -PlacementGroups Select optimizations & cleanup ([#10455](https://github.com/linode/manager/pull/10455)) diff --git a/packages/manager/.changeset/pr-10460-tech-stories-1715630809115.md b/packages/manager/.changeset/pr-10460-tech-stories-1715630809115.md deleted file mode 100644 index f95e5abb78a..00000000000 --- a/packages/manager/.changeset/pr-10460-tech-stories-1715630809115.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Clean up NodeBalancer Firewall feature flag ([#10460](https://github.com/linode/manager/pull/10460)) diff --git a/packages/manager/.changeset/pr-10463-tech-stories-1715699566824.md b/packages/manager/.changeset/pr-10463-tech-stories-1715699566824.md deleted file mode 100644 index 8a3d578749e..00000000000 --- a/packages/manager/.changeset/pr-10463-tech-stories-1715699566824.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Update Storybook to 8.1.0 ([#10463](https://github.com/linode/manager/pull/10463)) diff --git a/packages/manager/.changeset/pr-10464-tech-stories-1715711350251.md b/packages/manager/.changeset/pr-10464-tech-stories-1715711350251.md deleted file mode 100644 index 3ee90fd3f51..00000000000 --- a/packages/manager/.changeset/pr-10464-tech-stories-1715711350251.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Upgrade country-region-data to 3.0.0 ([#10464](https://github.com/linode/manager/pull/10464)) diff --git a/packages/manager/.changeset/pr-10465-tests-1715702208954.md b/packages/manager/.changeset/pr-10465-tests-1715702208954.md deleted file mode 100644 index 30c1318e0c7..00000000000 --- a/packages/manager/.changeset/pr-10465-tests-1715702208954.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up support ticket test intercepts ([#10465](https://github.com/linode/manager/pull/10465)) diff --git a/packages/manager/.changeset/pr-10467-tests-1715779598575.md b/packages/manager/.changeset/pr-10467-tests-1715779598575.md deleted file mode 100644 index 05b57b404a9..00000000000 --- a/packages/manager/.changeset/pr-10467-tests-1715779598575.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up cy.intercept calls in nodebalancer ([#10467](https://github.com/linode/manager/pull/10467)) diff --git a/packages/manager/.changeset/pr-10470-tests-1715719518601.md b/packages/manager/.changeset/pr-10470-tests-1715719518601.md deleted file mode 100644 index 6f1b819910e..00000000000 --- a/packages/manager/.changeset/pr-10470-tests-1715719518601.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix failing StackScript test following deprecation of Fedora 38 Image ([#10470](https://github.com/linode/manager/pull/10470)) diff --git a/packages/manager/.changeset/pr-10471-added-1715780084724.md b/packages/manager/.changeset/pr-10471-added-1715780084724.md deleted file mode 100644 index d6583cf25b2..00000000000 --- a/packages/manager/.changeset/pr-10471-added-1715780084724.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Tags to Image create capture tab ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/manager/.changeset/pr-10471-tests-1715780339835.md b/packages/manager/.changeset/pr-10471-tests-1715780339835.md deleted file mode 100644 index 3a47837eb67..00000000000 --- a/packages/manager/.changeset/pr-10471-tests-1715780339835.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up and improves image creation Cypress tests ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/manager/.changeset/pr-10472-tests-1715794017061.md b/packages/manager/.changeset/pr-10472-tests-1715794017061.md deleted file mode 100644 index fb19a708b31..00000000000 --- a/packages/manager/.changeset/pr-10472-tests-1715794017061.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up cy.intercept calls in notification and events ([#10472](https://github.com/linode/manager/pull/10472)) diff --git a/packages/manager/.changeset/pr-10474-added-1715961690900.md b/packages/manager/.changeset/pr-10474-added-1715961690900.md deleted file mode 100644 index 6ae952321ab..00000000000 --- a/packages/manager/.changeset/pr-10474-added-1715961690900.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Add options for default policies when creating a Firewall ([#10474](https://github.com/linode/manager/pull/10474)) diff --git a/packages/manager/.changeset/pr-10476-tests-1715869194947.md b/packages/manager/.changeset/pr-10476-tests-1715869194947.md deleted file mode 100644 index e11c1b69692..00000000000 --- a/packages/manager/.changeset/pr-10476-tests-1715869194947.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up cy.intercept calls in resize-linode ([#10476](https://github.com/linode/manager/pull/10476)) diff --git a/packages/manager/.changeset/pr-10478-tests-1715876232371.md b/packages/manager/.changeset/pr-10478-tests-1715876232371.md deleted file mode 100644 index cd904644c91..00000000000 --- a/packages/manager/.changeset/pr-10478-tests-1715876232371.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up cy.intercept calls in smoke-delete-linode ([#10478](https://github.com/linode/manager/pull/10478)) diff --git a/packages/manager/.changeset/pr-10483-tech-stories-1715958313413.md b/packages/manager/.changeset/pr-10483-tech-stories-1715958313413.md deleted file mode 100644 index 5b072a0bf02..00000000000 --- a/packages/manager/.changeset/pr-10483-tech-stories-1715958313413.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Retire recharts feature flag ([#10483](https://github.com/linode/manager/pull/10483)) diff --git a/packages/manager/.changeset/pr-10490-fixed-1716227619276.md b/packages/manager/.changeset/pr-10490-fixed-1716227619276.md deleted file mode 100644 index 4e2fdf04d94..00000000000 --- a/packages/manager/.changeset/pr-10490-fixed-1716227619276.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Duplicate speedtest helper text in Create Cluster form ([#10490](https://github.com/linode/manager/pull/10490)) diff --git a/packages/manager/.changeset/pr-10575-tests-1720464616116.md b/packages/manager/.changeset/pr-10575-tests-1720464616116.md new file mode 100644 index 00000000000..774da01e27e --- /dev/null +++ b/packages/manager/.changeset/pr-10575-tests-1720464616116.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test for Login History page ([#10575](https://github.com/linode/manager/pull/10575)) diff --git a/packages/manager/.changeset/pr-10627-added-1719905623432.md b/packages/manager/.changeset/pr-10627-added-1719905623432.md new file mode 100644 index 00000000000..ded64e17a37 --- /dev/null +++ b/packages/manager/.changeset/pr-10627-added-1719905623432.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Disabled Create Volume Button on the Landing Page for Restricted Users ([#10627](https://github.com/linode/manager/pull/10627)) diff --git a/packages/manager/.changeset/pr-10630-changed-1719904456730.md b/packages/manager/.changeset/pr-10630-changed-1719904456730.md new file mode 100644 index 00000000000..5e04441c1e4 --- /dev/null +++ b/packages/manager/.changeset/pr-10630-changed-1719904456730.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Disabled Create Volume button on empty state landing page for restricted users ([#10630](https://github.com/linode/manager/pull/10630)) diff --git a/packages/manager/.changeset/pr-10632-changed-1719914806428.md b/packages/manager/.changeset/pr-10632-changed-1719914806428.md new file mode 100644 index 00000000000..855b27f535a --- /dev/null +++ b/packages/manager/.changeset/pr-10632-changed-1719914806428.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use `getRestrictedResourceText` utility and move restrictions Notice to top of Volume Create ([#10632](https://github.com/linode/manager/pull/10632)) diff --git a/packages/manager/.changeset/pr-10641-added-1720011097638.md b/packages/manager/.changeset/pr-10641-added-1720011097638.md new file mode 100644 index 00000000000..b1399389c83 --- /dev/null +++ b/packages/manager/.changeset/pr-10641-added-1720011097638.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Disable Volume Action Menu buttons for restricted users ([#10641](https://github.com/linode/manager/pull/10641)) diff --git a/packages/manager/.changeset/pr-10646-fixed-1720111189359.md b/packages/manager/.changeset/pr-10646-fixed-1720111189359.md new file mode 100644 index 00000000000..a02eeed2abe --- /dev/null +++ b/packages/manager/.changeset/pr-10646-fixed-1720111189359.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Incorrect error notice in Volume drawers for restricted users ([#10646](https://github.com/linode/manager/pull/10646)) diff --git a/packages/manager/.changeset/pr-10647-upcoming-features-1720210373584.md b/packages/manager/.changeset/pr-10647-upcoming-features-1720210373584.md new file mode 100644 index 00000000000..edfa6edd243 --- /dev/null +++ b/packages/manager/.changeset/pr-10647-upcoming-features-1720210373584.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add feature flag and capability for OBJ Gen2 ([#10647](https://github.com/linode/manager/pull/10647)) diff --git a/packages/manager/.changeset/pr-10649-upcoming-features-1720453076417.md b/packages/manager/.changeset/pr-10649-upcoming-features-1720453076417.md new file mode 100644 index 00000000000..3a047c5df55 --- /dev/null +++ b/packages/manager/.changeset/pr-10649-upcoming-features-1720453076417.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Analytics Events to Linode Create v2 ([#10649](https://github.com/linode/manager/pull/10649)) diff --git a/packages/manager/.changeset/pr-10654-tech-stories-1720468649871.md b/packages/manager/.changeset/pr-10654-tech-stories-1720468649871.md new file mode 100644 index 00000000000..0df861ae755 --- /dev/null +++ b/packages/manager/.changeset/pr-10654-tech-stories-1720468649871.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Improve API flexibility for useToastNotification ([#10654](https://github.com/linode/manager/pull/10654)) diff --git a/packages/manager/.changeset/pr-10657-fixed-1720475119486.md b/packages/manager/.changeset/pr-10657-fixed-1720475119486.md new file mode 100644 index 00000000000..56b96bcb2ea --- /dev/null +++ b/packages/manager/.changeset/pr-10657-fixed-1720475119486.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Github CLI install link in Contributing guide ([#10657](https://github.com/linode/manager/pull/10657)) diff --git a/packages/manager/.changeset/pr-10660-fixed-1720504089793.md b/packages/manager/.changeset/pr-10660-fixed-1720504089793.md new file mode 100644 index 00000000000..52e0fdddff4 --- /dev/null +++ b/packages/manager/.changeset/pr-10660-fixed-1720504089793.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +LKE details page 'Delete Pool' button misalignment ([#10660](https://github.com/linode/manager/pull/10660)) diff --git a/packages/manager/.changeset/pr-10661-removed-1720547888843.md b/packages/manager/.changeset/pr-10661-removed-1720547888843.md new file mode 100644 index 00000000000..9d8fdf57c21 --- /dev/null +++ b/packages/manager/.changeset/pr-10661-removed-1720547888843.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Removed +--- + +Gravatar analytics events ([#10661](https://github.com/linode/manager/pull/10661)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 3c64f9b2c0e..d28ee97f89f 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -115,6 +115,7 @@ module.exports = { rules: { '@linode/cloud-manager/no-custom-fontWeight': 'error', '@typescript-eslint/camelcase': 'off', + "@typescript-eslint/consistent-type-imports": "warn", '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/interface-name-prefix': 'off', diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 7356fbbda3a..1edbc296bda 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,8 +4,255 @@ 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-05-13] - v1.119.0 +## [2024-07-08] - v1.123.0 + +### Added: + +- Design Tokens (CDS 2.0) ([#10022](https://github.com/linode/manager/pull/10022)) +- Design update dismissible banner ([#10640](https://github.com/linode/manager/pull/10640)) + +### Changed: + +- Rebuild Linode drawer ([#10594](https://github.com/linode/manager/pull/10594)) +- Auto-populate Image label based on Linode and Disk names ([#10604](https://github.com/linode/manager/pull/10604)) +- Update Linode disk action menu ([#10614](https://github.com/linode/manager/pull/10614)) + +### Fixed: + +- Potential runtime issue with conditional hook ([#10584](https://github.com/linode/manager/pull/10584)) +- Visual bug inside Node Pools table ([#10599](https://github.com/linode/manager/pull/10599)) +- Linode Resize dialog UX when linode data is loading or there is an error ([#10618](https://github.com/linode/manager/pull/10618)) + +### Removed: + +- Region helper text on the Image Upload page ([#10642](https://github.com/linode/manager/pull/10642)) +### Tech Stories: + +- Refactor `SupportTicketDialog` with React Hook Form ([#10557](https://github.com/linode/manager/pull/10557)) +- Query Key Factory for ACLB ([#10598](https://github.com/linode/manager/pull/10598)) +- Make `Factory.each` start incrementing at 1 instead of 0 ([#10619](https://github.com/linode/manager/pull/10619)) + +### Tests: + +- Cypress integration test for SSH key update and delete ([#10542](https://github.com/linode/manager/pull/10542)) +- Refactor Cypress Longview test to use mock API data/events ([#10579](https://github.com/linode/manager/pull/10579)) +- Add assertions for created LKE cluster in Cypress LKE tests ([#10593](https://github.com/linode/manager/pull/10593)) +- Update Object Storage tests to mock account capabilities as needed for Multicluster ([#10602](https://github.com/linode/manager/pull/10602)) +- Fix OBJ test failure caused by visiting hardcoded and out-of-date URL ([#10609](https://github.com/linode/manager/pull/10609)) +- Combine VPC details page subnet create, edit, and delete Cypress tests ([#10612](https://github.com/linode/manager/pull/10612)) +- De-parameterize Cypress Domain Record Create tests ([#10615](https://github.com/linode/manager/pull/10615)) +- De-parameterize Cypress Deep Link smoke tests ([#10622](https://github.com/linode/manager/pull/10622)) +- Improve security of Linodes created during tests ([#10633](https://github.com/linode/manager/pull/10633)) + +### Upcoming Features: + +- Gecko GA Region Select ([#10479](https://github.com/linode/manager/pull/10479)) +- Add Dashboard Selection component inside the Global Filters of CloudPulse view ([#10589](https://github.com/linode/manager/pull/10589)) +- Conditionally disable regions based on the selected image on Linode Create ([#10607](https://github.com/linode/manager/pull/10607)) +- Prevent Linode Create v2 from toggling mid-creation ([#10611](https://github.com/linode/manager/pull/10611)) +- Add new search query parser to Linode Create v2 StackScripts tab ([#10613](https://github.com/linode/manager/pull/10613)) +- Add ā€˜Manage Image Regionsā€™ Drawer ([#10617](https://github.com/linode/manager/pull/10617)) +- Add Marketplace Cluster pricing support to Linode Create v2 ([#10623](https://github.com/linode/manager/pull/10623)) +- Add debouncing to the Linode Create v2 `VLANSelect` ([#10628](https://github.com/linode/manager/pull/10628)) +- Add Validation to Linode Create v2 Marketplace Tab ([#10629](https://github.com/linode/manager/pull/10629)) +- Add Image distributed compatibility notice to Linode Create ([#10636](https://github.com/linode/manager/pull/10636)) + +## [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: + +- Object Storage showing incorrect object URLs ([#10603](https://github.com/linode/manager/pull/10603)) + +## [2024-06-11] - v1.121.1 + +### Fixed: + +- Core Plan table display ([#10567](https://github.com/linode/manager/pull/10567)) + +## [2024-06-10] - v1.121.0 + +### Added: + +- Tags to Edit Image drawer ([#10466](https://github.com/linode/manager/pull/10466)) +- Tags to image upload tab ([#10484](https://github.com/linode/manager/pull/10484)) +- Apache Kafka Cluster and Couchbase Cluster Marketplace Apps ([#10500](https://github.com/linode/manager/pull/10500)) +- Improvements to Clone flow to encourage powering down before cloning ([#10508](https://github.com/linode/manager/pull/10508)) +- Alphabetical account sorting and search capabilities to Switch Account drawer ([#10515](https://github.com/linode/manager/pull/10515)) + +### Changed: + +- Use dynamic pricing with `object-storage/types` endpoint ([#10468](https://github.com/linode/manager/pull/10468)) +- Modify limited availability banner display logic ([#10536](https://github.com/linode/manager/pull/10536)) +- Add `regions` and `total_size` fields to `imageFactory` ([#10541](https://github.com/linode/manager/pull/10541)) + +### Fixed: + +- Unsurfaced interface error in Linode Config dialog ([#10429](https://github.com/linode/manager/pull/10429)) +- Firewall landing device request with -1 ID ([#10509](https://github.com/linode/manager/pull/10509)) +- Leading whitespace in list of Firewall Services ([#10527](https://github.com/linode/manager/pull/10527)) +- Misalignment of Cluster Summary section at some screen sizes ([#10531](https://github.com/linode/manager/pull/10531)) +- Stale assigned Firewall data displaying on Linode and NodeBalancer details pages ([#10534](https://github.com/linode/manager/pull/10534)) + +### Tech Stories: + +- Replace Select with Autocomplete in: volumes ([#10437](https://github.com/linode/manager/pull/10437)) +- Query Key Factory for Support Tickets ([#10496](https://github.com/linode/manager/pull/10496)) +- Query Key Factory for Databases ([#10503](https://github.com/linode/manager/pull/10503)) +- Remove `recompose` - Part 1 ([#10516](https://github.com/linode/manager/pull/10516)) +- Clean up loading components ([#10524](https://github.com/linode/manager/pull/10524)) +- New `consistent-type-imports` es-lint warning ([#10540](https://github.com/linode/manager/pull/10540)) +- Query Key Factory for Security Questions and Preferences ([#10543](https://github.com/linode/manager/pull/10543)) +- Upgrade Cypress from v13.5.0 to v13.11.0 ([#10548](https://github.com/linode/manager/pull/10548)) +- Rename Edge regions to Distributed regions ([#10452](https://github.com/linode/manager/pull/10452)) + +### Tests: + +- Improve unit test suite stability ([#10278](https://github.com/linode/manager/pull/10278)) +- Added test automation for database resize feature. ([#10461](https://github.com/linode/manager/pull/10461)) +- Add Linode Create v2 end-to-end tests ([#10469](https://github.com/linode/manager/pull/10469)) +- Add Cypress test coverage for Linode Create v2 flow ([#10469](https://github.com/linode/manager/pull/10469)) +- Remove console logs from e2e tests ([#10506](https://github.com/linode/manager/pull/10506)) +- Add Linode details page assertion for LISH via SSH Info ([#10513](https://github.com/linode/manager/pull/10513)) +- Add unit tests for CreateImageFromDiskDialog and EnableBackupsDialog and LDE-related E2E assertions for Create Image flow ([#10521](https://github.com/linode/manager/pull/10521)) +- Fix `EditRouteDrawer.test.tsx` unit test flake ([#10526](https://github.com/linode/manager/pull/10526)) +- Cypress integration tests for PG update label flow ([#10529](https://github.com/linode/manager/pull/10529)) +- Add Cypress integration test for email bounce banners ([#10532](https://github.com/linode/manager/pull/10532)) +- Improve test Linode security ([#10538](https://github.com/linode/manager/pull/10538)) + +### Upcoming Features: + +- New tax id validation for non-US countries ([#10512](https://github.com/linode/manager/pull/10512)) +- Add CloudPulse feature flag and landing page([#10393](https://github.com/linode/manager/pull/10393)) +- Add Dashboard Global Filters and Dashboards Tab to the CloudPulse component ([#10397](https://github.com/linode/manager/pull/10397)) +- Add Encrypted/Not Encrypted status to LKE Node Pool table ([#10480](https://github.com/linode/manager/pull/10480)) +- 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) +- 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)) +- Tags to Image Create capture tab ([#10471](https://github.com/linode/manager/pull/10471)) +- Options for default policies when creating a Firewall ([#10474](https://github.com/linode/manager/pull/10474)) + +### Changed: + +- Make all tooltips interactive and prevent `disableInteractive` for future usage ([#10501](https://github.com/linode/manager/pull/10501)) + +### Fixed: + +- Duplicate speedtest helper text in Create Cluster form ([#10490](https://github.com/linode/manager/pull/10490)) +- `RegionSelect` unexpected keyboard behavior ([#10495](https://github.com/linode/manager/pull/10495)) + +### Removed: + +- `parentChildAccountAccess` feature flag ([#10489](https://github.com/linode/manager/pull/10489)) +- `firewallNodebalancer` feature flag ([#10460](https://github.com/linode/manager/pull/10460)) +- `recharts` feature flag ([#10483](https://github.com/linode/manager/pull/10483)) + +### Tech Stories: + +- Add script to generate internal test results payload ([#10422](https://github.com/linode/manager/pull/10422)) +- Update Storybook to 8.1.0 ([#10463](https://github.com/linode/manager/pull/10463)) +- Upgrade country-region-data to 3.0.0 ([#10464](https://github.com/linode/manager/pull/10464)) +- Remove aria-label from TableRow ([#10485](https://github.com/linode/manager/pull/10485)) + +### Tests: + +- Add Placement Group populated landing page UI tests ([#10446](https://github.com/linode/manager/pull/10446)) +- Add Placement Group Linode assignment UI tests ([#10449](https://github.com/linode/manager/pull/10449)) +- Add Cypress test coverage for Disk Encryption in Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) +- Clean up support ticket test intercepts ([#10465](https://github.com/linode/manager/pull/10465)) +- Clean up cy.intercept calls in nodebalancer test ([#10467](https://github.com/linode/manager/pull/10467)) +- Fix failing StackScript test following deprecation of Fedora 38 Image ([#10470](https://github.com/linode/manager/pull/10470)) +- Clean up and improves image creation Cypress tests ([#10471](https://github.com/linode/manager/pull/10471)) +- Clean up cy.intercept calls in notification and events ([#10472](https://github.com/linode/manager/pull/10472)) +- Add integration test for Linode Create with Placement Group ([#10473](https://github.com/linode/manager/pull/10473)) +- Clean up cy.intercept calls in resize-linode test ([#10476](https://github.com/linode/manager/pull/10476)) +- Clean up cy.intercept calls in smoke-delete-linode test ([#10478](https://github.com/linode/manager/pull/10478)) +- Add cypress assertion and test for placement group deletion error handling ([#10493](https://github.com/linode/manager/pull/10493)) + +### Upcoming Features: + +- Linode Create Refactor - Scroll Errors Into View ([#10454](https://github.com/linode/manager/pull/10454)) +- Optimize and clean up PlacementGroups Select ([#10455](https://github.com/linode/manager/pull/10455)) +- 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: @@ -43,10 +290,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)) @@ -59,17 +306,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)) @@ -109,7 +353,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)) @@ -121,10 +365,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)) @@ -170,12 +414,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 @@ -236,7 +480,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)) @@ -293,7 +536,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/Dockerfile b/packages/manager/Dockerfile index cd3e70e8bc1..da157f3e810 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -19,7 +19,7 @@ CMD yarn start:manager:ci # # Builds an image containing Cypress and miscellaneous system utilities required # by the tests. -FROM cypress/included:13.5.0 as e2e-build +FROM cypress/included:13.11.0 as e2e-build RUN apt-get update \ && apt-get install -y expect openssh-client \ && rm -rf /var/cache/apt/* \ 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/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index 3c114a06a7d..40a4b5761ef 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -27,11 +27,6 @@ import { } from 'support/util/random'; import type { CancelAccount } from '@linode/api-v4'; import { mockWebpageUrl } from 'support/intercepts/general'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; describe('Account cancellation', () => { /* @@ -325,12 +320,6 @@ describe('Parent/Child account cancellation', () => { const cancellationComments = randomPhrase(); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetAccount(mockAccount).as('getAccount'); mockGetProfile(mockProfile).as('getProfile'); mockCancelAccountError(cancellationPaymentErrorMessage, 409).as( diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index 797e97edccf..d8abac82d79 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -6,12 +6,11 @@ import { profileFactory } from 'src/factories'; import { accountLoginFactory } from 'src/factories/accountLogin'; import { formatDate } from 'src/utilities/formatDate'; import { mockGetAccountLogins } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + loginHelperText, + loginEmptyStateMessageText, +} from 'support/constants/account'; import { PARENT_USER } from 'src/features/Account/constants'; describe('Account login history', () => { @@ -42,20 +41,12 @@ describe('Account login history', () => { 'getAccountLogins' ); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table is visible. - cy.findByText( - 'Logins across all users on your account over the last 90 days.' - ).should('be.visible'); + cy.findByText(loginHelperText).should('be.visible'); // Confirm the login table includes the expected column headers and mocked logins are visible in table. cy.findByLabelText('Account Logins').within(() => { @@ -83,9 +74,6 @@ describe('Account login history', () => { .closest('tr') .within(() => { // Confirm that successful login and status icon display in table. - cy.findByText(mockSuccessfulLogin.status, { exact: false }).should( - 'be.visible' - ); cy.findAllByLabelText(`Status is ${mockSuccessfulLogin.status}`); // Confirm all other fields display in table. @@ -114,20 +102,12 @@ describe('Account login history', () => { mockGetProfile(mockProfile).as('getProfile'); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table and table are not visible. - cy.findByText( - 'Logins across all users on your account over the last 90 days.' - ).should('not.exist'); + cy.findByText(loginHelperText).should('not.exist'); cy.findByLabelText('Account Logins').should('not.exist'); cy.findByText( @@ -149,24 +129,119 @@ describe('Account login history', () => { mockGetProfile(mockProfile).as('getProfile'); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table and table are not visible. - cy.findByText( - 'Logins across all users on your account over the last 90 days.' - ).should('not.exist'); + cy.findByText(loginHelperText).should('not.exist'); cy.findByLabelText('Account Logins').should('not.exist'); cy.findByText( "You don't have permissions to edit this Account. Please contact your account administrator to request the necessary permissions." ); }); + + /* + * - Vaildates login history landing page with mock data. + * - Confirms that each login is listed in the Login History table. + * - Confirms that "Successful" indicator is shown for successful login attempts, and the "Failure" indicator is shown for the failed ones. + * - Confirms that clicking on the username for the login navigates to the expected user page + */ + it('shows each login in the Login History landing page as expected', () => { + const mockProfile = profileFactory.build({ + username: 'mock-user', + restricted: false, + user_type: 'default', + }); + const mockFailedLogin = accountLoginFactory.build({ + status: 'failed', + username: 'mock-user-failed', + restricted: false, + }); + const mockSuccessfulLogin = accountLoginFactory.build({ + status: 'successful', + username: 'mock-user-successful', + restricted: false, + }); + + mockGetProfile(mockProfile).as('getProfile'); + mockGetAccountLogins([mockFailedLogin, mockSuccessfulLogin]).as( + 'getAccountLogins' + ); + + // Navigate to Account Login History page. + cy.visitWithLogin('/account/login-history'); + cy.wait(['@getProfile']); + + // Confirm helper text above table is visible. + cy.findByText(loginHelperText).should('be.visible'); + + // Confirm the login table includes the expected column headers and mocked logins are visible in table. + cy.findByLabelText('Account Logins').within(() => { + cy.get('thead').findByText('Date').should('be.visible'); + cy.get('thead').findByText('Username').should('be.visible'); + cy.get('thead').findByText('IP').should('be.visible'); + cy.get('thead').findByText('Permission Level').should('be.visible'); + cy.get('thead').findByText('Access').should('be.visible'); + + // Confirm that restricted user's failed login and status icon display in table. + cy.findByText(mockFailedLogin.username) + .should('be.visible') + .closest('tr') + .within(() => { + // cy.findByText(mockFailedLogin.status, { exact: false }).should( + // 'be.visible' + // ); + cy.findAllByLabelText(`Status is ${mockFailedLogin.status}`); + cy.findByText('Unrestricted').should('be.visible'); + }); + + // Confirm that unrestricted user login displays in table. + cy.findByText(mockSuccessfulLogin.username) + .should('be.visible') + .closest('tr') + .within(() => { + // Confirm that successful login and status icon display in table. + cy.findAllByLabelText(`Status is ${mockSuccessfulLogin.status}`); + + // Confirm all other fields display in table. + cy.findByText( + formatDate(mockSuccessfulLogin.datetime, { + timezone: mockProfile.timezone, + }) + ).should('be.visible'); + cy.findByText(mockSuccessfulLogin.ip).should('be.visible'); + cy.findByText('Unrestricted').should('be.visible'); + }); + }); + }); + + /* + * - Confirms that empty state is handled gracefully, showing corresponding message. + */ + it('shows empty message when there is no login history', () => { + mockGetAccountLogins([]).as('getAccountLogins'); + + // Navigate to Login History landing page. + cy.visitWithLogin('/account/login-history'); + cy.wait('@getAccountLogins'); + + // Confirm helper text above table is visible. + cy.findByText(loginHelperText).should('be.visible'); + + cy.findByLabelText('Account Logins').within(() => { + cy.get('thead').findByText('Date').should('be.visible'); + cy.get('thead').findByText('Username').should('be.visible'); + cy.get('thead').findByText('IP').should('be.visible'); + cy.get('thead').findByText('Permission Level').should('be.visible'); + cy.get('thead').findByText('Access').should('be.visible'); + }); + + cy.get('[data-testid="table-row-empty"]') + .should('be.visible') + .within(() => { + cy.findByText(loginEmptyStateMessageText).should('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts index 72f3a3bd68b..49eb9c30630 100644 --- a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts @@ -1,10 +1,5 @@ import { Profile } from '@linode/api-v4'; import { profileFactory } from '@src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { getProfile } from 'support/api/account'; import { interceptGetProfile } from 'support/intercepts/profile'; @@ -18,12 +13,6 @@ const verifyUsernameAndEmail = ( tooltip: string, checkEmail: boolean ) => { - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetProfile(mockRestrictedProxyProfile); // Navigate to User Profile page diff --git a/packages/manager/cypress/e2e/core/account/email-bounce.spec.ts b/packages/manager/cypress/e2e/core/account/email-bounce.spec.ts new file mode 100644 index 00000000000..7e8443fe8ad --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/email-bounce.spec.ts @@ -0,0 +1,217 @@ +/** + * @file Integration tests for Cloud Manager email bounce banners. + */ + +import { Notification } from '@linode/api-v4'; +import { notificationFactory } from '@src/factories/notification'; +import { mockGetNotifications } from 'support/intercepts/events'; +import { getProfile } from 'support/api/account'; +import { ui } from 'support/ui'; +import { mockUpdateProfile } from 'support/intercepts/profile'; +import { randomString } from 'support/util/random'; +import { accountFactory } from 'src/factories/account'; +import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; + +const notifications_billing_email_bounce: Notification[] = [ + notificationFactory.build({ + type: 'billing_email_bounce', + severity: 'major', + }), +]; + +const notifications_user_email_bounce: Notification[] = [ + notificationFactory.build({ + type: 'user_email_bounce', + severity: 'major', + }), +]; + +const confirmButton = 'Yes itā€™s correct.'; +const updateButton = 'No, letā€™s update it.'; + +describe('Email bounce banners', () => { + /* + * Confirm that the user profile email banner appears when the user_email_bounce notification is present + * Confirm that clicking "Yes, it's correct" causes a PUT request to be made to the API account endpoint containing the current user profile email address + */ + it('User profile email bounce is visible and can be confirmed by users', () => { + getProfile().then((profile) => { + const userprofileEmail = profile.body.email; + + const UserProfileEmailBounceBanner = `An email to your user profileā€™s email address couldnā€™t be delivered. Is ${userprofileEmail} the correct address?`; + + mockGetNotifications(notifications_user_email_bounce).as( + 'mockNotifications' + ); + cy.visitWithLogin('/account/users'); + cy.wait('@mockNotifications'); + + mockUpdateProfile({ + ...profile.body, + email: userprofileEmail, + }).as('updateEmail'); + + cy.contains(UserProfileEmailBounceBanner) + .should('be.visible') + .parent() + .parent() + .within(() => { + ui.button + .findByTitle(confirmButton) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.toast.assertMessage('Email confirmed'); + cy.contains(UserProfileEmailBounceBanner).should('not.exist'); + cy.wait('@updateEmail'); + cy.findByText(`${userprofileEmail}`).should('be.visible'); + }); + }); + + /* + * Confirm that the user profile email banner appears when the user_email_bounce notification is present + * Confirm that clicking "No, let's update it" redirects the user to {{/account} and that the contact info edit drawer is automatically opened + */ + // TODO unskip the test once M3-8181 is fixed + it.skip('User profile email bounce is visible and can be updated by users', () => { + const newEmail = `${randomString(12)}@example.com`; + + getProfile().then((profile) => { + const userprofileEmail = profile.body.email; + + const UserProfileEmailBounceBanner = `An email to your user profileā€™s email address couldnā€™t be delivered. Is ${userprofileEmail} the correct address?`; + + mockGetNotifications(notifications_user_email_bounce).as( + 'mockNotifications' + ); + cy.visitWithLogin('/account/users'); + cy.wait('@mockNotifications'); + + cy.contains(UserProfileEmailBounceBanner) + .should('be.visible') + .parent() + .parent() + .within(() => { + ui.button + .findByTitle(updateButton) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[id="email"]') + .should('be.visible') + .should('have.value', userprofileEmail) + .clear() + .type(newEmail); + + cy.get('[data-qa-textfield-label="Email"]') + .parent() + .parent() + .parent() + .within(() => { + ui.button + .findByTitle('Update Email') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText('Email updated successfully.').should('be.visible'); + + // see M3-8181 + cy.contains(UserProfileEmailBounceBanner).should('not.exist'); + }); + }); + + /* + * Confirm that the billing email banner appears when the billing_email_bounce notification is present + * Confirm that clicking "Yes, it's correct" causes a PUT request to be made to the API account endpoint containing the current billing email address + */ + it('Billing email bounce is visible and can be confirmed by users', () => { + const accountData = accountFactory.build(); + // mock the user's account data and confirm that it is displayed correctly upon page load + mockUpdateAccount(accountData).as('updateAccount'); + // get the user's account data for Cloud to inject the email address into the notification + mockGetAccount(accountData).as('getAccount'); + + const billingemail = accountData.email; + const BillingEmailBounceBanner = `An email to your accountā€™s email address couldnā€™t be delivered. Is ${billingemail} the correct address?`; + + mockGetNotifications(notifications_billing_email_bounce).as( + 'mockNotifications' + ); + + cy.visitWithLogin('/account/billing'); + cy.wait(['@mockNotifications', '@getAccount']); + + // check the billing email bounce banner and click the confirm button + cy.contains(BillingEmailBounceBanner) + .should('be.visible') + .parent() + .parent() + .within(() => { + ui.button + .findByTitle(confirmButton) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // check the toast notification + cy.wait('@updateAccount'); + ui.toast.assertMessage('Email confirmed'); + + // confirm the billing email bounce banner not exist after clicking the confirm button + cy.contains(BillingEmailBounceBanner).should('not.exist'); + + // confirm the email address is visible in Billing Contact + cy.findByText('Billing Contact') + .should('be.visible') + .parent() + .parent() + .within(() => { + cy.findByText(`${billingemail}`).should('be.visible'); + }); + }); + + /* + * Confirm that the billing email banner appears when the billing_email_bounce notification is present + * Confirm that clicking "No, let's update it" redirects the user to {{/account} and that the contact info edit drawer is automatically opened + */ + // TODO unskip the test once M3-8181 is fixed + it.skip('Billing email bounce is visible and can be updated by users', () => { + const accountData = accountFactory.build(); + // mock the user's account data and confirm that it is displayed correctly upon page load + mockUpdateAccount(accountData).as('updateAccount'); + // get the user's account data for Cloud to inject the email address into the notification + mockGetAccount(accountData).as('getAccount'); + + const billingemail = accountData.email; + const BillingEmailBounceBanner = `An email to your accountā€™s email address couldnā€™t be delivered. Is ${billingemail} the correct address?`; + + mockGetNotifications(notifications_billing_email_bounce).as( + 'mockNotifications' + ); + + cy.visitWithLogin('/account/billing'); + cy.wait(['@mockNotifications', '@getAccount']); + + cy.contains(BillingEmailBounceBanner) + .should('be.visible') + .parent() + .parent() + .within(() => { + ui.button + .findByTitle(updateButton) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // see M3-8181 + cy.contains(BillingEmailBounceBanner).should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index 5f616fe4de6..270f5568f23 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -15,11 +15,6 @@ import { } from 'support/intercepts/profile'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { PROXY_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constants'; describe('Personal access tokens', () => { @@ -278,24 +273,13 @@ describe('Personal access tokens', () => { }); const proxyUserProfile = profileFactory.build({ user_type: 'proxy' }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetProfile(proxyUserProfile); mockGetPersonalAccessTokens([proxyToken]).as('getTokens'); mockGetAppTokens([]).as('getAppTokens'); mockRevokePersonalAccessToken(proxyToken.id).as('revokeToken'); cy.visitWithLogin('/profile/tokens'); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getTokens', - '@getAppTokens', - ]); + cy.wait(['@getTokens', '@getAppTokens']); // Find token in list, confirm "Rename" is disabled and tooltip displays. cy.findByText(proxyToken.label) 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 7a71dfe0fb9..71428f889ea 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -2,7 +2,6 @@ * @file Tests for service transfer functionality between accounts. */ -import { createLinode } from '@linode/api-v4/lib/linodes'; import { getProfile } from '@linode/api-v4/lib/profile'; import { EntityTransfer, Linode, Profile } from '@linode/api-v4'; import { entityTransferFactory } from 'src/factories/entityTransfers'; @@ -19,6 +18,7 @@ import { } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; +import { createTestLinode } from 'support/util/linodes'; import { pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomUuid } from 'support/util/random'; import { visitUrlWithManagedEnabled } from 'support/api/managed'; @@ -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') @@ -244,22 +244,25 @@ describe('Account service transfers', () => { * - Confirms that users can cancel a service transfer */ it('can initiate and cancel a service transfer', () => { - // Create a Linode to transfer and wait for it to boot. + // Create a Linode to transfer. const setupLinode = async (): Promise => { const payload = createLinodeRequestFactory.build({ label: randomLabel(), region: chooseRegion().id, }); - const linode: Linode = await createLinode(payload); - await pollLinodeStatus(linode.id, 'running', { + const linode: Linode = await createTestLinode(payload, { + securityMethod: 'powered_off', + }); + + await pollLinodeStatus(linode.id, 'offline', { initialDelay: 15000, }); return linode; }; - cy.defer(setupLinode(), 'creating and booting Linode').then( + cy.defer(() => setupLinode(), 'creating and booting Linode').then( (linode: Linode) => { interceptInitiateEntityTransfer().as('initiateTransfer'); @@ -320,7 +323,7 @@ describe('Account service transfers', () => { cy.get('[data-qa-close-drawer]').should('be.visible').click(); }); - // Attempt to receive the an invalid token. + // Attempt to receive an invalid token. redeemToken(randomUuid()); assertReceiptError('Not found'); 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..d0cf29ac00d --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts @@ -0,0 +1,351 @@ +import { sshKeyFactory } from 'src/factories'; +import { + mockCreateSSHKey, + mockCreateSSHKeyError, + mockDeleteSSHKey, + mockGetSSHKeys, + mockUpdateSSHKey, +} 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); + }); + + /* + * - Validates SSH key update flow using mock data. + * - Confirms that the drawer opens when clicking. + * - Confirms that a form validation error appears when the label is not present. + * - Confirms UI flow when user updates an SSH key. + */ + it('updates 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`, + }); + const newSSHKeyLabel = randomLabel(); + const modifiedSSHKey = sshKeyFactory.build({ + ...mockSSHKey, + label: newSSHKeyLabel, + }); + + mockGetSSHKeys([mockSSHKey]).as('getSSHKeys'); + + // Navigate to SSH key landing page. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + // When a user clicks "Edit" button on SSH key landing page (/profile/keys), the "Edit SSH Key" drawer opens + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle(`Edit SSH Key ${mockSSHKey.label}`) + .should('be.visible') + .within(() => { + // When the label is unchanged, the 'Save' button is diabled + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.disabled'); + + // When a user tries to update an SSH key without a label, a form validation error appears + cy.get('[id="label"]').clear(); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Label is required.'); + + // SSH label is not modified when the operation is cancelled + cy.get('[id="label"]').clear().type(newSSHKeyLabel); + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.findAllByText(mockSSHKey.label).should('be.visible'); + + mockGetSSHKeys([modifiedSSHKey]).as('getSSHKeys'); + mockUpdateSSHKey(mockSSHKey.id, modifiedSSHKey).as('updateSSHKey'); + + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle(`Edit SSH Key ${mockSSHKey.label}`) + .should('be.visible') + .within(() => { + // Update a new ssh key + cy.get('[id="label"]').clear().type(newSSHKeyLabel); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@updateSSHKey', '@getSSHKeys']); + + // When a user updates an SSH key, a toast notification appears that says "Successfully updated SSH key." + ui.toast.assertMessage('Successfully updated SSH key.'); + + // When a user updates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user + cy.findAllByText(modifiedSSHKey.label).should('be.visible'); + }); + + /* + * - Vaildates SSH key delete flow using mock data. + * - Confirms that the dialog opens when clicking. + * - Confirms UI flow when user deletes an SSH key. + */ + it('deletes an SSH key via Profile page as expected', () => { + const mockSSHKeys = sshKeyFactory.buildList(2); + + mockGetSSHKeys(mockSSHKeys).as('getSSHKeys'); + + // Navigate to SSH key landing page. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + mockDeleteSSHKey(mockSSHKeys[0].id).as('deleteSSHKey'); + mockGetSSHKeys([mockSSHKeys[1]]).as('getUpdatedSSHKeys'); + + // When a user clicks "Delete" button on SSH key landing page (/profile/keys), the "Delete SSH Key" dialog opens + cy.findAllByText(`${mockSSHKeys[0].label}`) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog + .findByTitle('Delete SSH Key') + .should('be.visible') + .within(() => { + cy.findAllByText( + `Are you sure you want to delete SSH key ${mockSSHKeys[0].label}?` + ).should('be.visible'); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSSHKey', '@getUpdatedSSHKeys']); + + // When a user deletes an SSH key, the SSH key is removed from the list + cy.findAllByText(mockSSHKeys[0].label).should('not.exist'); + + mockDeleteSSHKey(mockSSHKeys[1].id).as('deleteSSHKey'); + mockGetSSHKeys([]).as('getUpdatedSSHKeys'); + + // When a user clicks "Delete" button on SSH key landing page (/profile/keys), the "Delete SSH Key" dialog opens + cy.findAllByText(`${mockSSHKeys[1].label}`) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog + .findByTitle('Delete SSH Key') + .should('be.visible') + .within(() => { + cy.findAllByText( + `Are you sure you want to delete SSH key ${mockSSHKeys[1].label}?` + ).should('be.visible'); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSSHKey', '@getUpdatedSSHKeys']); + + // When a user deletes the last SSH key, the list of SSH keys updates to show "No items to display." + cy.findAllByText(mockSSHKeys[1].label).should('not.exist'); + cy.findAllByText('No items to display.').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts index 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/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index e67d5695332..d8f21fa1366 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -11,14 +11,9 @@ import { mockUpdateUser, mockUpdateUserGrants, } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { shuffleArray } from 'support/util/arrays'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; // Message shown when user has unrestricted account access. @@ -504,12 +499,6 @@ describe('User permission management', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockActiveUser, mockRestrictedUser]).as('getUsers'); mockGetUser(mockActiveUser); mockGetUserGrants(mockActiveUser.username, mockUserGrants); @@ -520,7 +509,6 @@ describe('User permission management', () => { ); mockGetUser(mockRestrictedUser); mockGetUserGrants(mockRestrictedUser.username, mockUserGrants); - cy.wait(['@getClientStream', '@getFeatureFlags']); cy.get('[data-qa-global-section]') .should('be.visible') @@ -573,12 +561,6 @@ describe('User permission management', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -590,8 +572,6 @@ describe('User permission management', () => { `/account/users/${mockRestrictedProxyUser.username}/permissions` ); - cy.wait(['@getClientStream', '@getFeatureFlags']); - cy.findByText('Parent User Permissions', { exact: false }).should( 'be.visible' ); diff --git a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts index 23279de91af..2b2fd767d4f 100644 --- a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts @@ -7,13 +7,8 @@ import { mockGetUsers, } from 'support/intercepts/account'; import { mockGetSecurityQuestions } from 'support/intercepts/profile'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { verificationBannerNotice } from 'support/constants/user'; describe('User verification banner', () => { @@ -46,12 +41,6 @@ describe('User verification banner', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream(); - mockGetUsers([mockRestrictedProxyUser]); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -128,12 +117,6 @@ describe('User verification banner', () => { mockSecurityQuestions.security_questions[2].response = mockSecurityQuestionAnswers[2]; - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -211,12 +194,6 @@ describe('User verification banner', () => { mockSecurityQuestions.security_questions[2].response = mockSecurityQuestionAnswers[2]; - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index e6c79be4da4..4597d099fda 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -10,16 +10,11 @@ import { mockGetUsers, mockDeleteUser, } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { PARENT_USER } from 'src/features/Account/constants'; @@ -65,12 +60,6 @@ const initTestUsers = (profile: Profile, enableChildAccountAccess: boolean) => { global: { child_account_access: enableChildAccountAccess }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Initially mock user with unrestricted account access. mockGetUsers(mockUsers).as('getUsers'); mockGetUser(mockRestrictedParentWithoutChildAccountAccess); @@ -114,7 +103,7 @@ describe('Users landing page', () => { // Confirm that "Child account access" column is present cy.findByText('Child Account Access').should('be.visible'); mockUsers.forEach((user) => { - cy.get(`[aria-label="User ${user.username}"]`) + cy.get(`[data-qa-table-row="${user.username}"]`) .should('be.visible') .within(() => { if ( @@ -228,12 +217,6 @@ describe('Users landing page', () => { restricted: false, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Initially mock user with unrestricted account access. mockGetUsers([mockUser]).as('getUsers'); mockGetUser(mockUser); @@ -276,12 +259,6 @@ describe('Users landing page', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -466,12 +443,6 @@ describe('Users landing page', () => { restricted: true, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockUser]).as('getUsers'); mockGetUser(mockUser); mockGetUserGrantsUnrestrictedAccess(mockUser.username); @@ -565,7 +536,6 @@ describe('Users landing page', () => { expect(intercept.request.body['restricted']).to.equal(newUser.restricted); }); cy.wait('@getUsers'); - cy.wait(['@getClientStream', '@getFeatureFlags']); // the new user is displayed in the user list cy.findByText(newUser.username).should('be.visible'); @@ -587,12 +557,6 @@ describe('Users landing page', () => { restricted: false, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockUser, additionalUser]).as('getUsers'); mockGetUser(mockUser); mockGetUserGrantsUnrestrictedAccess(mockUser.username); diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index dbd4d28727f..c73550b7a13 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -2,16 +2,13 @@ import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories/account'; import type { Account } from '@linode/api-v4'; import { ui } from 'support/ui'; -import { profileFactory } from '@src/factories'; - +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { TAX_ID_HELPER_TEXT } from 'src/features/Billing/constants'; import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; - -import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { randomLabel } from 'support/util/random'; +import type { Flags } from 'src/featureFlags'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ @@ -73,6 +70,14 @@ const checkAccountContactDisplay = (accountInfo: Account) => { }; describe('Billing Contact', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + taxId: makeFeatureFlagData({ + enabled: true, + }), + }); + mockGetFeatureFlagClientstream(); + }); it('Edit Contact Info', () => { // mock the user's account data and confirm that it is displayed correctly upon page load mockGetAccount(accountData).as('getAccount'); @@ -134,6 +139,8 @@ describe('Billing Contact', () => { .click() .clear() .type(newAccountData['phone']); + cy.get('[data-qa-contact-country]').click().type('Afghanistan{enter}'); + cy.findByText(TAX_ID_HELPER_TEXT).should('be.visible'); cy.get('[data-qa-contact-country]') .click() .type('United States{enter}'); @@ -146,6 +153,7 @@ describe('Billing Contact', () => { .click() .clear() .type(newAccountData['tax_id']); + cy.findByText(TAX_ID_HELPER_TEXT).should('not.exist'); cy.get('[data-qa-save-contact-info="true"]') .click() .then(() => { @@ -161,32 +169,3 @@ describe('Billing Contact', () => { }); }); }); - -describe('Parent/Child feature disabled', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); - }); - - it('disables company name for Parent users', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: false, - user_type: 'parent', - }); - - mockGetProfile(mockProfile); - cy.visitWithLogin('/account/billing/edit'); - - ui.drawer - .findByTitle('Edit Billing Contact Info') - .should('be.visible') - .within(() => { - cy.findByLabelText('Company Name') - .should('be.visible') - .should('be.disabled'); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 55566712282..6869a54a91b 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -7,16 +7,11 @@ import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; import { ADMINISTRATOR, PARENT_USER } from 'src/features/Account/constants'; import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; // Tooltip message that appears on disabled billing action buttons for restricted @@ -229,170 +224,118 @@ describe('restricted user billing flows', () => { mockGetPaymentMethods(mockPaymentMethods); }); - // TODO Delete all of these tests when Parent/Child launches and flag is removed. - describe('Parent/Child feature disabled', () => { - beforeEach(() => { - // Mock the Parent/Child feature flag to be enabled. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); + /* + * - Confirms that users with read-only account access cannot edit billing information. + * - Confirms UX enhancements are applied when parent/child feature flag is enabled. + * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. + * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. + * - Confirms that button tooltip text reflects read-only account access. + * - Confirms that payment method action menu items are disabled. + */ + it('cannot edit billing information with read-only account access', () => { + const mockProfile = profileFactory.build({ + username: randomLabel(), + restricted: true, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + restricted: true, + user_type: 'default', + }); + + const mockGrants = grantsFactory.build({ + global: { + account_access: 'read_only', + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + + assertEditBillingInfoDisabled(restrictedUserTooltip); + assertAddPaymentMethodDisabled(restrictedUserTooltip); + assertMakeAPaymentDisabled( + restrictedUserTooltip + + ` Please contact your ${ADMINISTRATOR} to request the necessary permissions.` + ); + }); + + /* + * - Confirms that child users cannot edit billing information. + * - Confirms that UX enhancements are applied when parent/child feature flag is enabled. + * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. + * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. + * - Confirms that button tooltip text reflects child user access. + * - Confirms that payment method action menu items are disabled. + */ + it('cannot edit billing information as child account', () => { + const mockProfile = profileFactory.build({ + username: randomLabel(), + user_type: 'child', }); - /* - * - Smoke test to confirm that regular users can edit billing information. - * - Confirms that billing action buttons are enabled and open their respective drawers on click. - * - Confirms that payment method action menu items are enabled. - */ - it('can edit billing information', () => { - // The flow prior to Parent/Child does not account for user privileges, instead relying - // on the API to forbid actions when the user does not have the required privileges. - // Because the API is doing the heavy lifting, we only need to ensure that the billing action - // buttons behave as expected for this smoke test. - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - user_type: 'default', - restricted: false, - }); - - // Confirm button behavior for regular users. - mockGetProfile(mockProfile); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); + const mockUser = accountUserFactory.build({ + username: mockProfile.username, }); + + mockGetProfile(mockProfile); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + + assertEditBillingInfoDisabled(restrictedUserTooltip); + assertAddPaymentMethodDisabled(restrictedUserTooltip); + assertMakeAPaymentDisabled( + restrictedUserTooltip + + ` Please contact your ${PARENT_USER} to request the necessary permissions.` + ); }); - describe('Parent/Child feature enabled', () => { - beforeEach(() => { - // Mock the Parent/Child feature flag to be enabled. - // TODO Delete this `beforeEach()` block when Parent/Child launches and flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); + /* + * - Smoke test to confirm that regular and parent users can edit billing information. + * - Confirms that billing action buttons are enabled and open their respective drawers on click. + */ + it('can edit billing information as a regular user and as a parent user', () => { + const mockProfileRegular = profileFactory.build({ + username: randomLabel(), + restricted: false, }); - /* - * - Confirms that users with read-only account access cannot edit billing information. - * - Confirms UX enhancements are applied when parent/child feature flag is enabled. - * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. - * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. - * - Confirms that button tooltip text reflects read-only account access. - * - Confirms that payment method action menu items are disabled. - */ - it('cannot edit billing information with read-only account access', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: true, - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - restricted: true, - user_type: 'default', - }); - - const mockGrants = grantsFactory.build({ - global: { - account_access: 'read_only', - }, - }); - - mockGetProfile(mockProfile); - mockGetProfileGrants(mockGrants); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - - assertEditBillingInfoDisabled(restrictedUserTooltip); - assertAddPaymentMethodDisabled(restrictedUserTooltip); - assertMakeAPaymentDisabled( - restrictedUserTooltip + - ` Please contact your ${ADMINISTRATOR} to request the necessary permissions.` - ); + const mockUserRegular = accountUserFactory.build({ + username: mockProfileRegular.username, + user_type: 'default', + restricted: false, }); - /* - * - Confirms that child users cannot edit billing information. - * - Confirms that UX enhancements are applied when parent/child feature flag is enabled. - * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. - * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. - * - Confirms that button tooltip text reflects child user access. - * - Confirms that payment method action menu items are disabled. - */ - it('cannot edit billing information as child account', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - user_type: 'child', - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - }); - - mockGetProfile(mockProfile); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - - assertEditBillingInfoDisabled(restrictedUserTooltip); - assertAddPaymentMethodDisabled(restrictedUserTooltip); - assertMakeAPaymentDisabled( - restrictedUserTooltip + - ` Please contact your ${PARENT_USER} to request the necessary permissions.` - ); + const mockProfileParent = profileFactory.build({ + username: randomLabel(), + restricted: false, }); - /* - * - Smoke test to confirm that regular and parent users can edit billing information. - * - Confirms that billing action buttons are enabled and open their respective drawers on click. - */ - it('can edit billing information as a regular user and as a parent user', () => { - const mockProfileRegular = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUserRegular = accountUserFactory.build({ - username: mockProfileRegular.username, - user_type: 'default', - restricted: false, - }); - - const mockProfileParent = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUserParent = accountUserFactory.build({ - username: mockProfileParent.username, - user_type: 'parent', - restricted: false, - }); - - // Confirm button behavior for regular users. - mockGetProfile(mockProfileRegular); - mockGetUser(mockUserRegular); - cy.visitWithLogin('/account/billing'); - cy.findByText(mockProfileRegular.username); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); - - // Confirm button behavior for parent users. - mockGetProfile(mockProfileParent); - mockGetUser(mockUserParent); - cy.visitWithLogin('/account/billing'); - cy.findByText(mockProfileParent.username); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); + const mockUserParent = accountUserFactory.build({ + username: mockProfileParent.username, + user_type: 'parent', + restricted: false, }); + + // Confirm button behavior for regular users. + mockGetProfile(mockProfileRegular); + mockGetUser(mockUserRegular); + cy.visitWithLogin('/account/billing'); + cy.findByText(mockProfileRegular.username); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); + + // Confirm button behavior for parent users. + mockGetProfile(mockProfileParent); + mockGetUser(mockUserParent); + cy.visitWithLogin('/account/billing'); + cy.findByText(mockProfileParent.username); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); }); }); 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/databases/resize-database.spec.ts b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts new file mode 100644 index 00000000000..1dab1c0e0ad --- /dev/null +++ b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts @@ -0,0 +1,368 @@ +/** + * @file DBaaS integration tests for resize operations. + */ + +import { randomNumber, randomIp, randomString } from 'support/util/random'; +import { databaseFactory, possibleStatuses } from 'src/factories/databases'; +import { ui } from 'support/ui'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetDatabase, + mockGetDatabaseCredentials, + mockGetDatabaseTypes, + mockResize, + mockResizeProvisioningDatabase, +} from 'support/intercepts/databases'; +import { + databaseClusterConfiguration, + databaseConfigurationsResize, + mockDatabaseNodeTypes, +} from 'support/constants/databases'; +import { accountFactory } from '@src/factories'; + +/** + * Resizes a current database cluster to a larger plan size. + * + * This requires that the 'Resize' tab is currently active. No + * assertion is made on the result of the access control update attempt. + * + * @param initialLabel - Database label to resize. + */ + +const resizeDatabase = (initialLabel: string) => { + ui.button + .findByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + ui.dialog + .findByTitle(`Resize Database Cluster ${initialLabel}?`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Cluster Name').click().type(initialLabel); + ui.buttonGroup + .findButtonByTitle('Resize Cluster') + .should('be.visible') + .click(); + }); +}; + +describe('Resizing existing clusters', () => { + databaseConfigurationsResize.forEach( + (configuration: databaseClusterConfiguration) => { + describe(`Resizes a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { + /* + * - Tests active database resize UI flows using mocked data. + * - Confirms that users can resize an existing database. + * - Confirms that users can not downsize and smaller plans are disabled. + * - Confirms that larger size plans are enabled to select for resizing and summary section displays pricing details for the selected plan. + */ + it('Can resize active database clusters', () => { + const initialLabel = configuration.label; + const allowedIp = randomIp(); + const initialPassword = randomString(16); + const database = databaseFactory.build({ + id: randomNumber(1, 1000), + type: configuration.linodeType, + label: initialLabel, + region: configuration.region.id, + engine: configuration.dbType, + cluster_size: 3, + status: 'active', + allow_list: [allowedIp], + }); + + // Mock account to ensure 'Managed Databases' capability. + const databaseType = mockDatabaseNodeTypes.find( + (nodeType) => nodeType.id === database.type + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabase(database).as('getDatabase'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); + mockGetDatabaseCredentials( + database.id, + database.engine, + initialPassword + ).as('getCredentials'); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getAccount', '@getDatabase', '@getDatabaseTypes']); + + cy.get('[data-reach-tab-list]').within(() => { + cy.findByText('Resize').should('be.visible').click(); + }); + ui.button + .findByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.disabled'); + + let nodeTypeClass = ''; + + ['Dedicated CPU', 'Shared CPU'].forEach((tabTitle) => { + // Click on the tab we want. + ui.button.findByTitle(tabTitle).should('be.visible').click(); + + if (tabTitle == 'Dedicated CPU') { + nodeTypeClass = 'dedicated'; + } else { + nodeTypeClass = 'standard'; + } + // Find the smaller plans name using `nodeType` and check radio button is disabled to select + mockDatabaseNodeTypes + .filter( + (nodeType) => + nodeType.class === nodeTypeClass && + nodeType.memory < databaseType.memory + ) + .forEach((nodeType) => { + cy.get('[aria-label="List of Linode Plans"]') + .should('be.visible') + .each(() => { + cy.contains(nodeType.label).should('be.visible'); + cy.get(`[id="${nodeType.id}"]`).should('be.disabled'); + }); + }); + + // Find the larger plans name using `nodeType` and check radio button is enabled to select + mockDatabaseNodeTypes + .filter( + (nodeType) => + nodeType.class === nodeTypeClass && + nodeType.memory > databaseType.memory + ) + .forEach((nodeType) => { + cy.get('[aria-label="List of Linode Plans"]') + .should('be.visible') + .each(() => { + cy.get(`[id="${nodeType.id}"]`) + .should('be.enabled') + .click(); + }); + const desiredPlanPrice = nodeType.engines[ + configuration.dbType + ].find((dbClusterSizeObj: { quantity: number }) => { + return dbClusterSizeObj.quantity === database.cluster_size; + })?.price; + if (!desiredPlanPrice) { + throw new Error('Unable to find mock plan type'); + } + cy.get('[data-testid="summary"]').within(() => { + cy.contains(`${nodeType.label}`).should('be.visible'); + cy.contains(`$${desiredPlanPrice.monthly}/month`).should( + 'be.visible' + ); + cy.contains(`$${desiredPlanPrice.hourly}/hour`).should( + 'be.visible' + ); + }); + }); + }); + // Find the current plan name using `nodeType` and check if it has current tag displaying in UI and radio button disabled, + if (configuration.linodeType.includes('dedicated')) { + nodeTypeClass = 'dedicated'; + ui.button.findByTitle('Dedicated CPU').should('be.visible').click(); + } else { + nodeTypeClass = 'standard'; + ui.button.findByTitle('Shared CPU').should('be.visible').click(); + } + mockDatabaseNodeTypes + .filter( + (nodeType) => + // nodeType.class === nodeTypeClass && + nodeType.id === database.type + ) + .forEach((nodeType) => { + cy.get('[aria-label="List of Linode Plans"]') + .should('be.visible') + .each(() => { + cy.get(`[data-qa-current-plan]`) + .parent() + .should('contain', nodeType.label) + .should('contain', 'Current Plan'); + }); + }); + + const largePlan = mockDatabaseNodeTypes.filter( + (nodeType) => + nodeType.class === nodeTypeClass && + nodeType.memory > databaseType.memory + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + cy.get(`[id="${largePlan[0].id}"]`).click(); + + mockResize(database.id, database.engine, { + ...database, + type: 'g6-standard-32', + }).as('scaleUpDatabase'); + resizeDatabase(initialLabel); + cy.wait('@scaleUpDatabase'); + }); + + /* + * - Tests active database resize UI flows using mocked data. + * - Confirms that users can resize an existing database from dedicated to shared. + * - Confirms that users can resize an existing database from shared to dedicated. + */ + it(`Can resize active database clusters from ${configuration.linodeType} type and switch plan type`, () => { + const initialLabel = configuration.label; + const allowedIp = randomIp(); + const initialPassword = randomString(16); + const database = databaseFactory.build({ + id: randomNumber(1, 1000), + type: configuration.linodeType, + label: initialLabel, + region: configuration.region.id, + engine: configuration.dbType, + cluster_size: 3, + status: 'active', + allow_list: [allowedIp], + }); + + // Mock account to ensure 'Managed Databases' capability. + const databaseType = mockDatabaseNodeTypes.find( + (nodeType) => nodeType.id === database.type + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabase(database).as('getDatabase'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); + mockGetDatabaseCredentials( + database.id, + database.engine, + initialPassword + ).as('getCredentials'); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getAccount', '@getDatabase', '@getDatabaseTypes']); + + cy.get('[data-reach-tab-list]').within(() => { + cy.findByText('Resize').should('be.visible').click(); + }); + ui.button + .findByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.disabled'); + + let nodeTypeClass = ''; + // Find the current plan name using `nodeType` and switch to another tab for selecting plan. + if (configuration.linodeType.includes('dedicated')) { + nodeTypeClass = 'dedicated'; + ui.button.findByTitle('Shared CPU').should('be.visible').click(); + } else { + nodeTypeClass = 'standard'; + ui.button.findByTitle('Dedicated CPU').should('be.visible').click(); + } + + const largePlan = mockDatabaseNodeTypes.filter( + (nodeType) => + nodeType.class != nodeTypeClass && + nodeType.memory > databaseType.memory + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + cy.get(`[id="${largePlan[0].id}"]`).click(); + + mockResize(database.id, database.engine, { + ...database, + type: 'g6-standard-32', + }).as('scaleUpDatabase'); + resizeDatabase(initialLabel); + cy.wait('@scaleUpDatabase'); + }); + + /* + * - Tests resizing database using mocked data. + * - Confirms that users cannot resize database for provisioning DBs. + * - Confirms that users cannot resize database for restoring DBs. + * - Confirms that users cannot resize database for failed DBs. + * - Confirms that users cannot resize database for degraded DBs + */ + it('Cannot resize database clusters while they are not in active state', () => { + // const databaseStatus = ["provisioning", 'failed', 'restoring']; + possibleStatuses.forEach((dbstatus) => { + if (dbstatus != 'active') { + const initialLabel = configuration.label; + const allowedIp = randomIp(); + const database = databaseFactory.build({ + id: randomNumber(1, 1000), + type: configuration.linodeType, + label: initialLabel, + region: configuration.region.id, + engine: configuration.dbType, + cluster_size: 3, + status: dbstatus, + allow_list: [allowedIp], + hosts: { + primary: undefined, + secondary: undefined, + }, + }); + + const errorMessage = `Your database is ${dbstatus}; please wait until it becomes active to perform this operation.`; + + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabase(database).as('getDatabase'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as( + 'getDatabaseTypes' + ); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getAccount', '@getDatabaseTypes', '@getDatabase']); + + mockResize(database.id, database.engine, { + ...database, + type: 'g6-standard-32', + }).as('resizeDatabase'); + + mockResizeProvisioningDatabase( + database.id, + database.engine, + errorMessage + ).as('resizeDatabase'); + + cy.get('[data-reach-tab-list]').within(() => { + cy.findByText('Resize').should('be.visible').click(); + }); + const databaseType = mockDatabaseNodeTypes.find( + (nodeType) => nodeType.id === database.type + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + let nodeTypeClass = ''; + if (configuration.linodeType.includes('standard')) { + nodeTypeClass = 'standard'; + } else { + nodeTypeClass = 'dedicated'; + } + const largePlan = mockDatabaseNodeTypes.filter( + (nodeType) => + nodeType.class === nodeTypeClass && + nodeType.memory > databaseType.memory + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + cy.get(`[id="${largePlan[0].id}"]`).click(); + resizeDatabase(initialLabel); + cy.wait('@resizeDatabase'); + cy.findByText(errorMessage).should('be.visible'); + cy.get('[data-qa-cancel="true"]') + .should('be.visible') + .should('be.enabled') + .click(); + } + }); + }); + }); + } + ); +}); 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-create-domain-records.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts index 379386f9258..3b1cb74e422 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts @@ -1,40 +1,33 @@ -/* eslint-disable sonarjs/no-duplicate-string */ import { authenticate } from 'support/api/authentication'; import { createDomain } from 'support/api/domains'; -import { fbtClick, getClick } from 'support/helpers'; import { interceptCreateDomainRecord } from 'support/intercepts/domains'; -import { cleanUp } from 'support/util/cleanup'; import { createDomainRecords } from 'support/constants/domains'; authenticate(); -describe('Creates Domains record with Form', () => { - before(() => { - cleanUp('domains'); - }); - createDomainRecords().forEach((rec) => { - return it(rec.name, () => { - createDomain().then((domain) => { - // intercept create api record request - interceptCreateDomainRecord().as('apiCreateRecord'); - const url = `/domains/${domain.id}`; - cy.visitWithLogin(url); - cy.url().should('contain', url); - fbtClick(rec.name); - rec.fields.forEach((f) => { - getClick(f.name).type(f.value); - }); - fbtClick('Save'); - cy.wait('@apiCreateRecord') - .its('response.statusCode') - .should('eq', 200); - cy.get(`[aria-label="${rec.tableAriaLabel}"]`).within((_table) => { - rec.fields.forEach((f) => { - if (f.skipCheck) { - return; - } - cy.findByText(f.value, { exact: !f.approximate }); - }); +describe('Creates Domains records with Form', () => { + it('Adds domain records to a newly created Domain', () => { + createDomain().then((domain) => { + // intercept create api record request + interceptCreateDomainRecord().as('apiCreateRecord'); + const url = `/domains/${domain.id}`; + cy.visitWithLogin(url); + cy.url().should('contain', url); + }); + + createDomainRecords().forEach((rec) => { + cy.findByText(rec.name).click(); + rec.fields.forEach((field) => { + cy.get(field.name).type(field.value); + }); + cy.findByText('Save').click(); + cy.wait('@apiCreateRecord').its('response.statusCode').should('eq', 200); + cy.get(`[aria-label="${rec.tableAriaLabel}"]`).within((_table) => { + rec.fields.forEach((field) => { + if (field.skipCheck) { + return; + } + cy.findByText(field.value, { exact: !field.approximate }); }); }); }); 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 43a92c8c3ca..4da8d8c2dab 100644 --- a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts @@ -1,7 +1,6 @@ -import { createLinode } from '@linode/api-v4/lib/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { createLinodeRequestFactory } from 'src/factories/linodes'; import { authenticate } from 'support/api/authentication'; -import { containsClick, getClick } from 'support/helpers'; import { interceptCreateFirewall } from 'support/intercepts/firewalls'; import { randomString, randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; @@ -33,10 +32,10 @@ describe('create firewall', () => { .should('be.visible') .within(() => { // An error message appears when attempting to create a Firewall without a label - getClick('[data-testid="submit"]'); + cy.get('[data-testid="submit"]').click(); cy.findByText('Label is required.'); // Fill out and submit firewall create form. - containsClick('Label').type(firewall.label); + cy.contains('Label').click().type(firewall.label); ui.buttonGroup .findButtonByTitle('Create Firewall') .should('be.visible') @@ -75,7 +74,10 @@ describe('create firewall', () => { label: randomLabel(), }; - cy.defer(createLinode(linodeRequest), 'creating Linode').then((linode) => { + cy.defer( + () => createTestLinode(linodeRequest, { securityMethod: 'powered_off' }), + 'creating Linode' + ).then((linode) => { interceptCreateFirewall().as('createFirewall'); cy.visitWithLogin('/firewalls/create'); @@ -84,7 +86,7 @@ describe('create firewall', () => { .should('be.visible') .within(() => { // Fill out and submit firewall create form. - containsClick('Label').type(firewall.label); + cy.contains('Label').click().type(firewall.label); cy.findByLabelText('Linodes') .should('be.visible') .click() 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 8763f5bd8fe..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 @@ -6,7 +6,6 @@ import { regionFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { createLinode } from '@linode/api-v4'; import { interceptCreateFirewall, interceptGetFirewalls, @@ -23,6 +22,7 @@ import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomNumber } from 'support/util/random'; import type { Linode, Region } from '@linode/api-v4'; import { chooseRegions } from 'support/util/regions'; +import { createTestLinode } from 'support/util/linodes'; const mockRegions: Region[] = [ regionFactory.build({ @@ -66,7 +66,7 @@ const migrationNoticeSubstrings = [ authenticate(); describe('Migrate Linode With Firewall', () => { before(() => { - cleanUp('firewalls'); + cleanUp(['firewalls', 'linodes']); }); /* @@ -144,7 +144,9 @@ describe('Migrate Linode With Firewall', () => { interceptGetFirewalls().as('getFirewalls'); // Create a Linode, then navigate to the Firewalls landing page. - cy.defer(createLinode(linodePayload)).then((linode: Linode) => { + cy.defer(() => + createTestLinode(linodePayload, { securityMethod: 'powered_off' }) + ).then((linode: Linode) => { interceptMigrateLinode(linode.id).as('migrateLinode'); cy.visitWithLogin('/firewalls'); cy.wait('@getFirewalls'); @@ -194,7 +196,7 @@ describe('Migrate Linode With Firewall', () => { // Make sure Linode is running before attempting to migrate. cy.get('[data-qa-linode-status]').within(() => { - cy.findByText('RUNNING'); + cy.findByText('OFFLINE'); }); ui.actionMenu 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/general/smoke-deep-link.spec.ts b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts index 1b7316d292c..bc9f2af2951 100644 --- a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts +++ b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts @@ -2,29 +2,19 @@ import { pages } from 'support/ui/constants'; import type { Page } from 'support/ui/constants'; -describe('smoke - deep link', () => { - pages.forEach((page: Page) => { - describe(`Go to ${page.name}`, () => { - // check if we run only one test - if (!page.goWithUI) { - return; - } - - // Here we use login to /null here - // so this is independant from what is coded in constants and which path are skipped - beforeEach(() => { - cy.visitWithLogin('/null'); - }); +describe('smoke - deep links', () => { + beforeEach(() => { + cy.visitWithLogin('/null'); + }); - page.goWithUI.forEach((uiPath) => { - (page.first ? it.only : page.skip ? it.skip : it)( - `by ${uiPath.name}`, - () => { - expect(uiPath.name).not.to.be.empty; - uiPath.go(); - cy.url().should('be.eq', `${Cypress.config('baseUrl')}${page.url}`); - } - ); + it('Go to each route and validate deep links', () => { + pages.forEach((page: Page) => { + cy.log(`Go to ${page.name}`); + page.goWithUI?.forEach((uiPath) => { + cy.log(`by ${uiPath.name}`); + expect(uiPath.name).not.to.be.empty; + uiPath.go(); + cy.url().should('be.eq', `${Cypress.config('baseUrl')}${page.url}`); }); }); }); diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index d5a3b762de9..95af9d0e48a 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -1,11 +1,5 @@ /* eslint-disable prettier/prettier */ /* eslint-disable sonarjs/no-duplicate-string */ -import { - getClick, - containsClick, - getVisible, - containsVisible, -} from 'support/helpers'; import 'cypress-file-upload'; import { interceptGetProfile } from 'support/intercepts/profile'; import { @@ -19,8 +13,14 @@ import { randomLabel, randomNumber, randomPhrase, + randomString, } from 'support/util/random'; -import { supportTicketFactory } from 'src/factories'; +import { + accountFactory, + domainFactory, + linodeFactory, + supportTicketFactory, +} from 'src/factories'; import { mockAttachSupportTicketFile, mockCreateSupportTicket, @@ -28,14 +28,38 @@ import { mockGetSupportTickets, mockGetSupportTicketReplies, } from 'support/intercepts/support'; -import { severityLabelMap } from 'src/features/Support/SupportTickets/ticketUtils'; +import { + SEVERITY_LABEL_MAP, + SMTP_DIALOG_TITLE, + SMTP_FIELD_NAME_TO_LABEL_MAP, + SMTP_HELPER_TEXT, +} from 'src/features/Support/SupportTickets/constants'; +import { formatDescription } from 'src/features/Support/SupportTickets/ticketUtils'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + EntityType, + TicketType, +} from 'src/features/Support/SupportTickets/SupportTicketDialog'; +import { createTestLinode } from 'support/util/linodes'; +import { cleanUp } from 'support/util/cleanup'; +import { authenticate } from 'support/api/authentication'; +import { MAGIC_DATE_THAT_EMAIL_RESTRICTIONS_WERE_IMPLEMENTED } from 'src/constants'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetDomains } from 'support/intercepts/domains'; +import { mockGetClusters } from 'support/intercepts/lke'; describe('help & support', () => { + after(() => { + cleanUp(['linodes']); + }); + + authenticate(); + /* * - Opens a Help & Support ticket using mock API data. * - Confirms that "Severity" field is not present when feature flag is disabled. */ - it('open support ticket', () => { + it('can open a support ticket', () => { mockAppendFeatureFlags({ supportTicketSeverity: makeFeatureFlagData(false), }); @@ -75,30 +99,30 @@ describe('help & support', () => { // intercept create ticket request, stub response. mockCreateSupportTicket(mockTicketData).as('createTicket'); - mockGetSupportTicket(mockTicketData).as('getTicket'); mockGetSupportTicketReplies(ticketId, []).as('getReplies'); mockAttachSupportTicketFile(ticketId).as('attachmentPost'); - containsClick('Open New Ticket'); + cy.contains('Open New Ticket').click(); cy.get('input[placeholder="Enter a title for your ticket."]') .click({ scrollBehavior: false }) .type(ticketLabel); cy.findByLabelText('Severity').should('not.exist'); - getClick('[data-qa-ticket-entity-type]'); - containsVisible('General/Account/Billing'); - getClick('[data-qa-ticket-description="true"]').type(ticketDescription); + cy.get('[data-qa-ticket-entity-type]').click(); + cy.contains('General/Account/Billing').should('be.visible'); + cy.get('[data-qa-ticket-description="true"]') + .click() + .type(ticketDescription); cy.get('[id="attach-file"]').attachFile(image); - getVisible('[value="test_screenshot.png"]'); - getClick('[data-qa-submit="true"]'); + cy.get('[value="test_screenshot.png"]').should('be.visible'); + cy.get('[data-qa-submit="true"]').click(); cy.wait('@createTicket').its('response.statusCode').should('eq', 200); cy.wait('@attachmentPost').its('response.statusCode').should('eq', 200); cy.wait('@getReplies').its('response.statusCode').should('eq', 200); - cy.wait('@getTicket').its('response.statusCode').should('eq', 200); - containsVisible(`#${ticketId}: ${ticketLabel}`); - containsVisible(ticketDescription); - containsVisible(image); + cy.contains(`#${ticketId}: ${ticketLabel}`).should('be.visible'); + cy.contains(ticketDescription).should('be.visible'); + cy.contains(image).should('be.visible'); }); }); @@ -119,7 +143,7 @@ describe('help & support', () => { // Get severity label for numeric severity level. // Bail out if we're unable to get a valid label -- this indicates a mismatch between the test and source. - const severityLabel = severityLabelMap.get(mockTicket.severity!); + const severityLabel = SEVERITY_LABEL_MAP.get(mockTicket.severity!); if (!severityLabel) { throw new Error( `Unable to retrieve label for severity level '${mockTicket.severity}'. Is this a valid support severity level?` @@ -190,4 +214,228 @@ describe('help & support', () => { cy.findByText(severityLabel).should('be.visible'); }); }); + + /* + * - Opens a SMTP Restriction Removal ticket using mock API data. + * - Creates a new linode that will have SMTP restrictions and navigates to a SMTP support ticket via notice link. + * - Confirms that the SMTP-specific fields are displayed and handled correctly. + */ + it('can create an SMTP support ticket', () => { + const mockAccount = accountFactory.build({ + first_name: 'Jane', + last_name: 'Doe', + company: 'Acme Co.', + active_since: MAGIC_DATE_THAT_EMAIL_RESTRICTIONS_WERE_IMPLEMENTED, + }); + + const mockFormFields = { + description: '', + entityId: '', + entityInputValue: '', + entityType: 'general' as EntityType, + selectedSeverity: undefined, + summary: 'SMTP Restriction Removal on ', + ticketType: 'smtp' as TicketType, + companyName: mockAccount.company, + customerName: `${mockAccount.first_name} ${mockAccount.last_name}`, + useCase: randomString(), + emailDomains: randomString(), + publicInfo: randomString(), + }; + + const mockSMTPTicket = supportTicketFactory.build({ + summary: mockFormFields.summary, + id: randomNumber(), + description: formatDescription(mockFormFields, 'smtp'), + status: 'new', + }); + + mockGetAccount(mockAccount); + mockCreateSupportTicket(mockSMTPTicket).as('createTicket'); + mockGetSupportTickets([]); + mockGetSupportTicket(mockSMTPTicket); + mockGetSupportTicketReplies(mockSMTPTicket.id, []); + + cy.visitWithLogin('/support/tickets'); + + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('open a support ticket').should('be.visible').click(); + + // Fill out ticket form. + ui.dialog + .findByTitle('Contact Support: SMTP Restriction Removal') + .should('be.visible') + .within(() => { + cy.findByText(SMTP_DIALOG_TITLE).should('be.visible'); + cy.findByText(SMTP_HELPER_TEXT).should('be.visible'); + + // Confirm summary, customer name, and company name fields are pre-populated with user account data. + cy.findByLabelText('Title', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.summary + linode.label); + + cy.findByLabelText('First and last name', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.customerName); + + cy.findByLabelText('Business or company name', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.companyName); + + ui.button + .findByTitle('Open Ticket') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm validation errors display when trying to submit without required fields. + cy.findByText('Use case is required.'); + cy.findByText('Email domains are required.'); + cy.findByText('Links to public information are required.'); + + // Complete the rest of the form. + cy.get('[data-qa-ticket-use-case]') + .should('be.visible') + .click() + .type(mockFormFields.useCase); + + cy.get('[data-qa-ticket-email-domains]') + .should('be.visible') + .click() + .type(mockFormFields.emailDomains); + + cy.get('[data-qa-ticket-public-info]') + .should('be.visible') + .click() + .type(mockFormFields.publicInfo); + + // Confirm there is no description field or file upload section. + cy.findByText('Description').should('not.exist'); + cy.findByText('Attach a File').should('not.exist'); + + ui.button + .findByTitle('Open Ticket') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that ticket create payload contains the expected data. + cy.wait('@createTicket').then((xhr) => { + expect(xhr.request.body?.summary).to.eq( + mockSMTPTicket.summary + linode.label + ); + expect(xhr.request.body?.description).to.eq(mockSMTPTicket.description); + }); + + // Confirm the new ticket is listed with the expected information upon redirecting to the details page. + cy.url().should('endWith', `support/tickets/${mockSMTPTicket.id}`); + cy.contains(`#${mockSMTPTicket.id}: SMTP Restriction Removal`).should( + 'be.visible' + ); + Object.values(SMTP_FIELD_NAME_TO_LABEL_MAP).forEach((fieldLabel) => { + cy.findByText(fieldLabel).should('be.visible'); + }); + }); + }); + + it('can create a support ticket with an entity', () => { + const mockLinodes = linodeFactory.buildList(2); + const mockDomain = domainFactory.build(); + + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + status: 'new', + }); + + mockCreateSupportTicket(mockTicket).as('createTicket'); + mockGetClusters([]); + mockGetSupportTickets([]); + mockGetSupportTicket(mockTicket); + mockGetSupportTicketReplies(mockTicket.id, []); + mockGetLinodes(mockLinodes); + mockGetDomains([mockDomain]); + + cy.visitWithLogin('/support/tickets'); + + ui.button + .findByTitle('Open New Ticket') + .should('be.visible') + .should('be.enabled') + .click(); + + // Fill out ticket form. + ui.dialog + .findByTitle('Open a Support Ticket') + .should('be.visible') + .within(() => { + cy.findByLabelText('Title', { exact: false }) + .should('be.visible') + .click() + .type(mockTicket.summary); + + cy.get('[data-qa-ticket-description]') + .should('be.visible') + .click() + .type(mockTicket.description); + + cy.get('[data-qa-ticket-entity-type]') + .click() + .type(`Linodes{downarrow}{enter}`); + + // Attempt to submit the form without an entity selected and confirm validation error. + ui.button + .findByTitle('Open Ticket') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Please select a Linode.').should('be.visible'); + + // Select an entity type for which there are no entities. + cy.get('[data-qa-ticket-entity-type]') + .click() + .type(`Kubernetes{downarrow}{enter}`); + + // Confirm the validation error clears when a new entity type is selected. + cy.findByText('Please select a Linode.').should('not.exist'); + + // Confirm helper text appears and entity id field is disabled. + cy.findByText( + 'You donā€™t have any Kubernetes Clusters on your account.' + ).should('be.visible'); + cy.get('[data-qa-ticket-entity-id]') + .find('input') + .should('be.disabled'); + + // Select another entity type. + cy.get('[data-qa-ticket-entity-type]') + .click() + .type(`{selectall}{del}Domains{uparrow}{enter}`); + + // Select an entity. + cy.get('[data-qa-ticket-entity-id]') + .should('be.visible') + .click() + .type(`${mockDomain.domain}{downarrow}{enter}`); + + ui.button + .findByTitle('Open Ticket') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that ticket create payload contains the expected data. + cy.wait('@createTicket').then((xhr) => { + expect(xhr.request.body?.summary).to.eq(mockTicket.summary); + expect(xhr.request.body?.description).to.eq(mockTicket.description); + }); + + // Confirm redirect to details page and that severity level is displayed. + cy.url().should('endWith', `support/tickets/${mockTicket.id}`); + }); }); 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 b4e73064099..ac1b6e794ba 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -1,9 +1,50 @@ -import type { Linode } from '@linode/api-v4'; +import type { Linode, Region } from '@linode/api-v4'; +import { accountFactory, linodeFactory, regionFactory } from 'src/factories'; import { authenticate } from 'support/api/authentication'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomPhrase } from 'support/util/random'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockGetLinodeDetails, + mockGetLinodes, +} from 'support/intercepts/linodes'; + +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + id: 'us-east', + label: 'Newark, NJ', + site_type: 'core', + }), + regionFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + id: 'us-den-edge-1', + label: 'Edge - Denver, CO', + site_type: 'edge', + }), +]; + +const mockLinodes: Linode[] = [ + linodeFactory.build({ + label: 'core-region-linode', + region: mockRegions[0].id, + }), + linodeFactory.build({ + label: 'edge-region-linode', + region: mockRegions[1].id, + }), +]; + +const DISK_ENCRYPTION_IMAGES_CAVEAT_COPY = + 'Virtual Machine Images are not encrypted.'; authenticate(); describe('create image (e2e)', () => { @@ -20,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'); @@ -53,6 +94,7 @@ describe('create image (e2e)', () => { cy.findByLabelText('Label') .should('be.enabled') .should('be.visible') + .clear() .type(label); // Give the Image a description @@ -84,4 +126,137 @@ describe('create image (e2e)', () => { }); }); }); + + it('displays notice informing user that Images are not encrypted, provided the LDE feature is enabled and the selected linode is not in an Edge region', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock responses + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodes(mockLinodes).as('getLinodes'); + + // intercept request + cy.visitWithLogin('/images/create'); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getAccount', + '@getLinodes', + '@getRegions', + ]); + + // Find the Linode select and open it + cy.findByLabelText('Linode') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Select a Linode') + .click(); + + // Select the Linode + ui.autocompletePopper + .findByTitle(mockLinodes[0].label) + .should('be.visible') + .should('be.enabled') + .click(); + + // Check if notice is visible + cy.findByText(DISK_ENCRYPTION_IMAGES_CAVEAT_COPY).should('be.visible'); + }); + + it('does not display a notice informing user that Images are not encrypted if the LDE feature is disabled', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock responses + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodes(mockLinodes).as('getLinodes'); + + // intercept request + cy.visitWithLogin('/images/create'); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getAccount', + '@getLinodes', + '@getRegions', + ]); + + // Find the Linode select and open it + cy.findByLabelText('Linode') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Select a Linode') + .click(); + + // Select the Linode + ui.autocompletePopper + .findByTitle(mockLinodes[0].label) + .should('be.visible') + .should('be.enabled') + .click(); + + // Check if notice is visible + cy.findByText(DISK_ENCRYPTION_IMAGES_CAVEAT_COPY).should('not.exist'); + }); + + it('does not display a notice informing user that Images are not encrypted if the selected linode is in an Edge region', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock responses + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetLinodeDetails(mockLinodes[1].id, mockLinodes[1]); + + // intercept request + cy.visitWithLogin('/images/create'); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getAccount', + '@getRegions', + '@getLinodes', + ]); + + // Find the Linode select and open it + cy.findByLabelText('Linode') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Select a Linode') + .click(); + + // Select the Linode + ui.autocompletePopper + .findByTitle(mockLinodes[1].label) + .should('be.visible') + .should('be.enabled') + .click(); + + // Check if notice is visible + cy.findByText(DISK_ENCRYPTION_IMAGES_CAVEAT_COPY).should('not.exist'); + }); }); diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index 5cec5a2b3ab..c6ae84c86ab 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -53,8 +53,6 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.wait('@mockLinodeRequest'); - console.log('mockLinode', mockLinode); - fbtVisible(mockLinode.label); fbtVisible(region.label); fbtVisible(`${mockLinode.id}`); diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 687a54abb80..fbd28300d66 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -131,7 +131,14 @@ const uploadImage = (label: string) => { mimeType: 'application/x-gzip', }); }); + cy.intercept('POST', apiMatcher('images/upload')).as('imageUpload'); + + ui.button + .findByAttribute('type', 'submit') + .should('be.enabled') + .should('be.visible') + .click(); }; authenticate(); diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts new file mode 100644 index 00000000000..663125cd190 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -0,0 +1,213 @@ +import { imageFactory, regionFactory } from 'src/factories'; +import { + mockGetCustomImages, + mockGetRecoveryImages, + mockUpdateImageRegions, +} from 'support/intercepts/images'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import type { Image } from '@linode/api-v4'; + +describe('Manage Image Regions', () => { + /** + * Adds two new regions to an Image (region3 and region4) + * and removes one existing region (region 1). + */ + it("updates an Image's regions", () => { + const region1 = regionFactory.build({ site_type: 'core' }); + const region2 = regionFactory.build({ site_type: 'core' }); + const region3 = regionFactory.build({ site_type: 'core' }); + const region4 = regionFactory.build({ site_type: 'core' }); + + const image = imageFactory.build({ + size: 50, + total_size: 100, + capabilities: ['distributed-images'], + regions: [ + { region: region1.id, status: 'available' }, + { region: region2.id, status: 'available' }, + ], + }); + + mockGetRegions([region1, region2, region3, region4]).as('getRegions'); + mockGetCustomImages([image]).as('getImages'); + mockGetRecoveryImages([]); + + cy.visitWithLogin('/images'); + cy.wait(['@getImages', '@getRegions']); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Verify total size is rendered + cy.findByText(`${image.total_size} MB`).should('be.visible'); + + // Verify capabilities are rendered + cy.findByText('Distributed').should('be.visible'); + + // Verify the first region is rendered + cy.findByText(region1.label + ',').should('be.visible'); + + // Click the "+1" + cy.findByText('+1').should('be.visible').should('be.enabled').click(); + }); + + // Verify the Manage Regions drawer opens and contains basic content + ui.drawer + .findByTitle(`Manage Regions for ${image.label}`) + .should('be.visible') + .within(() => { + // Verify the Image regions render + cy.findByText(region1.label).should('be.visible'); + cy.findByText(region2.label).should('be.visible'); + + cy.findByText('Image will be available in these regions (2)').should( + 'be.visible' + ); + + // Verify the "Save" button is disabled because no changes have been made + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.disabled'); + + // Close the Manage Regions drawer + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Open the Image's action menu + ui.actionMenu + .findByTitle(`Action menu for Image ${image.label}`) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Manage Regions" option in the action menu + ui.actionMenuItem + .findByTitle('Manage Regions') + .should('be.visible') + .should('be.enabled') + .click(); + + // Open the Regions Multi-Select + cy.findByLabelText('Add Regions') + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify "Select All" shows up as an option + ui.autocompletePopper + .findByTitle('Select All') + .should('be.visible') + .should('be.enabled'); + + // Verify region3 shows up as an option and select it + ui.autocompletePopper + .findByTitle(`${region3.label} (${region3.id})`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify region4 shows up as an option and select it + ui.autocompletePopper + .findByTitle(`${region4.label} (${region4.id})`) + .should('be.visible') + .should('be.enabled') + .click(); + + const updatedImage: Image = { + ...image, + total_size: 150, + regions: [ + { region: region2.id, status: 'available' }, + { region: region3.id, status: 'pending replication' }, + { region: region4.id, status: 'pending replication' }, + ], + }; + + // mock the POST /v4/images/:id:regions response + mockUpdateImageRegions(image.id, updatedImage); + + // mock the updated paginated response + mockGetCustomImages([updatedImage]); + + // Click outside of the Region Multi-Select to commit the selection to the list + ui.drawer + .findByTitle(`Manage Regions for ${image.label}`) + .click() + .within(() => { + // Verify the existing image regions render + cy.findByText(region1.label).should('be.visible'); + cy.findByText(region2.label).should('be.visible'); + + // Verify the newly selected image regions render + cy.findByText(region3.label).should('be.visible'); + cy.findByText(region4.label).should('be.visible'); + cy.findAllByText('unsaved').should('be.visible'); + + // Verify the count is now 3 + cy.findByText('Image will be available in these regions (4)').should( + 'be.visible' + ); + + // Verify the "Save" button is enabled because a new region is selected + ui.button.findByTitle('Save').should('be.visible').should('be.enabled'); + + // Remove region1 + cy.findByLabelText(`Remove ${region1.id}`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify the image isn't shown in the list after being removed + cy.findByText(region1.label).should('not.exist'); + + // Verify the count is now 2 + cy.findByText('Image will be available in these regions (3)').should( + 'be.visible' + ); + + // Save changes + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + + // "Unsaved" regions should transition to "pending replication" because + // they are now returned by the API + cy.findAllByText('pending replication').should('be.visible'); + + // The save button should become disabled now that changes have been saved + ui.button.findByTitle('Save').should('be.disabled'); + + // The save button should become disabled now that changes have been saved + ui.button.findByTitle('Save').should('be.disabled'); + + cy.findByLabelText('Close drawer').click(); + }); + + ui.toast.assertMessage('Image regions successfully updated.'); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Verify the new size is shown + cy.findByText('150 MB'); + + // Verify the first region is rendered + cy.findByText(region2.label + ',').should('be.visible'); + + // Verify the regions count is now "+2" + cy.findByText('+2').should('be.visible').should('be.enabled'); + }); + }); +}); 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/images/smoke-create-image.spec.ts b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts index 4f578d3bd7e..f6e0a0e1cdc 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -72,6 +72,7 @@ describe('create image (using mocks)', () => { cy.findByLabelText('Label') .should('be.enabled') .should('be.visible') + .clear() .type(mockNewImage.label); // Give the Image a description 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..bff073133a9 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -78,6 +78,7 @@ describe('LKE Cluster Creation', () => { * - Confirms that user is redirected to new LKE cluster summary page. * - Confirms that new LKE cluster summary page shows expected node pools. * - Confirms that new LKE cluster is shown on LKE clusters landing page. + * - Confirms that correct information is shown on the LKE cluster summary page */ it('can create an LKE cluster', () => { const clusterLabel = randomLabel(); @@ -114,6 +115,11 @@ describe('LKE Cluster Creation', () => { cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); + let totalCpu = 0; + let totalMemory = 0; + let totalStorage = 0; + let monthPrice = 0; + // Add a node pool for each randomly selected plan, and confirm that the // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { @@ -150,7 +156,29 @@ describe('LKE Cluster Creation', () => { // instance of the pool appears in the checkout bar. cy.findAllByText(checkoutName).first().should('be.visible'); }); + + // Expected information on the LKE cluster summary page. + if (clusterPlan.size == 2 && clusterPlan.type == 'Linode') { + totalCpu = totalCpu + nodeCount * 1; + totalMemory = totalMemory + nodeCount * 2; + totalStorage = totalStorage + nodeCount * 50; + monthPrice = monthPrice + nodeCount * 12; + } + if (clusterPlan.size == 4 && clusterPlan.type == 'Linode') { + totalCpu = totalCpu + nodeCount * 2; + totalMemory = totalMemory + nodeCount * 4; + totalStorage = totalStorage + nodeCount * 80; + monthPrice = monthPrice + nodeCount * 24; + } + if (clusterPlan.size == 4 && clusterPlan.type == 'Dedicated') { + totalCpu = totalCpu + nodeCount * 2; + totalMemory = totalMemory + nodeCount * 4; + totalStorage = totalStorage + nodeCount * 80; + monthPrice = monthPrice + nodeCount * 36; + } }); + // $60.00/month for enabling HA control plane + const totalPrice = monthPrice + 60; // Create LKE cluster. cy.get('[data-testid="kube-checkout-bar"]') @@ -184,6 +212,15 @@ describe('LKE Cluster Creation', () => { const similarNodePoolCount = getSimilarPlans(clusterPlan, clusterPlans) .length; + //Confirm that the cluster created with the expected parameters. + cy.findAllByText(`${clusterRegion.label}`).should('be.visible'); + cy.findAllByText(`${totalCpu} CPU Cores`).should('be.visible'); + cy.findAllByText(`${totalMemory} GB RAM`).should('be.visible'); + cy.findAllByText(`${totalStorage} GB Storage`).should('be.visible'); + cy.findAllByText(`$${totalPrice}.00/month`).should('be.visible'); + cy.contains('Kubernetes API Endpoint').should('be.visible'); + cy.contains('linodelke.net:443').should('be.visible'); + cy.findAllByText(nodePoolLabel, { selector: 'h2' }) .should('have.length', similarNodePoolCount) .first() @@ -266,7 +303,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/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 9d89090ff43..e576ff2ffcd 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -4,6 +4,7 @@ import { kubeLinodeFactory, linodeFactory, } from 'src/factories'; +import { extendType } from 'src/utilities/extendType'; import { latestKubernetesVersion } from 'support/constants/lke'; import { mockGetCluster, @@ -785,6 +786,7 @@ describe('LKE cluster updates for DC-specific prices', () => { */ it('can resize pools with DC-specific prices', () => { const dcSpecificPricingRegion = getRegionById('us-east'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, @@ -796,7 +798,7 @@ describe('LKE cluster updates for DC-specific prices', () => { const mockNodePoolResized = nodePoolFactory.build({ count: 3, - type: dcPricingMockLinodeTypes[0].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(3), }); @@ -812,19 +814,19 @@ describe('LKE cluster updates for DC-specific prices', () => { id: node.instance_id ?? undefined, ipv4: [randomIp()], region: dcSpecificPricingRegion.id, - type: dcPricingMockLinodeTypes[0].id, + type: mockPlanType.id, }); } ); - const mockNodePoolDrawerTitle = 'Resize Pool: Linode 0 GB Plan'; + const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`; mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( 'getNodePools' ); mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetLinodeType(dcPricingMockLinodeTypes[0]).as('getLinodeType'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetKubernetesVersions().as('getVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -938,15 +940,17 @@ describe('LKE cluster updates for DC-specific prices', () => { }, }); + const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); + const mockNewNodePool = nodePoolFactory.build({ count: 2, - type: dcPricingMockLinodeTypes[0].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(2), }); const mockNodePool = nodePoolFactory.build({ count: 1, - type: dcPricingMockLinodeTypes[0].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(1), }); @@ -954,7 +958,7 @@ describe('LKE cluster updates for DC-specific prices', () => { mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); mockGetKubernetesVersions().as('getVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); - mockGetLinodeType(dcPricingMockLinodeTypes[0]).as('getLinodeType'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetLinodeTypes(dcPricingMockLinodeTypes); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -963,7 +967,9 @@ describe('LKE cluster updates for DC-specific prices', () => { cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); // Assert that initial node pool is shown on the page. - cy.findByText('Linode 0 GB', { selector: 'h2' }).should('be.visible'); + cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( + 'be.visible' + ); // Confirm total price is listed in Kube Specs. cy.findByText('$14.40/month').should('be.visible'); @@ -987,7 +993,7 @@ describe('LKE cluster updates for DC-specific prices', () => { .should('be.visible') .should('be.enabled') .click(); - cy.findByText('Linode 0 GB') + cy.findByText(mockPlanType.formattedLabel) .should('be.visible') .closest('tr') .within(() => { @@ -1024,6 +1030,7 @@ describe('LKE cluster updates for DC-specific prices', () => { */ it('can resize pools with region prices of $0', () => { const dcSpecificPricingRegion = getRegionById('us-southeast'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, @@ -1035,7 +1042,7 @@ describe('LKE cluster updates for DC-specific prices', () => { const mockNodePoolResized = nodePoolFactory.build({ count: 3, - type: dcPricingMockLinodeTypes[2].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(3), }); @@ -1051,19 +1058,19 @@ describe('LKE cluster updates for DC-specific prices', () => { id: node.instance_id ?? undefined, ipv4: [randomIp()], region: dcSpecificPricingRegion.id, - type: dcPricingMockLinodeTypes[2].id, + type: mockPlanType.id, }); } ); - const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan'; + const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`; mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( 'getNodePools' ); mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetKubernetesVersions().as('getVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -1160,6 +1167,8 @@ describe('LKE cluster updates for DC-specific prices', () => { it('can add node pools with region prices of $0', () => { const dcSpecificPricingRegion = getRegionById('us-southeast'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); + const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, region: dcSpecificPricingRegion.id, @@ -1170,13 +1179,13 @@ describe('LKE cluster updates for DC-specific prices', () => { const mockNewNodePool = nodePoolFactory.build({ count: 2, - type: dcPricingMockLinodeTypes[2].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(2), }); const mockNodePool = nodePoolFactory.build({ count: 1, - type: dcPricingMockLinodeTypes[2].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(1), }); @@ -1184,7 +1193,7 @@ describe('LKE cluster updates for DC-specific prices', () => { mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); mockGetKubernetesVersions().as('getVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); - mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetLinodeTypes(dcPricingMockLinodeTypes); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -1193,7 +1202,9 @@ describe('LKE cluster updates for DC-specific prices', () => { cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); // Assert that initial node pool is shown on the page. - cy.findByText('Linode 2 GB', { selector: 'h2' }).should('be.visible'); + cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( + 'be.visible' + ); // Confirm total price of $0 is listed in Kube Specs. cy.findByText('$0.00/month').should('be.visible'); 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 01694c1fa4a..f973a37651d 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -1,6 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { Linode } from '@linode/api-v4'; -import { createLinode } from '@linode/api-v4'; import { linodeFactory, linodeBackupsFactory, @@ -27,6 +26,7 @@ import { randomLabel } from 'support/util/random'; import { dcPricingMockLinodeTypesForBackups } from 'support/constants/dc-specific-pricing'; import { chooseRegion } from 'support/util/regions'; import { expectManagedDisabled } from 'support/api/managed'; +import { createTestLinode } from 'support/util/linodes'; authenticate(); describe('linode backups', () => { @@ -53,52 +53,53 @@ describe('linode backups', () => { booted: false, }); - cy.defer(createLinode(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(createLinode(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 3e4901afb86..cd9a78f2258 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -1,4 +1,3 @@ -import { Linode, createLinode } from '@linode/api-v4'; import { linodeFactory, createLinodeRequestFactory } from '@src/factories'; import { interceptCloneLinode, @@ -18,6 +17,8 @@ import { chooseRegion, getRegionById } from 'support/util/regions'; import { randomLabel } from 'support/util/random'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; +import type { Linode } from '@linode/api-v4'; /** * Returns the Cloud Manager URL to clone a given Linode. @@ -32,6 +33,9 @@ const getLinodeCloneUrl = (linode: Linode): string => { return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; +/* Timeout after 4 minutes while waiting for clone. */ +const CLONE_TIMEOUT = 240_000; + authenticate(); describe('clone linode', () => { before(() => { @@ -43,20 +47,23 @@ describe('clone linode', () => { * - Confirms that Linode can be cloned successfully. */ it('can clone a Linode from Linode details page', () => { - const linodeRegion = chooseRegion(); + const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); 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(createLinode(linodePayload)).then((linode: Linode) => { - const linodeRegion = getRegionById(linodePayload.region!); - + // 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) => { interceptCloneLinode(linode.id).as('cloneLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); @@ -99,7 +106,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/create-linode-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts new file mode 100644 index 00000000000..bf7774cc0bd --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts @@ -0,0 +1,77 @@ +/** + * @file Smoke tests for Linode Create flow across common mobile viewport sizes. + */ + +import { linodeFactory } from 'src/factories'; +import { MOBILE_VIEWPORTS } from 'support/constants/environment'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { ui } from 'support/ui'; +import { mockCreateLinode } from 'support/intercepts/linodes'; + +describe('Linode create mobile smoke', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + MOBILE_VIEWPORTS.forEach((viewport) => { + /* + * - Confirms Linode create flow can be completed on common mobile screen sizes + * - Creates a basic Nanode and confirms interactions succeed and outgoing request contains expected data. + */ + it(`can create Linode (${viewport.label})`, () => { + const mockLinodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + + cy.viewport(viewport.width, viewport.height); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectPlanCard('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Nanode 1 GB').should('be.visible'); + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(mockLinodeRegion.label).should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestBody = xhr.request.body; + + expect(requestBody['image']).to.equal('linode/debian11'); + expect(requestBody['label']).to.equal(mockLinode.label); + expect(requestBody['region']).to.equal(mockLinodeRegion.id); + expect(requestBody['type']).to.equal('g6-nanode-1'); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts new file mode 100644 index 00000000000..07a04310671 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -0,0 +1,186 @@ +import { + accountUserFactory, + linodeFactory, + sshKeyFactory, +} from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { linodeCreatePage } from 'support/ui/pages'; +import { ui } from 'support/ui'; +import { mockCreateSSHKey } from 'support/intercepts/profile'; + +describe('Create Linode with SSH Key', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow when creating a Linode with an authorized SSH key. + * - Confirms that existing SSH keys are listed on page and can be selected. + * - Confirms that outgoing Linode create API request contains authorized user for chosen key. + */ + it('can add an existing SSH key during Linode create flow', () => { + const linodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSshKey = sshKeyFactory.build({ + label: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + username: randomLabel(), + ssh_keys: [mockSshKey.label], + }); + + mockGetUsers([mockUser]); + mockGetUser(mockUser); + mockCreateLinode(mockLinode).as('createLinode'); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that SSH key is listed, then select it. + cy.findByText(mockSshKey.label) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockUser.username); + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).click(); + }); + + // Click "Create Linode" button and confirm outgoing request data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that outgoing Linode create request contains authorized user that + // corresponds to the selected SSH key. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['authorized_users'][0]).to.equal(mockUser.username); + }); + }); + + /* + * - Confirms UI flow when creating and selecting an SSH key during Linode create flow. + * - Confirms that new SSH key is automatically shown in Linode create page. + * - Confirms that outgoing Linode create API request contains authorized user for new key. + */ + it('can add a new SSH key during Linode create flow', () => { + const linodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSshKey = sshKeyFactory.build({ + label: randomLabel(), + ssh_key: `ssh-rsa ${randomString(16)}`, + }); + + const mockUser = accountUserFactory.build({ + username: randomLabel(), + ssh_keys: [], + }); + + const mockUserWithKey = { + ...mockUser, + ssh_keys: [mockSshKey.label], + }; + + mockGetUser(mockUser); + mockGetUsers([mockUser]); + mockCreateLinode(mockLinode).as('createLinode'); + mockCreateSSHKey(mockSshKey).as('createSSHKey'); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that no SSH keys are listed for the mocked user. + cy.findByText(mockUser.username) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('None').should('be.visible'); + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).should( + 'be.disabled' + ); + }); + + // Click "Add an SSH Key" and enter a label and the public key, then submit. + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetUsers([mockUserWithKey]).as('refetchUsers'); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + cy.findByLabelText('Label').type(mockSshKey.label); + cy.findByLabelText('SSH Public Key').type(mockSshKey.ssh_key); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@createSSHKey', '@refetchUsers']); + + // Confirm that the new SSH key is listed, and select it to be added to the Linode. + cy.findByText(mockSshKey.label) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).click(); + }); + + // Click "Create Linode" button and confirm outgoing request data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that outgoing Linode create request contains authorized user that + // corresponds to the new SSH key. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['authorized_users'][0]).to.equal(mockUser.username); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts new file mode 100644 index 00000000000..21096becdf3 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -0,0 +1,149 @@ +import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with user data', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with cloud-init user data specified. + * - Confirms that outgoing API request contains expected user data payload. + */ + it('can specify user data during Linode Create flow', () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Metadata'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + const userDataFixturePath = 'user-data/user-data-config-basic.yml'; + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + // Fill out create form, selecting a region and image that both have + // cloud-init capabilities. + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Expand "Add User Data" accordion and enter user data config. + ui.accordionHeading + .findByTitle('Add User Data') + .should('be.visible') + .click(); + + cy.fixture(userDataFixturePath).then((userDataContents) => { + ui.accordion.findByTitle('Add User Data').within(() => { + cy.findByText('User Data').click(); + cy.focused().type(userDataContents); + }); + + // Submit form to create Linode and confirm that outgoing API request + // contains expected user data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['metadata']['user_data']).to.equal( + btoa(userDataContents) + ); + }); + }); + }); + + /* + * - Confirms UI flow when creating a Linode using a region that lacks cloud-init capability. + * - Confirms that "Add User Data" section is hidden when selected region lacks cloud-init. + */ + it('cannot specify user data when selected region does not support it', () => { + const mockLinodeRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + mockGetRegions([mockLinodeRegion]); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + + // Confirm that "Add User Data" section is hidden when selected region + // lacks cloud-init capability. + cy.findByText('Add User Data').should('not.exist'); + }); + + /* + * - Confirms UI flow when creating a Linode using an image that lacks cloud-init capability. + * - Confirms that "Add User Data" section is hidden when selected image lacks cloud-init. + */ + it('cannot specify user data when selected image does not support it', () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Metadata'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + const mockImage = imageFactory.build({ + id: `linode/${randomLabel()}`, + label: randomLabel(), + created_by: 'linode', + is_public: true, + vendor: 'Debian', + // `cloud-init` is omitted from Image capabilities. + capabilities: [], + }); + + mockGetImage(mockImage.id, mockImage); + mockGetAllImages([mockImage]); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage(mockImage.label); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + + // Confirm that "Add User Data" section is hidden when selected image + // lacks cloud-init capability. + cy.findByText('Add User Data').should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts new file mode 100644 index 00000000000..7b3d495de94 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -0,0 +1,240 @@ +import { linodeFactory, regionFactory, VLANFactory } from 'src/factories'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { chooseRegion } from 'support/util/regions'; +import { + randomIp, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockCreateLinode } from 'support/intercepts/linodes'; + +describe('Create Linode with VLANs', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Uses mock API data to confirm VLAN attachment UI flow during Linode create. + * - Confirms that outgoing Linode create API request contains expected data for VLAN. + * - Confirms that attached VLAN is reflected in the Linode create summary. + */ + it('can assign existing VLANs during Linode create flow', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + mockGetVLANs([mockVlan]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and select existing VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(mockVlan.label) + .should('be.visible') + .click(); + + cy.findByLabelText(/IPAM Address/) + .should('be.enabled') + .type(mockVlan.cidr_block); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal( + mockVlan.cidr_block + ); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Uses mock API data to confirm VLAN creation and attachment UI flow during Linode create. + * - Confirms that outgoing Linode create API request contains expected data for new VLAN. + * - Confirms that attached VLAN is reflected in the Linode create summary. + */ + it('can assign new VLANs during Linode create flow', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + mockGetVLANs([]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and specify new VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(`Create "${mockVlan.label}"`) + .should('be.visible') + .click(); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal(''); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Uses mock API data to confirm that VLANs cannot be assigned to Linodes in regions without capability. + * - Confirms that VLAN fields are disabled before and after selecting a region. + */ + it('cannot assign VLANs in regions without capability', () => { + const availabilityNotice = + 'VLANs are currently available in select regions.'; + + const nonVlanRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const vlanRegion = regionFactory.build({ + capabilities: ['Linodes', 'Vlans'], + }); + + mockGetRegions([nonVlanRegion, vlanRegion]); + cy.visitWithLogin('/linodes/create'); + + // Expand VLAN accordion, confirm VLAN availability notice is displayed and + // that VLAN fields are disabled while no region is selected. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .within(() => { + cy.contains(availabilityNotice).should('be.visible'); + cy.findByLabelText('VLAN').should('be.disabled'); + cy.findByLabelText(/IPAM Address/).should('be.disabled'); + }); + + // Select a region that is known not to have VLAN capability. + linodeCreatePage.selectRegionById(nonVlanRegion.id); + + // Confirm that VLAN fields are still disabled. + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .within(() => { + cy.findByLabelText('VLAN').should('be.disabled'); + cy.findByLabelText(/IPAM Address/).should('be.disabled'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts new file mode 100644 index 00000000000..668c344f2de --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -0,0 +1,267 @@ +import { + linodeFactory, + regionFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreateVPC, + mockCreateVPCError, + mockGetVPC, + mockGetVPCs, +} from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { linodeCreatePage, vpcCreateDrawer } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + randomIp, + randomLabel, + randomNumber, + randomPhrase, + randomString, +} from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with VPCs', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with an existing VPC assigned using mock API data. + * - Confirms that VPC assignment is reflected in create summary section. + * - Confirms that outgoing API request contains expected VPC interface data. + */ + it('can assign existing VPCs during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [], + ipv4: `${randomIp()}/0`, + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + // + }); + + mockGetVPCs([mockVPC]).as('getVPCs'); + mockGetVPC(mockVPC).as('getVPC'); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that mocked VPC is shown in the Autocomplete, and then select it. + cy.findByText('Assign VPC').click().type(`${mockVPC.label}`); + + ui.autocompletePopper + .findByTitle(mockVPC.label) + .should('be.visible') + .click(); + + // Confirm that Subnet selection appears and select mock subnet. + cy.findByLabelText('Subnet').should('be.visible').type(mockSubnet.label); + + ui.autocompletePopper + .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) + .should('be.visible') + .click(); + + // Confirm VPC assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VPC Assigned').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedVpcInterface = requestPayload['interfaces'][0]; + + // Confirm that request payload includes VPC interface. + expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); + expect(expectedVpcInterface['ipv4']).to.be.an('object').that.is.empty; + expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); + expect(expectedVpcInterface['purpose']).to.equal('vpc'); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Confirms UI flow to create a Linode with a new VPC assigned using mock API data. + * - Creates a VPC and a subnet from within the Linode Create flow. + * - Confirms that Cloud responds gracefully when VPC create API request fails. + * - Confirms that outgoing API request contains correct VPC interface data. + */ + it('can assign new VPCs during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockErrorMessage = 'An unknown error occurred.'; + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [], + ipv4: '10.0.0.0/24', + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + description: randomPhrase(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockGetVPCs([]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.findByText('Create VPC').should('be.visible').click(); + + ui.drawer + .findByTitle('Create VPC') + .should('be.visible') + .within(() => { + vpcCreateDrawer.setLabel(mockVPC.label); + vpcCreateDrawer.setDescription(mockVPC.description); + vpcCreateDrawer.setSubnetLabel(mockSubnet.label); + vpcCreateDrawer.setSubnetIpRange(mockSubnet.ipv4!); + + // Confirm that unexpected API errors are handled gracefully upon + // failed VPC creation. + mockCreateVPCError(mockErrorMessage, 500).as('createVpc'); + vpcCreateDrawer.submit(); + + cy.wait('@createVpc'); + cy.findByText(mockErrorMessage).scrollIntoView().should('be.visible'); + + // Create VPC with successful API response mocked. + mockCreateVPC(mockVPC).as('createVpc'); + vpcCreateDrawer.submit(); + }); + + // Attempt to create Linode before selecting a VPC subnet, and confirm + // that validation error appears. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText('Subnet is required.').should('be.visible'); + + // Confirm that Subnet selection appears and select mock subnet. + cy.findByLabelText('Subnet').should('be.visible').type(mockSubnet.label); + + ui.autocompletePopper + .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) + .should('be.visible') + .click(); + + // Check box to assign public IPv4. + cy.findByText('Assign a public IPv4 address for this Linode') + .should('be.visible') + .click(); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedVpcInterface = requestPayload['interfaces'][0]; + + // Confirm that request payload includes VPC interface. + expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); + expect(expectedVpcInterface['ipv4']).to.deep.equal({ nat_1_1: 'any' }); + expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); + expect(expectedVpcInterface['purpose']).to.equal('vpc'); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Confirms UI flow when attempting to assign VPC to Linode in region without capability. + * - Confirms that VPCs selection is disabled. + * - Confirms that notice text is present to explain that VPCs are unavailable. + */ + it('cannot assign VPCs to Linodes in regions without VPC capability', () => { + const mockRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const vpcNotAvailableMessage = + 'VPC is not available in the selected region.'; + + mockGetRegions([mockRegion]); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectRegionById(mockRegion.id); + + cy.findByLabelText('Assign VPC') + .scrollIntoView() + .should('be.visible') + .should('be.disabled'); + + cy.findByText(vpcNotAvailableMessage).should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index a7d7a8c2fb9..be6fad33848 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -1,514 +1,146 @@ -import { - containsVisible, - fbtClick, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; +/** + * @file Linode Create end-to-end tests. + */ + import { ui } from 'support/ui'; -import { apiMatcher } from 'support/util/intercepts'; -import { randomString, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { getRegionById } from 'support/util/regions'; -import { - subnetFactory, - vpcFactory, - linodeFactory, - linodeConfigFactory, - regionFactory, - VLANFactory, - LinodeConfigInterfaceFactory, - LinodeConfigInterfaceFactoryWithVPC, -} from '@src/factories'; -import { authenticate } from 'support/api/authentication'; +import { randomLabel, randomString } from 'support/util/random'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { cleanUp } from 'support/util/cleanup'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { - dcPricingPlanPlaceholder, - dcPricingMockLinodeTypes, - dcPricingDocsLabel, - dcPricingDocsUrl, -} from 'support/constants/dc-specific-pricing'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { - mockCreateLinode, - mockGetLinodeType, - mockGetLinodeTypes, - mockGetLinodeDisks, - mockGetLinodeVolumes, -} from 'support/intercepts/linodes'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { linodeCreatePage } from 'support/ui/pages'; +import { authenticate } from 'support/api/authentication'; import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; +import { interceptCreateLinode } from 'support/intercepts/linodes'; import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { interceptGetProfile } from 'support/intercepts/profile'; -import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; - -const mockRegions: Region[] = [ - regionFactory.build({ - capabilities: ['Linodes'], - country: 'uk', - id: 'eu-west', - label: 'London, UK', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'sg', - id: 'ap-south', - label: 'Singapore, SG', - }), - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-east', - label: 'Newark, NJ', - }), - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-central', - label: 'Dallas, TX', - }), -]; +let username: string; authenticate(); -describe('create linode', () => { +describe('Create Linode', () => { before(() => { cleanUp('linodes'); }); - /* - * Region select test. - * - * TODO: Cypress - * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 - * - * - Confirms that region select dropdown is visible and interactive. - * - Confirms that region select dropdown is populated with expected regions. - * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. - * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. - */ - it('region select', () => { - mockGetRegions(mockRegions).as('getRegions'); - - cy.visitWithLogin('linodes/create'); - - cy.wait(['@getRegions']); - - // Confirm that region select dropdown is visible and interactive. - ui.regionSelect.find().click(); - cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); - - // Confirm that region select dropdown are grouped by region, - // sorted alphabetically, with North America first. - cy.get('.MuiAutocomplete-groupLabel') - .should('have.length', 3) - .should((group) => { - expect(group[0]).to.contain('North America'); - expect(group[1]).to.contain('Asia'); - expect(group[2]).to.contain('Europe'); - }); - - // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. - cy.get('[data-qa-option]').should('exist').should('have.length', 4); - mockRegions.forEach((region) => { - cy.get('[data-qa-option]').contains(region.label); + // Enable the `linodeCreateRefactor` feature flag. + // TODO Delete these mocks once `linodeCreateRefactor` feature flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), }); - - // Select an option - cy.findByTestId('eu-west').click(); - // Confirm the popper is closed - cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); - // Confirm that the selected region is displayed in the input field. - cy.get('[data-testid="textfield-input"]').should( - 'have.value', - 'London, UK (eu-west)' - ); - - // Confirm that selecting a valid region updates the Plan Selection panel. - expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); - }); - - it('creates a nanode', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.get('[data-qa-deploy-linode]'); - cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"]'); - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); - containsVisible('PROVISIONING'); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it('creates a linode via CLI', () => { - const linodeLabel = randomLabel(); - const linodePass = randomString(32); - const linodeRegion = chooseRegion(); - - cy.visitWithLogin('/linodes/create'); - - ui.regionSelect.find().click(); - ui.autocompletePopper - .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) - .should('exist') - .click(); - - cy.get('[id="g6-dedicated-2"]').click(); - - cy.findByLabelText('Linode Label') - .should('be.visible') - .should('be.enabled') - .clear() - .type(linodeLabel); - - cy.findByLabelText('Root Password') - .should('be.visible') - .should('be.enabled') - .type(linodePass); - - ui.button - .findByTitle('Create using command line') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Create Linode') - .should('be.visible') - .within(() => { - // Switch to cURL view if necessary. - cy.findByText('cURL') - .should('be.visible') - .should('have.attr', 'data-selected'); - - // Confirm that cURL command has expected details. - [ - `"region": "${linodeRegion.id}"`, - `"type": "g6-dedicated-2"`, - `"label": "${linodeLabel}"`, - `"root_pass": "${linodePass}"`, - '"booted": true', - ].forEach((line: string) => - cy.findByText(line, { exact: false }).should('be.visible') - ); - - cy.findByText('Linode CLI').should('be.visible').click(); - - [ - `--region ${linodeRegion.id}`, - '--type g6-dedicated-2', - `--label ${linodeLabel}`, - `--root_pass ${linodePass}`, - `--booted true`, - ].forEach((line: string) => cy.contains(line).should('be.visible')); - - ui.buttonGroup - .findButtonByTitle('Close') - .should('be.visible') - .should('be.enabled') - .click(); - }); + mockGetFeatureFlagClientstream(); }); /* - * - Confirms DC-specific pricing UI flow works as expected during Linode creation. - * - Confirms that pricing docs link is shown in "Region" section. - * - Confirms that backups pricing is correct when selecting a region with a different price structure. + * End-to-end tests to create Linodes for each available plan type. */ - it('shows DC-specific pricing information during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const initialRegion = getRegionById('us-west'); - const newRegion = getRegionById('us-east'); - - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: initialRegion.id, - type: dcPricingMockLinodeTypes[0].id, - }); - - const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( - (linodeType) => linodeType.id === newRegion.id - )!; - const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === newRegion.id - )!; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes']); - - mockCreateLinode(mockLinode).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - getClick('[data-qa-deploy-linode]'); - - // A message is shown to instruct users to select a region in order to view plans and prices - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - 'Plan is required.' - ); - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - dcPricingPlanPlaceholder - ); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); - }); - // Confirm that the checkout summary at the bottom of the page reflects the correct price. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - // Confirm there is a docs link to the pricing page. - cy.findByText(dcPricingDocsLabel) - .should('be.visible') - .should('have.attr', 'href', dcPricingDocsUrl); - - ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); + describe('End-to-end', () => { + // Run an end-to-end test to create a basic Linode for each plan type described below. + describe('By plan type', () => { + [ + { + planType: 'Shared CPU', + planLabel: 'Nanode 1 GB', + planId: 'g6-nanode-1', + }, + { + planType: 'Dedicated CPU', + planLabel: 'Dedicated 4 GB', + planId: 'g6-dedicated-2', + }, + { + planType: 'High Memory', + planLabel: 'Linode 24 GB', + planId: 'g7-highmem-1', + }, + { + planType: 'Premium CPU', + planLabel: 'Premium 4 GB', + planId: 'g7-premium-2', + }, + // TODO Include GPU plan types. + ].forEach((planConfig) => { + /* + * - Parameterized end-to-end test to create a Linode for each plan type. + * - Confirms that a Linode of the given plan type can be deployed. + */ + it(`creates a ${planConfig.planType} Linode`, () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Premium Plans', 'Vlans'], + }); + const linodeLabel = randomLabel(); + + interceptGetProfile().as('getProfile'); + + interceptCreateLinode().as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Set Linode label, distribution, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan( + planConfig.planType, + planConfig.planLabel + ); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm information in summary is shown as expected. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(linodeRegion.label).should('be.visible'); + cy.findByText(planConfig.planLabel).should('be.visible'); + }); + + // Create Linode and confirm it's provisioned as expected. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const responsePayload = xhr.response?.body; + + // Confirm that API request and response contain expected data + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['region']).to.equal(linodeRegion.id); + expect(requestPayload['type']).to.equal(planConfig.planId); + + expect(responsePayload['label']).to.equal(linodeLabel); + expect(responsePayload['region']).to.equal(linodeRegion.id); + expect(responsePayload['type']).to.equal(planConfig.planId); + + // Confirm that Cloud redirects to details page + cy.url().should('endWith', `/linodes/${responsePayload['id']}`); + }); + + cy.wait('@getProfile').then((xhr) => { + username = xhr.response?.body.username; + }); + + // TODO Confirm whether or not toast notification should appear here. + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + + // confirm that LISH Console via SSH section is correct + cy.contains('LISH Console via SSH') + .should('be.visible') + .closest('tr') + .within(() => { + cy.contains( + `ssh -t ${username}@lish-${linodeRegion.id}.linode.com ${linodeLabel}` + ).should('be.visible'); + }); + }); }); - // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { - const region: Region = getRegionById('us-southeast'); - const mockNoVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes'], - }); - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockNoVPCRegion]).as('getRegions'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible( - 'Allow Linode to communicate in an isolated environment.' - ); - // Helper text appears if VPC is not available in selected region. - containsVisible('VPC is not available in the selected region.'); - }); - }); - - it('assigns a VPC to the linode during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const region: Region = getRegionById('us-southeast'); - const diskLabel: string = 'Debian 10 Disk'; - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: region.id, - type: dcPricingMockLinodeTypes[0].id, - }); - const mockVLANs: VLAN[] = VLANFactory.buildList(2); - const mockSubnet = subnetFactory.build({ - id: randomNumber(2), - label: randomLabel(), - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - region: 'us-southeast', - subnets: [mockSubnet], - }); - const mockVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes', 'VPCs', 'Vlans'], - }); - const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ - ipam_address: null, - purpose: 'public', - }); - const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); - const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - purpose: 'vpc', - active: true, - }); - const mockConfig: Config = linodeConfigFactory.build({ - id: randomNumber(), - interfaces: [ - // The order of this array is significant. Index 0 (eth0) should be public. - mockPublicConfigInterface, - mockVlanConfigInterface, - mockVpcConfigInterface, - ], - }); - const mockDisks: Disk[] = [ - { - id: 44311273, - status: 'ready', - label: diskLabel, - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', - filesystem: 'ext4', - size: 81408, - }, - { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', - filesystem: 'swap', - size: 512, - }, - ]; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockVPCRegion]).as('getRegions'); - - mockGetVLANs(mockVLANs); - mockGetVPC(mockVPC).as('getVPC'); - mockGetVPCs([mockVPC]).as('getVPCs'); - mockCreateLinode(mockLinode).as('linodeCreated'); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); - mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); - mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait([ - '@getLinodeTypes', - '@getClientStream', - '@getFeatureFlags', - '@getVPCs', - ]); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present, and the VPC in the same region of - // the linode can be selected. - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible('Assign this Linode to an existing VPC.'); - // select VPC - cy.get('[data-qa-enhanced-select="None"]') - .should('be.visible') - .click() - .type(`${mockVPC.label}{enter}`); - // select subnet - cy.findByText('Select Subnet') - .should('be.visible') - .click() - .type(`${mockSubnet.label}{enter}`); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - - fbtClick('Configurations'); - //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); - - // Confirm that VLAN and VPC have been assigned. - cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible(`${mockConfig.label} ā€“ GRUB 2`); - containsVisible('eth0 ā€“ Public Internet'); - containsVisible(`eth2 ā€“ VPC: ${mockVPC.label}`); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts new file mode 100644 index 00000000000..cf92b39a21b --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -0,0 +1,607 @@ +/** + * @file Integration tests and end-to-end tests for legacy Linode Create flow. + */ +// TODO Delete this test file when `linodeCreateRefactor` feature flag is retired. +// Move out any tests (e.g. region select test) for flows that aren't covered by new tests in the meantime. + +import { + containsVisible, + fbtClick, + fbtVisible, + getClick, + getVisible, +} from 'support/helpers'; +import { ui } from 'support/ui'; +import { randomString, randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { getRegionById } from 'support/util/regions'; +import { + accountFactory, + subnetFactory, + vpcFactory, + linodeFactory, + linodeConfigFactory, + regionFactory, + VLANFactory, + LinodeConfigInterfaceFactory, + LinodeConfigInterfaceFactoryWithVPC, +} from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { cleanUp } from 'support/util/cleanup'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + dcPricingPlanPlaceholder, + dcPricingMockLinodeTypes, + dcPricingDocsLabel, + dcPricingDocsUrl, +} from 'support/constants/dc-specific-pricing'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { + interceptCreateLinode, + mockCreateLinode, + mockGetLinodeType, + mockGetLinodeTypes, + mockGetLinodeDisks, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + checkboxTestId, + headerTestId, +} from 'src/components/DiskEncryption/DiskEncryption'; + +import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; + +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'uk', + id: 'eu-west', + label: 'London, UK', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-east', + label: 'Newark, NJ', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + }), +]; + +authenticate(); +describe('create linode', () => { + before(() => { + cleanUp('linodes'); + }); + + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * Region select test. + * + * TODO: Cypress + * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 + * + * - Confirms that region select dropdown is visible and interactive. + * - Confirms that region select dropdown is populated with expected regions. + * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. + * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. + */ + it('region select', () => { + mockGetRegions(mockRegions).as('getRegions'); + + cy.visitWithLogin('linodes/create'); + + cy.wait(['@getRegions']); + + // Confirm that region select dropdown is visible and interactive. + ui.regionSelect.find().click(); + cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); + + // Confirm that region select dropdown are grouped by region, + // sorted alphabetically, with North America first. + cy.get('.MuiAutocomplete-groupLabel') + .should('have.length', 3) + .should((group) => { + expect(group[0]).to.contain('North America'); + expect(group[1]).to.contain('Asia'); + expect(group[2]).to.contain('Europe'); + }); + + // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. + cy.get('[data-qa-option]').should('exist').should('have.length', 4); + mockRegions.forEach((region) => { + cy.get('[data-qa-option]').contains(region.label); + }); + + // Select an option + cy.findByTestId('eu-west').click(); + // Confirm the popper is closed + cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); + // Confirm that the selected region is displayed in the input field. + cy.get('[data-testid="textfield-input"]').should( + 'have.value', + 'London, UK (eu-west)' + ); + + // Confirm that selecting a valid region updates the Plan Selection panel. + expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); + }); + + it('creates a nanode', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.get('[data-qa-deploy-linode]'); + interceptCreateLinode().as('linodeCreated'); + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + ui.regionSelect.find().click(); + ui.regionSelect + .findItemByRegionLabel( + chooseRegion({ capabilities: ['Vlans', 'Linodes'] }).label + ) + .click(); + fbtClick('Shared CPU'); + getClick('[id="g6-nanode-1"]'); + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); + containsVisible('PROVISIONING'); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + }); + + it('creates a linode via CLI', () => { + const linodeLabel = randomLabel(); + const linodePass = randomString(32); + const linodeRegion = chooseRegion(); + + cy.visitWithLogin('/linodes/create'); + + ui.regionSelect.find().click(); + ui.autocompletePopper + .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) + .should('exist') + .click(); + + cy.get('[id="g6-dedicated-2"]').click(); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .should('be.enabled') + .clear() + .type(linodeLabel); + + cy.findByLabelText('Root Password') + .should('be.visible') + .should('be.enabled') + .type(linodePass); + + ui.button + .findByTitle('Create using command line') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + // Switch to cURL view if necessary. + cy.findByText('cURL') + .should('be.visible') + .should('have.attr', 'data-selected'); + + // Confirm that cURL command has expected details. + [ + `"region": "${linodeRegion.id}"`, + `"type": "g6-dedicated-2"`, + `"label": "${linodeLabel}"`, + `"root_pass": "${linodePass}"`, + '"booted": true', + ].forEach((line: string) => + cy.findByText(line, { exact: false }).should('be.visible') + ); + + cy.findByText('Linode CLI').should('be.visible').click(); + + [ + `--region ${linodeRegion.id}`, + '--type g6-dedicated-2', + `--label ${linodeLabel}`, + `--root_pass ${linodePass}`, + `--booted true`, + ].forEach((line: string) => cy.contains(line).should('be.visible')); + + ui.buttonGroup + .findButtonByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /* + * - Confirms DC-specific pricing UI flow works as expected during Linode creation. + * - Confirms that pricing docs link is shown in "Region" section. + * - Confirms that backups pricing is correct when selecting a region with a different price structure. + */ + it('shows DC-specific pricing information during create flow', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + const initialRegion = getRegionById('us-west'); + const newRegion = getRegionById('us-east'); + + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: initialRegion.id, + type: dcPricingMockLinodeTypes[0].id, + }); + + const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( + (linodeType) => linodeType.id === newRegion.id + )!; + const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === newRegion.id + )!; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes']); + + mockCreateLinode(mockLinode).as('linodeCreated'); + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + getClick('[data-qa-deploy-linode]'); + + // A message is shown to instruct users to select a region in order to view plans and prices + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + 'Plan is required.' + ); + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + dcPricingPlanPlaceholder + ); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirm that the checkout summary at the bottom of the page reflects the correct price. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + // Confirm there is a docs link to the pricing page. + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); + + ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + }); + + it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { + const region: Region = getRegionById('us-southeast'); + const mockNoVPCRegion = regionFactory.build({ + id: region.id, + label: region.label, + capabilities: ['Linodes'], + }); + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetRegions([mockNoVPCRegion]).as('getRegions'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click().type(`${region.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + + // the "VPC" section is present + getVisible('[data-testid="vpc-panel"]').within(() => { + containsVisible( + 'Allow Linode to communicate in an isolated environment.' + ); + // Helper text appears if VPC is not available in selected region. + containsVisible('VPC is not available in the selected region.'); + }); + }); + + it('assigns a VPC to the linode during create flow', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + const region: Region = getRegionById('us-southeast'); + const diskLabel: string = 'Debian 10 Disk'; + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: region.id, + type: dcPricingMockLinodeTypes[0].id, + }); + const mockVLANs: VLAN[] = VLANFactory.buildList(2); + const mockSubnet = subnetFactory.build({ + id: randomNumber(2), + label: randomLabel(), + }); + const mockVPC = vpcFactory.build({ + id: randomNumber(), + region: 'us-southeast', + subnets: [mockSubnet], + }); + const mockVPCRegion = regionFactory.build({ + id: region.id, + label: region.label, + capabilities: ['Linodes', 'VPCs', 'Vlans'], + }); + const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + ipam_address: null, + purpose: 'public', + }); + const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); + const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + purpose: 'vpc', + active: true, + }); + const mockConfig: Config = linodeConfigFactory.build({ + id: randomNumber(), + interfaces: [ + // The order of this array is significant. Index 0 (eth0) should be public. + mockPublicConfigInterface, + mockVlanConfigInterface, + mockVpcConfigInterface, + ], + }); + const mockDisks: Disk[] = [ + { + id: 44311273, + status: 'ready', + label: diskLabel, + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:30', + filesystem: 'ext4', + size: 81408, + }, + { + id: 44311274, + status: 'ready', + label: '512 MB Swap Image', + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:31', + filesystem: 'swap', + size: 512, + }, + ]; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetRegions([mockVPCRegion]).as('getRegions'); + + mockGetVLANs(mockVLANs); + mockGetVPC(mockVPC).as('getVPC'); + mockGetVPCs([mockVPC]).as('getVPCs'); + mockCreateLinode(mockLinode).as('linodeCreated'); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); + mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); + mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait([ + '@getLinodeTypes', + '@getClientStream', + '@getFeatureFlags', + '@getVPCs', + ]); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click().type(`${region.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + + // the "VPC" section is present, and the VPC in the same region of + // the linode can be selected. + getVisible('[data-testid="vpc-panel"]').within(() => { + containsVisible('Assign this Linode to an existing VPC.'); + // select VPC + cy.get('[data-qa-enhanced-select="None"]') + .should('be.visible') + .click() + .type(`${mockVPC.label}{enter}`); + // select subnet + cy.findByText('Select Subnet') + .should('be.visible') + .click() + .type(`${mockSubnet.label}{enter}`); + }); + + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + + fbtClick('Configurations'); + //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); + + // Confirm that VLAN and VPC have been assigned. + cy.findByLabelText('List of Configurations').within(() => { + cy.get('tr').should('have.length', 2); + containsVisible(`${mockConfig.label} ā€“ GRUB 2`); + containsVisible('eth0 ā€“ Public Internet'); + containsVisible(`eth2 ā€“ VPC: ${mockVPC.label}`); + }); + }); + + it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); + }); + + it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegion = regionFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegionWithoutDiskEncryption = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid="${headerTestId}"]`).should('exist'); + + // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected + ui.regionSelect.find().click(); + ui.select + .findItemByText( + `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` + ) + .click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); + + ui.regionSelect.find().click(); + ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 51b32e4ca54..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,7 +242,11 @@ describe('Linode Config management', () => { */ it('Boots a config', () => { cy.defer( - createLinodeAndGetConfig(null, { waitForBoot: true }), + () => + 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); @@ -279,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(null, { waitForBoot: true }), - createTestLinode(), + 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'); @@ -367,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 41c59858748..6cd87caaf14 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { Linode } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; -import { createLinode } from 'support/api/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { containsVisible, fbtClick, fbtVisible } from 'support/helpers'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -103,8 +103,8 @@ describe('linode storage tab', () => { }); it('try to delete in use disk', () => { - const diskName = 'Debian 10 Disk'; - createLinode().then((linode) => { + const diskName = 'Debian 11 Disk'; + 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'; - createLinode({ image: null }).then((linode: 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'; - createLinode({ 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'; - createLinode({ 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/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index 86838505220..5a6480c18ec 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -44,6 +44,11 @@ const mockDedicatedLinodeTypes = [ label: 'dedicated-3', class: 'dedicated', }), + linodeTypeFactory.build({ + id: 'dedicated-4', + label: 'dedicated-4', + class: 'dedicated', + }), ]; const mockSharedLinodeTypes = [ @@ -98,11 +103,21 @@ const mockRegionAvailability = [ available: false, region: 'us-east', }), + regionAvailabilityFactory.build({ + plan: 'dedicated-4', + available: false, + region: 'us-east', + }), regionAvailabilityFactory.build({ plan: 'highmem-1', available: false, region: 'us-east', }), + regionAvailabilityFactory.build({ + plan: 'shared-3', + available: false, + region: 'us-east', + }), ]; const linodePlansPanel = '[data-qa-tp="Linode Plan"]'; @@ -110,7 +125,7 @@ const k8PlansPanel = '[data-qa-tp="Add Node Pools"]'; const planSelectionTable = 'List of Linode Plans'; const notices = { - limitedAvailability: '[data-testid="disabled-plan-tooltip"]', + limitedAvailability: '[data-testid="limited-availability-banner"]', unavailable: '[data-testid="notice-error"]', }; @@ -136,15 +151,15 @@ describe('displays linode plans panel based on availability', () => { // Dedicated CPU tab // Should be selected/open by default // Should have the limited availability notice - // Should contain 4 plans (5 rows including the header row) - // Should have 2 plans disabled - // Should have tooltips for the disabled plans (not more than half disabled plans in the panel) + // Should contain 5 plans (6 rows including the header row) + // Should have 3 plans disabled + // Should not have tooltips for the disabled plans (more than half disabled plans in the panel) cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); cy.get(notices.limitedAvailability).should('be.visible'); cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 5); + cy.findAllByRole('row').should('have.length', 6); cy.get('[id="dedicated-1"]').should('be.enabled'); cy.get('[id="dedicated-2"]').should('be.enabled'); cy.get( @@ -152,15 +167,15 @@ describe('displays linode plans panel based on availability', () => { ); cy.get('[id="dedicated-3"]').should('be.disabled'); cy.get('[id="g6-dedicated-64"]').should('be.disabled'); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 2); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); // Shared CPU tab // Should have no notices // Should contain 3 plans (4 rows including the header row) - // Should have 0 disabled plan - // Should have no tooltip for the disabled plan + // Should have 1 disabled plan + // Should have one tooltip for the disabled plan cy.findByText('Shared CPU').click(); cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 0); @@ -169,8 +184,8 @@ describe('displays linode plans panel based on availability', () => { cy.findAllByRole('row').should('have.length', 4); cy.get('[id="shared-1"]').should('be.enabled'); cy.get('[id="shared-2"]').should('be.enabled'); - cy.get('[id="shared-3"]').should('be.enabled'); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); + cy.get('[id="shared-3"]').should('be.disabled'); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); }); }); @@ -178,7 +193,7 @@ describe('displays linode plans panel based on availability', () => { // Should have the limited availability notice // Should contain 1 plan (2 rows including the header row) // Should have one disabled plan - // Should have tooltip for the disabled plan (more than half disabled plans in the panel, but only one plan) + // Should have no tooltip for the disabled plan (more than half disabled plans in the panel) cy.findByText('High Memory').click(); cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); @@ -187,7 +202,7 @@ describe('displays linode plans panel based on availability', () => { cy.findByRole('table', { name: planSelectionTable }).within(() => { cy.findAllByRole('row').should('have.length', 2); cy.get('[id="highmem-1"]').should('be.disabled'); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); @@ -232,9 +247,9 @@ describe('displays kubernetes plans panel based on availability', () => { // Dedicated CPU tab // Should be selected/open by default // Should have the limited availability notice - // Should contain 4 plans (5 rows including the header row) - // Should have 2 plans disabled - // Should have tooltips for the disabled plans (not more than half disabled plans in the panel) + // Should contain 5 plans (6 rows including the header row) + // Should have 3 plans disabled + // Should have no tooltips for the disabled plans (more than half disabled plans in the panel) // All inputs for a row should be enabled if row is enabled (only testing one row in suite) // All inputs for a disabled row should be disabled (only testing one row in suite) cy.get(k8PlansPanel).within(() => { @@ -242,7 +257,7 @@ describe('displays kubernetes plans panel based on availability', () => { cy.get(notices.limitedAvailability).should('be.visible'); cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 5); + cy.findAllByRole('row').should('have.length', 6); cy.get('[data-qa-plan-row="dedicated-1"]').should( 'not.have.attr', 'disabled' @@ -270,14 +285,14 @@ describe('displays kubernetes plans panel based on availability', () => { ) .should('be.disabled'); }); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 2); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); // Shared CPU tab // Should have no notices // Should contain 3 plans (4 rows including the header row) - // Should have 1 disabled plan + // Should have 2 disabled plans // Should have tooltip for the disabled plan (not more than half disabled plans in the panel) cy.findByText('Shared CPU').click(); cy.get(k8PlansPanel).within(() => { @@ -293,11 +308,8 @@ describe('displays kubernetes plans panel based on availability', () => { 'not.have.attr', 'disabled' ); - cy.get('[data-qa-plan-row="shared-3"]').should( - 'not.have.attr', - 'disabled' - ); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); + cy.get('[data-qa-plan-row="shared-3"]').should('have.attr', 'disabled'); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); }); }); @@ -305,7 +317,7 @@ describe('displays kubernetes plans panel based on availability', () => { // Should have the limited availability notice // Should contain 1 plan (2 rows including the header row) // Should have one disabled plan - // Should have tooltip for the disabled plan (more than half disabled plans in the panel, but only one plan) + // Should have no tooltip for the disabled plan (more than half disabled plans in the panel) cy.findByText('High Memory').click(); cy.get(k8PlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); @@ -317,7 +329,7 @@ describe('displays kubernetes plans panel based on availability', () => { 'have.attr', 'disabled' ); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); 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 55b5e951243..2c2c98ce89b 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -1,4 +1,4 @@ -import { createLinode, CreateLinodeRequest, Linode } from '@linode/api-v4'; +import { CreateLinodeRequest, Linode } from '@linode/api-v4'; import { ui } from 'support/ui'; import { randomString, randomLabel } from 'support/util/random'; import { authenticate } from 'support/api/authentication'; @@ -12,6 +12,7 @@ import { mockGetLinodeDetails, mockRebuildLinodeError, } from 'support/intercepts/linodes'; +import { createTestLinode } from 'support/util/linodes'; /** * Creates a Linode and StackScript. @@ -27,7 +28,7 @@ const createStackScriptAndLinode = async ( ) => { return Promise.all([ createStackScript(stackScriptRequestPayload), - createLinode(linodeRequestPayload), + createTestLinode(linodeRequestPayload), ]); }; @@ -117,45 +118,46 @@ describe('rebuild linode', () => { region: chooseRegion().id, }); - cy.defer(createLinode(linodeCreatePayload), 'creating Linode').then( - (linode: Linode) => { - interceptRebuildLinode(linode.id).as('linodeRebuild'); + 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'); + 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'); + 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}`); + ui.select + .findByText('Choose an image') + .should('be.visible') + .click() + .type(`${image}{enter}`); - // Type to confirm. - cy.findByLabelText('Linode Label').type(linode.label); + // Type to confirm. + cy.findByLabelText('Linode Label').type(linode.label); - // checkPasswordComplexity(rootPassword); - assertPasswordComplexity(weakPassword, 'Weak'); - submitRebuild(); - cy.findByText(passwordComplexityError).should('be.visible'); + // checkPasswordComplexity(rootPassword); + assertPasswordComplexity(weakPassword, 'Weak'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('be.visible'); - assertPasswordComplexity(fairPassword, 'Fair'); - 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'); - }); + 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'); + }); }); /* @@ -171,52 +173,53 @@ describe('rebuild linode', () => { region: chooseRegion().id, }); - cy.defer(createLinode(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'); + }); }); /* @@ -250,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 2d62584aa8e..533eac2535f 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -1,5 +1,4 @@ import type { Linode } from '@linode/api-v4'; -import { createLinode } from '@linode/api-v4'; import { createLinodeRequestFactory, linodeFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { @@ -12,6 +11,7 @@ import { } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -43,44 +43,50 @@ describe('Rescue Linodes', () => { region: chooseRegion().id, }); - cy.defer(createLinode(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 d55affe595c..0d003ddd864 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -1,4 +1,4 @@ -import { createLinode } from 'support/api/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { containsVisible, fbtVisible, getClick } from 'support/helpers'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -15,7 +15,13 @@ describe('resize linode', () => { it('resizes a linode by increasing size: warm migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - createLinode().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'); - createLinode().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'); - createLinode().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 @@ -98,8 +116,17 @@ describe('resize linode', () => { }); it('resizes a linode by decreasing size', () => { - createLinode().then((linode) => { - const diskName = 'Debian 10 Disk'; + // 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 @@ -145,8 +172,6 @@ describe('resize linode', () => { cy.contains('Resize').should('be.enabled').click(); }); - ui.drawer.findByTitle(`Resize Debian 10 Disk`); - ui.drawer .findByTitle(`Resize ${diskName}`) .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 403ff604712..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 @@ -1,5 +1,5 @@ import { authenticate } from 'support/api/authentication'; -import { createLinode } from '@linode/api-v4/lib/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { createLinodeRequestFactory } from '@src/factories/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -73,7 +73,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createLinode(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(createLinode(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(createLinode(linodeCreatePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes`); @@ -219,10 +219,10 @@ describe('delete linode', () => { const createTwoLinodes = async (): Promise<[Linode, Linode]> => { return Promise.all([ - createLinode( + createTestLinode( createLinodeRequestFactory.build({ label: randomLabel() }) ), - createLinode( + createTestLinode( createLinodeRequestFactory.build({ label: randomLabel() }) ), ]); @@ -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 848aeb2fc87..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 @@ -17,7 +17,13 @@ describe('switch linode state', () => { * - Does not wait for Linode to finish being shut down before succeeding. */ it('powers off a linode from landing page', () => { - cy.defer(createTestLinode()).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 reboot shortly after booting up when the Linode is + // attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') @@ -58,7 +64,13 @@ describe('switch linode state', () => { * - Waits for Linode to fully shut down before succeeding. */ it('powers off a linode from details page', () => { - cy.defer(createTestLinode()).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 reboot shortly after booting up when the Linode is + // attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); cy.contains('RUNNING').should('be.visible'); cy.findByText(linode.label).should('be.visible'); @@ -86,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'); + }); + } + ); }); /* @@ -128,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'); + } + ); }); /* @@ -156,7 +172,13 @@ describe('switch linode state', () => { * - Does not wait for Linode to finish rebooting before succeeding. */ it('reboots a linode from landing page', () => { - cy.defer(createTestLinode()).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 reboot shortly after booting up when the Linode is + // attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') @@ -197,7 +219,13 @@ describe('switch linode state', () => { * - Waits for Linode to finish rebooting before succeeding. */ it('reboots a linode from details page', () => { - cy.defer(createTestLinode()).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 reboot shortly after booting up when the Linode is + // attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); cy.contains('RUNNING').should('be.visible'); cy.findByText(linode.label).should('be.visible'); 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 ee944990f44..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 @@ -1,5 +1,4 @@ -import { createLinode } from 'support/api/linodes'; -import { containsVisible } from 'support/helpers'; +import { createTestLinode } from 'support/util/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; @@ -12,10 +11,10 @@ describe('update linode label', () => { }); it('updates a linode label from details page', () => { - createLinode().then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); - containsVisible('RUNNING'); + cy.contains('RUNNING').should('be.visible'); cy.get(`[aria-label="Edit ${linode.label}"]`).click(); cy.get(`[id="edit-${linode.label}-label"]`) @@ -29,10 +28,10 @@ describe('update linode label', () => { }); it('updates a linode label from the "Settings" tab', () => { - createLinode().then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); - containsVisible('RUNNING'); + cy.contains('RUNNING').should('be.visible'); cy.visitWithLogin(`/linodes/${linode.id}/settings`); cy.get('[id="label"]').click().clear().type(`${newLinodeLabel}{enter}`); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index d45259e8a6a..320f57da41e 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -1,28 +1,26 @@ -import type { Linode, LongviewClient } from '@linode/api-v4'; -import { createLongviewClient } from '@linode/api-v4'; -import { longviewResponseFactory, longviewClientFactory } from 'src/factories'; -import { LongviewResponse } from 'src/features/Longview/request.types'; +import type { LongviewClient } from '@linode/api-v4'; +import { DateTime } from 'luxon'; +import { + longviewResponseFactory, + longviewClientFactory, + longviewAppsFactory, + longviewLatestStatsFactory, + longviewPackageFactory, +} from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { - longviewInstallTimeout, longviewStatusTimeout, longviewEmptyStateMessage, longviewAddClientButtonText, } from 'support/constants/longview'; import { interceptFetchLongviewStatus, - interceptGetLongviewClients, mockGetLongviewClients, mockFetchLongviewStatus, mockCreateLongviewClient, } from 'support/intercepts/longview'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { createAndBootLinode } from 'support/util/linodes'; -import { randomLabel, randomString } from 'support/util/random'; - -// Timeout if Linode creation and boot takes longer than 1 and a half minutes. -const linodeCreateTimeout = 90000; /** * Returns the command used to install Longview which is shown in Cloud's UI. @@ -35,31 +33,6 @@ const getInstallCommand = (installCode: string): string => { return `curl -s https://lv.linode.com/${installCode} | sudo bash`; }; -/** - * Installs Longview on a Linode. - * - * @param linodeIp - IP of Linode on which to install Longview. - * @param linodePass - Root password of Linode on which to install Longview. - * @param installCommand - Longview installation command. - * - * @returns Cypress chainable. - */ -const installLongview = ( - linodeIp: string, - linodePass: string, - installCommand: string -) => { - return cy.exec('./cypress/support/scripts/longview/install-longview.sh', { - failOnNonZeroExit: true, - timeout: longviewInstallTimeout, - env: { - LINODEIP: linodeIp, - LINODEPASSWORD: linodePass, - CURLCOMMAND: installCommand, - }, - }); -}; - /** * Waits for Cloud Manager to fetch Longview data and receive updates. * @@ -100,6 +73,58 @@ const waitForLongviewData = ( ); }; +/* + * Mocks that represent the state of Longview while waiting for client to be installed. + */ +const longviewLastUpdatedWaiting = longviewResponseFactory.build({ + ACTION: 'lastUpdated', + DATA: { updated: 0 }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetValuesWaiting = longviewResponseFactory.build({ + ACTION: 'getValues', + DATA: {}, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetLatestValueWaiting = longviewResponseFactory.build({ + ACTION: 'getLatestValue', + DATA: {}, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +/* + * Mocks that represent the state of Longview once client is installed and data is received. + */ +const longviewLastUpdatedInstalled = longviewResponseFactory.build({ + ACTION: 'lastUpdated', + DATA: { + updated: DateTime.now().plus({ minutes: 1 }).toSeconds(), + }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetValuesInstalled = longviewResponseFactory.build({ + ACTION: 'getValues', + DATA: { + Packages: longviewPackageFactory.buildList(5), + }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetLatestValueInstalled = longviewResponseFactory.build({ + ACTION: 'getLatestValue', + DATA: longviewLatestStatsFactory.build(), + NOTIFICATIONS: [], + VERSION: 0.4, +}); + authenticate(); describe('longview', () => { before(() => { @@ -107,82 +132,76 @@ describe('longview', () => { }); /* - * - Tests Longview installation end-to-end using real API data. - * - Creates a Linode, connects to it via SSH, and installs Longview using the given cURL command. + * - Tests Longview installation end-to-end using mock API data. * - Confirms that Cloud Manager UI updates to reflect Longview installation and data. */ + it('can install Longview client on a Linode', () => { - const linodePassword = randomString(32, { - symbols: false, - lowercase: true, - uppercase: true, - numbers: true, - spaces: false, + const client: LongviewClient = longviewClientFactory.build({ + api_key: '01AE82DD-6F99-44F6-95781512B64FFBC3', + apps: longviewAppsFactory.build(), + created: new Date().toISOString(), + id: 338283, + install_code: '748632FC-E92B-491F-A29D44019039017C', + label: 'longview-client-longview338283', + updated: new Date().toISOString(), }); - const createLinodeAndClient = async () => { - return Promise.all([ - createAndBootLinode({ - root_pass: linodePassword, - type: 'g6-standard-1', - }), - createLongviewClient(randomLabel()), - ]); - }; - - // Create Linode and Longview Client before loading Longview landing page. - cy.defer(createLinodeAndClient(), { - label: 'Creating Linode and Longview Client...', - timeout: linodeCreateTimeout, - }).then(([linode, client]: [Linode, LongviewClient]) => { - const linodeIp = linode.ipv4[0]; - const installCommand = getInstallCommand(client.install_code); - - interceptGetLongviewClients().as('getLongviewClients'); - interceptFetchLongviewStatus().as('fetchLongviewStatus'); - cy.visitWithLogin('/longview'); - cy.wait('@getLongviewClients'); - - // Find the table row for the new Longview client, assert expected information - // is displayed inside of it. - cy.get(`[data-qa-longview-client="${client.id}"]`) - .should('be.visible') - .within(() => { - cy.findByText(client.label).should('be.visible'); - cy.findByText(client.api_key).should('be.visible'); - cy.contains(installCommand).should('be.visible'); - cy.findByText('Waiting for data...'); - }); - - // Install Longview on Linode by SSHing into machine and executing cURL command. - installLongview(linodeIp, linodePassword, installCommand).then( - (output) => { - // TODO Output this to a log file. - console.log(output.stdout); - console.log(output.stderr); - } - ); + mockGetLongviewClients([client]).as('getLongviewClients'); + mockFetchLongviewStatus(client, 'lastUpdated', longviewLastUpdatedWaiting); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesWaiting); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueWaiting + ).as('fetchLongview'); + + const installCommand = getInstallCommand(client.install_code); - // Wait for Longview to begin serving data and confirm that Cloud Manager - // UI updates accordingly. - waitForLongviewData('fetchLongviewStatus', client.api_key); - - // Sometimes Cloud Manager UI does not updated automatically upon receiving - // Longivew status data. Performing a page reload mitigates this issue. - // TODO Remove call to `cy.reload()`. - cy.reload(); - cy.get(`[data-qa-longview-client="${client.id}"]`) - .should('be.visible') - .within(() => { - cy.findByText('Waiting for data...').should('not.exist'); - cy.findByText('CPU').should('be.visible'); - cy.findByText('RAM').should('be.visible'); - cy.findByText('Swap').should('be.visible'); - cy.findByText('Load').should('be.visible'); - cy.findByText('Network').should('be.visible'); - cy.findByText('Storage').should('be.visible'); - }); + cy.visitWithLogin('/longview'); + cy.wait('@getLongviewClients'); + + // Confirm that Longview landing page lists a client that is still waiting for data... + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText(client.label).should('be.visible'); + cy.findByText(client.api_key).should('be.visible'); + cy.contains(installCommand).should('be.visible'); + cy.findByText('Waiting for data...'); + }); + + // Update mocks after initial Longview fetch to simulate client installation and data retrieval. + // The next time Cloud makes a request to the fetch endpoint, data will start being returned. + // 3 fetches is necessary because the Longview landing page fires 3 requests to the Longview fetch endpoint for each client. + // See https://github.com/linode/manager/pull/10579#discussion_r1647945160 + cy.wait(['@fetchLongview', '@fetchLongview', '@fetchLongview']).then(() => { + mockFetchLongviewStatus( + client, + 'lastUpdated', + longviewLastUpdatedInstalled + ); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesInstalled); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueInstalled + ); }); + + // Confirms that UI updates to show that data has been retrieved. + cy.findByText(`${client.label}`).should('be.visible'); + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText('Waiting for data...').should('not.exist'); + cy.findByText('CPU').should('be.visible'); + cy.findByText('RAM').should('be.visible'); + cy.findByText('Swap').should('be.visible'); + cy.findByText('Load').should('be.visible'); + cy.findByText('Network').should('be.visible'); + cy.findByText('Storage').should('be.visible'); + }); }); /* @@ -191,10 +210,15 @@ describe('longview', () => { */ it('displays empty state message when no clients are present and shows the new client when creating one', () => { const client: LongviewClient = longviewClientFactory.build(); - const status: LongviewResponse = longviewResponseFactory.build(); mockGetLongviewClients([]).as('getLongviewClients'); mockCreateLongviewClient(client).as('createLongviewClient'); - mockFetchLongviewStatus(status).as('fetchLongviewStatus'); + mockFetchLongviewStatus(client, 'lastUpdated', longviewLastUpdatedWaiting); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesWaiting); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueWaiting + ).as('fetchLongview'); cy.visitWithLogin('/longview'); cy.wait('@getLongviewClients'); @@ -210,6 +234,24 @@ describe('longview', () => { .click(); cy.wait('@createLongviewClient'); + // Update mocks after initial Longview fetch to simulate client installation and data retrieval. + // The next time Cloud makes a request to the fetch endpoint, data will start being returned. + // 3 fetches is necessary because the Longview landing page fires 3 requests to the Longview fetch endpoint for each client. + // See https://github.com/linode/manager/pull/10579#discussion_r1647945160 + cy.wait(['@fetchLongview', '@fetchLongview', '@fetchLongview']).then(() => { + mockFetchLongviewStatus( + client, + 'lastUpdated', + longviewLastUpdatedInstalled + ); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesInstalled); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueInstalled + ); + }); + // Confirms that UI updates to show the new client when creating one. cy.findByText(`${client.label}`).should('be.visible'); cy.get(`[data-qa-longview-client="${client.id}"]`) 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 0672c1accd1..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 @@ -1,12 +1,5 @@ import { entityTag } from 'support/constants/cypress'; -import { createLinode } from 'support/api/linodes'; -import { - containsClick, - fbtClick, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; +import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; @@ -34,8 +27,12 @@ const createNodeBalancerWithUI = ( const regionName = getRegionById(nodeBal.region).label; cy.visitWithLogin('/nodebalancers/create'); - getVisible('[id="nodebalancer-label"]').click().clear().type(nodeBal.label); - containsClick('create a tag').type(entityTag); + cy.get('[id="nodebalancer-label"]') + .should('be.visible') + .click() + .clear() + .type(nodeBal.label); + cy.contains('create a tag').click().type(entityTag); if (isDcPricingTest) { const newRegion = getRegionById('br-gru'); @@ -66,7 +63,7 @@ const createNodeBalancerWithUI = ( ui.regionSelect.find().click().clear().type(`${regionName}{enter}`); // node backend config - fbtClick('Label').type(randomLabel()); + cy.findByText('Label').click().type(randomLabel()); cy.findByLabelText('IP Address') .should('be.visible') @@ -85,9 +82,14 @@ describe('create NodeBalancer', () => { }); it('creates a NodeBalancer in a region with base pricing', () => { - // create a linode in NW where the NB will be created const region = chooseRegion(); - createLinode({ region: region.id }).then((linode) => { + const linodePayload = { + region: region.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: randomLabel(), region: region.id, @@ -109,7 +111,12 @@ describe('create NodeBalancer', () => { */ it('displays API errors for NodeBalancer Create form fields', () => { const region = chooseRegion(); - createLinode({ region: region.id }).then((linode) => { + const linodePayload = { + region: region.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: `${randomLabel()}-^`, ipv4: linode.ipv4[1], @@ -120,15 +127,21 @@ describe('create NodeBalancer', () => { interceptCreateNodeBalancer().as('createNodeBalancer'); createNodeBalancerWithUI(nodeBal); - fbtVisible(`Label can't contain special characters or spaces.`); - getVisible('[id="nodebalancer-label"]') + cy.findByText(`Label can't contain special characters or spaces.`).should( + 'be.visible' + ); + cy.get('[id="nodebalancer-label"]') + .should('be.visible') .click() .clear() .type(randomLabel()); - getClick('[data-qa-protocol-select="true"]').type('TCP{enter}'); - getClick('[data-qa-session-stickiness-select]').type( - 'HTTP Cookie{enter}' - ); + + cy.get('[data-qa-protocol-select="true"]').click().type('TCP{enter}'); + + cy.get('[data-qa-session-stickiness-select]') + .click() + .type('HTTP Cookie{enter}'); + deployNodeBalancer(); const errMessage = `Stickiness http_cookie requires protocol 'http' or 'https'`; cy.wait('@createNodeBalancer') @@ -136,7 +149,8 @@ describe('create NodeBalancer', () => { .should('deep.equal', { errors: [{ field: 'configs[0].stickiness', reason: errMessage }], }); - fbtVisible(errMessage); + + cy.findByText(errMessage).should('be.visible'); }); }); @@ -146,7 +160,12 @@ describe('create NodeBalancer', () => { */ it('shows DC-specific pricing information when creating a NodeBalancer', () => { const initialRegion = getRegionById('us-west'); - createLinode({ region: initialRegion.id }).then((linode) => { + const linodePayload = { + region: initialRegion.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: randomLabel(), region: initialRegion.id, diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts index c2bfc924014..421c819dd14 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts @@ -3,6 +3,11 @@ import { eventFactory } from '@src/factories/events'; import { RecPartial } from 'factory.ts'; import { containsClick, getClick } from 'support/helpers'; import { mockGetEvents } from 'support/intercepts/events'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; const eventActions: RecPartial[] = [ 'backups_cancel', @@ -15,6 +20,7 @@ const eventActions: RecPartial[] = [ 'disk_duplicate', 'disk_resize', 'disk_update', + 'database_resize', 'database_low_disk_space', 'entity_transfer_accept', 'entity_transfer_cancel', @@ -106,6 +112,14 @@ const events: Event[] = eventActions.map((action) => { }); describe('verify notification types and icons', () => { + before(() => { + // TODO eventMessagesV2: delete when flag is removed and update test + mockAppendFeatureFlags({ + eventMessagesV2: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + }); + it(`notifications`, () => { mockGetEvents(events).as('mockEvents'); cy.visitWithLogin('/linodes'); 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 85735a3cf48..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, @@ -36,16 +40,6 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; // Various messages, notes, and warnings that may be shown when enabling Object Storage // under different circumstances. const objNotes = { - // When enabling OBJ using a region with a regular pricing structure, when OBJ DC-specific pricing is disabled. - regularPricing: /Linode Object Storage costs a flat rate of \$5\/month, and includes 250 GB of storage and 1 TB of outbound data transfer. Beyond that, it.*s \$0.02 per GB per month./, - - // When enabling OBJ using a region with special pricing during the free beta period (OBJ DC-specific pricing is disabled). - dcSpecificBetaPricing: /Object Storage for .* is currently in beta\. During the beta period, Object Storage in these regions is free\. After the beta period, customers will be notified before charges for this service begin./, - - // When enabling OBJ without having selected a region, when OBJ DC-specific pricing is disabled. - dcPricingGenericExplanation: - 'Pricing for monthly rate and overage costs will depend on the data center you select for deployment.', - // When enabling OBJ, in both the Access Key flow and Create Bucket flow, when OBJ DC-specific pricing is enabled. objDCPricing: 'Object Storage costs a flat rate of $5/month, and includes 250 GB of storage. When you enable Object Storage, 1 TB of outbound data transfer will be added to your global network transfer pool.', @@ -66,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 c53fdf988c3..4d415a8fb19 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, @@ -47,6 +50,9 @@ const getNonEmptyBucketMessage = (bucketLabel: string) => { /** * Create a bucket with the given label and cluster. * + * This function assumes that OBJ Multicluster is not enabled. Use + * `setUpBucketMulticluster` to set up OBJ buckets when Multicluster is enabled. + * * @param label - Bucket label. * @param cluster - Bucket cluster. * @@ -57,12 +63,40 @@ const setUpBucket = (label: string, cluster: string) => { objectStorageBucketFactory.build({ label, cluster, - // Default factory sets `region`, but API does not accept it yet. + + // API accepts either `cluster` or `region`, but not both. Our factory + // populates both fields, so we have to manually set `region` to `undefined` + // to avoid 400 responses from the API. region: undefined, }) ); }; +/** + * Create a bucket with the given label and cluster. + * + * This function assumes that OBJ Multicluster is enabled. Use + * `setUpBucket` to set up OBJ buckets when Multicluster is disabled. + * + * @param label - Bucket label. + * @param regionId - ID of Bucket region. + * + * @returns Promise that resolves to created Bucket. + */ +const setUpBucketMulticluster = (label: string, regionId: string) => { + return createBucket( + objectStorageBucketFactory.build({ + label, + region: regionId, + + // API accepts either `cluster` or `region`, but not both. Our factory + // populates both fields, so we have to manually set `cluster` to `undefined` + // to avoid 400 responses from the API. + cluster: undefined, + }) + ); +}; + /** * Uploads the file at the given path and assigns it the given filename. * @@ -132,6 +166,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'); @@ -207,7 +242,8 @@ describe('object storage end-to-end tests', () => { it('can upload, access, and delete objects', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; - const bucketPage = `/object-storage/buckets/${bucketCluster}/${bucketLabel}/objects`; + const bucketRegionId = 'us-southeast'; + const bucketPage = `/object-storage/buckets/${bucketRegionId}/${bucketLabel}/objects`; const bucketFolderName = randomLabel(); const bucketFiles = [ @@ -216,7 +252,7 @@ describe('object storage end-to-end tests', () => { ]; cy.defer( - setUpBucket(bucketLabel, bucketCluster), + () => setUpBucketMulticluster(bucketLabel, bucketRegionId), 'creating Object Storage bucket' ).then(() => { interceptUploadBucketObjectS3( @@ -237,7 +273,7 @@ describe('object storage end-to-end tests', () => { cy.wait('@uploadObject'); cy.reload(); - cy.findByLabelText(bucketFiles[0].name).should('be.visible'); + cy.findByText(bucketFiles[0].name).should('be.visible'); ui.button.findByTitle('Delete').should('be.visible').click(); ui.dialog @@ -409,7 +445,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/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index 604f7550039..3a08199557f 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -19,9 +19,8 @@ import { } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mapStackScriptLabelToOCA } from 'src/features/OneClickApps/utils'; -import { baseApps } from 'src/features/StackScripts/stackScriptUtils'; import { stackScriptFactory } from 'src/factories/stackscripts'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import type { StackScript } from '@linode/api-v4'; import type { OCA } from '@src/features/OneClickApps/types'; @@ -42,7 +41,7 @@ describe('OneClick Apps (OCA)', () => { const stackScripts: StackScript[] = xhr.response?.body.data ?? []; const trimmedApps: StackScript[] = filterOneClickApps({ - baseApps, + baseAppIds: Object.keys(oneClickApps).map(Number), newApps: {}, queryResults: stackScripts, }); @@ -65,7 +64,7 @@ describe('OneClick Apps (OCA)', () => { // This is only true for the apps defined in `oneClickApps.ts` expect( mapStackScriptLabelToOCA({ - oneClickApps, + oneClickApps: Object.values(oneClickApps), stackScriptLabel: decodedLabel, }) ).to.not.be.undefined; @@ -82,7 +81,7 @@ describe('OneClick Apps (OCA)', () => { stackScriptCandidate.should('exist').click(); const app: OCA | undefined = mapStackScriptLabelToOCA({ - oneClickApps, + oneClickApps: Object.values(oneClickApps), stackScriptLabel: candidateApp.label, }); @@ -163,7 +162,7 @@ describe('OneClick Apps (OCA)', () => { const password = randomString(16); const image = 'linode/ubuntu22.04'; const rootPassword = randomString(16); - const region = chooseRegion(); + const region = chooseRegion({ capabilities: ['Vlans'] }); const linodeLabel = randomLabel(); const levelName = 'Get the enderman!'; diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index cb1300418d2..cfb59e42e07 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -21,10 +21,6 @@ import { mockGetUser, } from 'support/intercepts/account'; import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockAllApiRequests } from 'support/intercepts/general'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { @@ -33,7 +29,6 @@ import { } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { assertLocalStorageValue } from 'support/util/local-storage'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { grantsFactory } from '@src/factories/grants'; @@ -156,14 +151,6 @@ describe('Parent/Child account switching', () => { * Tests to confirm that Parent account users can switch to Child accounts as expected. */ describe('From Parent to Child', () => { - beforeEach(() => { - // @TODO M3-7554, M3-7559: Remove feature flag mocks after feature launch and clean-up. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms that Parent account user can switch to Child account from Account Billing page. * - Confirms that Child account information is displayed in user menu button after switch. @@ -320,19 +307,78 @@ describe('Parent/Child account switching', () => { mockChildAccount.company ); }); + + /* + * - Confirms search functionality in the account switching drawer. + */ + it('can search child accounts', () => { + mockGetProfile(mockParentProfile); + mockGetAccount(mockParentAccount); + mockGetChildAccounts([mockChildAccount, mockAlternateChildAccount]); + mockGetUser(mockParentUser); + + cy.visitWithLogin('/'); + cy.trackPageVisit().as('pageVisit'); + + // Confirm that Parent account username and company name are shown in user + // menu button, then click the button. + assertUserMenuButton( + mockParentProfile.username, + mockParentAccount.company + ).click(); + + // Click "Switch Account" button in user menu. + ui.userMenu + .find() + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Switch Account') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm search functionality. + ui.drawer + .findByTitle('Switch Account') + .should('be.visible') + .within(() => { + // Confirm all child accounts are displayed when drawer loads. + cy.findByText(mockChildAccount.company).should('be.visible'); + cy.findByText(mockAlternateChildAccount.company).should('be.visible'); + + // Confirm no results message. + mockGetChildAccounts([]).as('getEmptySearchResults'); + cy.findByPlaceholderText('Search').click().type('Fake Name'); + cy.wait('@getEmptySearchResults'); + + cy.contains(mockChildAccount.company).should('not.exist'); + cy.findByText( + 'There are no child accounts that match this query.' + ).should('be.visible'); + + // Confirm filtering by company name displays only one search result. + mockGetChildAccounts([mockChildAccount]).as('getSearchResults'); + cy.findByPlaceholderText('Search') + .click() + .clear() + .type(mockChildAccount.company); + cy.wait('@getSearchResults'); + + cy.findByText(mockChildAccount.company).should('be.visible'); + cy.contains(mockAlternateChildAccount.company).should('not.exist'); + cy.contains( + 'There are no child accounts that match this query.' + ).should('not.exist'); + }); + }); }); /** * Tests to confirm that Parent account users can switch back from Child accounts as expected. */ describe('From Child to Parent', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms that a Child account Proxy user can switch back to a Parent account from Billing page. * - Confirms that Parent account information is displayed in user menu button after switch. @@ -398,9 +444,7 @@ describe('Parent/Child account switching', () => { .findByTitle('Switch Account') .should('be.visible') .within(() => { - cy.findByText('There are no indirect customer accounts.').should( - 'be.visible' - ); + cy.findByText('There are no child accounts.').should('be.visible'); cy.findByText('switch back to your account') .should('be.visible') .click(); diff --git a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts index bf91b178e3b..0c03b94d23e 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts @@ -1,9 +1,4 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetLinodes } from 'support/intercepts/linodes'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { accountFactory, accountUserFactory, @@ -33,14 +28,6 @@ const mockChildAccountProxyProfile = profileFactory.build({ }); describe('Parent/Child token expiration', () => { - // @TODO M3-7554, M3-7559: Remove feature flag mocks after launch and clean-up. - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms flow when a Proxy user attempts to switch back to a Parent account with expired auth token. * - Uses mock API and local storage data. diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts new file mode 100644 index 00000000000..c0ad5c28d4c --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts @@ -0,0 +1,209 @@ +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + accountFactory, + linodeFactory, + placementGroupFactory, +} from 'src/factories'; +import { regionFactory } from 'src/factories'; +import { ui } from 'support/ui/'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreatePlacementGroup, + mockGetPlacementGroups, +} from 'support/intercepts/placement-groups'; +import { randomString } from 'support/util/random'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from 'src/features/PlacementGroups/constants'; + +import type { Region } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + +const mockAccount = accountFactory.build(); +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes', 'Placement Group'], + id: 'us-east', + label: 'Newark, NJ', + country: 'us', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + country: 'us', + }), +]; + +describe('Linode create flow with Placement Group', () => { + beforeEach(() => { + mockGetAccount(mockAccount); + mockGetRegions(mockRegions).as('getRegions'); + // TODO Remove feature flag mocks when `placementGroups` flag is retired. + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + linodeCreateRefactor: makeFeatureFlagData( + false + ), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms Placement Group create UI flow using mock API data. + * - Confirms that outgoing Placement Group create request contains expected data. + * - Confirms that Cloud automatically updates to list new Placement Group on landing page. + */ + it('can create a linode with a newly created Placement Group', () => { + cy.visitWithLogin('/linodes/create'); + + cy.findByText( + 'Select a Region for your Linode to see existing placement groups.' + ).should('be.visible'); + + // Region without capability + // Choose region + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[1].label).click(); + + // Choose plan + cy.findByText('Shared CPU').click(); + cy.get('[id="g6-nanode-1"]').click(); + + cy.findByText('Placement Groups in Dallas, TX (us-central)').should( + 'be.visible' + ); + cy.get('[data-testid="placement-groups-no-capability-notice"]').should( + 'be.visible' + ); + ui.tooltip + .findByText('Regions that support placement groups') + .should('be.visible') + .click(); + cy.get('[data-testid="supported-pg-region-us-east"]').should('be.visible'); + + // Region with capability + // Choose region + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + // Choose plan + cy.findByText('Shared CPU').click(); + cy.get('[id="g6-nanode-1"]').click(); + + // Choose Placement Group + // No Placement Group available + cy.findByText('Placement Groups in Newark, NJ (us-east)').should( + 'be.visible' + ); + // Open the select + cy.get('[data-testid="placement-groups-select"] input').click(); + cy.findByText('There are no placement groups in this region.').click(); + // Close the select + cy.get('[data-testid="placement-groups-select"] input').click(); + + // Create a Placement Group + ui.button + .findByTitle('Create Placement Group') + .should('be.visible') + .should('be.enabled') + .click(); + + const mockPlacementGroup = placementGroupFactory.build({ + label: 'pg-1-us-east', + region: mockRegions[0].id, + affinity_type: 'anti_affinity:local', + is_strict: true, + is_compliant: true, + }); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockCreatePlacementGroup(mockPlacementGroup).as('createPlacementGroup'); + + ui.drawer + .findByTitle('Create Placement Group') + .should('be.visible') + .within(() => { + // Confirm that the drawer contains the expected default information. + // - A selection region + // - An Affinity Type Enforcement message + // - a disabled "Create Placement Group" button. + cy.findByText('Newark, NJ (us-east)').should('be.visible'); + cy.findByText(CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE).should( + 'be.visible' + ); + ui.buttonGroup + .findButtonByTitle('Create Placement Group') + .should('be.disabled'); + + // Enter label and submit form. + cy.findByLabelText('Label').type(mockPlacementGroup.label); + + ui.buttonGroup + .findButtonByTitle('Create Placement Group') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for outgoing API request and confirm that payload contains expected data. + cy.wait('@createPlacementGroup').then((xhr) => { + const requestPayload = xhr.request?.body; + expect(requestPayload['affinity_type']).to.equal('anti_affinity:local'); + expect(requestPayload['is_strict']).to.equal(true); + expect(requestPayload['label']).to.equal(mockPlacementGroup.label); + expect(requestPayload['region']).to.equal(mockRegions[0].id); + }); + + // Confirm that the drawer closes and a success message is displayed. + ui.toast.assertMessage( + `Placement Group ${mockPlacementGroup.label} successfully created.` + ); + + // Select the newly created Placement Group. + cy.wait('@getPlacementGroups'); + cy.get('[data-testid="placement-groups-select"] input').should( + 'have.value', + mockPlacementGroup.label + ); + + const linodeLabel = 'linode-with-placement-group'; + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: mockRegions[0].id, + placement_group: { + id: mockPlacementGroup.id, + }, + }); + + // Confirm the Placement group assignment is accounted for in the summary. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText('Assigned to Placement Group').should('be.visible'); + }); + + // Type in a label, password and submit the form. + mockCreateLinode(mockLinode).as('createLinode'); + cy.get('#linode-label').clear().type('linode-with-placement-group'); + cy.get('#root-password').type(randomString(32)); + + cy.get('[data-qa-deploy-linode]').click(); + + // Wait for outgoing API request and confirm that payload contains expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request?.body; + + expect(requestPayload['region']).to.equal(mockRegions[0].id); + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['placement_group'].id).to.equal( + mockPlacementGroup.id + ); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts index 088b40ce4c1..ceb4b9669b9 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts @@ -17,6 +17,8 @@ import { import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from 'src/features/PlacementGroups/constants'; + const mockAccount = accountFactory.build(); describe('Placement Group create flow', () => { @@ -69,8 +71,6 @@ describe('Placement Group create flow', () => { }); const placementGroupLimitMessage = `Maximum placement groups in region: ${mockPlacementGroupRegion.placement_group_limits.maximum_pgs_per_customer}`; - const affinityTypeMessage = - 'Once you create a placement group, you cannot change its Affinity Type Enforcement setting.'; mockGetRegions(mockRegions); mockGetPlacementGroups([]).as('getPlacementGroups'); @@ -103,7 +103,9 @@ describe('Placement Group create flow', () => { .type(`${mockPlacementGroupRegion.label}{enter}`); cy.findByText(placementGroupLimitMessage).should('be.visible'); - cy.findByText(affinityTypeMessage).should('be.visible'); + cy.findByText(CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE).should( + 'be.visible' + ); ui.buttonGroup .findButtonByTitle('Create Placement Group') diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts index e99a3ffecf7..c1dfd05220a 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -13,6 +13,8 @@ import { mockDeletePlacementGroup, mockGetPlacementGroups, mockUnassignPlacementGroupLinodes, + mockDeletePlacementGroupError, + mockUnassignPlacementGroupLinodesError, } from 'support/intercepts/placement-groups'; import { accountFactory, @@ -42,6 +44,9 @@ const unassignWarning = const emptyStateMessage = 'Control the physical placement or distribution of Linode instances within a data center or availability zone.'; +// Error message that when an unexpected error occurs. +const PlacementGroupErrorMessage = 'An unknown error has occurred.'; + describe('Placement Group deletion', () => { beforeEach(() => { // TODO Remove feature flag mocks when `placementGroups` flag is retired. @@ -60,8 +65,9 @@ describe('Placement Group deletion', () => { * - Confirms that user is not warned or prompted to unassign Linodes when none are assigned. * - Confirms that UI automatically updates to reflect deleted Placement Group. * - Confirms that landing page reverts to its empty state when last Placement Group is deleted. + * - Confirms that user can retry and continue with deletion when unexpected error happens. */ - it('can delete without Linodes assigned', () => { + it('can delete without Linodes assigned when unexpected error show up and retry', () => { const mockPlacementGroupRegion = chooseRegion(); const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), @@ -76,7 +82,6 @@ describe('Placement Group deletion', () => { cy.visitWithLogin('/placement-groups'); cy.wait('@getPlacementGroups'); - // Click "Delete" button next to the mock Placement Group. cy.findByText(mockPlacementGroup.label) .should('be.visible') .closest('tr') @@ -88,6 +93,30 @@ describe('Placement Group deletion', () => { .click(); }); + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockDeletePlacementGroupError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('deletePlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@deletePlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + }); + + // Click "Delete" button next to the mock Placement Group, + // mock a successful response and confirm that Cloud mockDeletePlacementGroup(mockPlacementGroup.id).as('deletePlacementGroup'); mockGetPlacementGroups([]).as('getPlacementGroups'); @@ -99,8 +128,6 @@ describe('Placement Group deletion', () => { cy.findByText(deletionWarning).should('be.visible'); cy.findByText(unassignWarning).should('not.exist'); - cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); - ui.button .findByTitle('Delete') .should('be.visible') @@ -122,8 +149,9 @@ describe('Placement Group deletion', () => { * - Confirms that user is prompted to unassign Linodes before being able to proceed with deletion. * - Confirms that UI automatically updates to reflect unassigned Linodes during deletion. * - Confirms that UI automatically updates to reflect deleted Placement Group. + * - Confirms that user can retry and continue with unassignment when unexpected error happens. */ - it('can delete with Linodes assigned', () => { + it('can delete with Linodes assigned when unexpected error show up and retry', () => { const mockPlacementGroupRegion = chooseRegion(); // Linodes that are assigned to the Placement Group being deleted. @@ -176,6 +204,36 @@ describe('Placement Group deletion', () => { .click(); }); + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockUnassignPlacementGroupLinodesError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('UnassignPlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + cy.wait('@UnassignPlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + }); + // Confirm deletion warning appears and that form cannot be submitted // while Linodes are assigned. ui.dialog @@ -256,4 +314,243 @@ describe('Placement Group deletion', () => { cy.findByText(mockPlacementGroup.label).should('not.exist'); cy.findByText(secondMockPlacementGroup.label).should('be.visible'); }); + + /* + * - Confirms UI flow for Placement Group deletion from landing page using mock API data. + * - Confirms that user is not warned or prompted to unassign Linodes when none are assigned. + * - Confirms that UI automatically updates to reflect deleted Placement Group. + * - Confirms that landing page reverts to its empty state when last Placement Group is deleted. + * - Confirms that user can close and reopen the dialog when unexpected error happens. + */ + it('can delete without Linodes assigned when unexpected error show up and reopen the dialog', () => { + const mockPlacementGroupRegion = chooseRegion(); + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: [], + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockDeletePlacementGroupError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('deletePlacementGroupError'); + + // The dialog can be closed after an unexpect error show up + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@deletePlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( + 'not.exist' + ); + + // Click "Delete" button next to the mock Placement Group, + // mock a successful response and confirm that Cloud + mockDeletePlacementGroup(mockPlacementGroup.id).as('deletePlacementGroup'); + + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + mockGetPlacementGroups([]).as('getPlacementGroups'); + + // Confirm deletion warning appears, complete Type-to-Confirm, and submit confirmation. + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + // ensure error message not exist when reopening the dialog + cy.findByText(PlacementGroupErrorMessage).should('not.exist'); + + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /* + * - Confirms UI flow for Placement Group deletion from landing page using mock API data. + * - Confirms deletion flow when Placement Group has one or more Linodes assigned to it. + * - Confirms that user is prompted to unassign Linodes before being able to proceed with deletion. + * - Confirms that user can close and reopen the dialog when unexpected error happens. + */ + it('can unassign Linode when unexpected error show up and reopen the dialog', () => { + const mockPlacementGroupRegion = chooseRegion(); + + // Linodes that are assigned to the Placement Group being deleted. + const mockPlacementGroupLinodes = buildArray(3, () => + linodeFactory.build({ + label: randomLabel(), + id: randomNumber(), + region: mockPlacementGroupRegion.id, + }) + ); + + // Placement Group that will be deleted. + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: mockPlacementGroupLinodes.map((linode) => ({ + linode_id: linode.id, + is_compliant: true, + })), + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + // Second unrelated Placement Group to verify landing page content after deletion. + const secondMockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: [], + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + mockGetLinodes(mockPlacementGroupLinodes).as('getLinodes'); + mockGetPlacementGroups([mockPlacementGroup, secondMockPlacementGroup]).as( + 'getPlacementGroups' + ); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + // Click "Delete" button next to the mock Placement Group. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockUnassignPlacementGroupLinodesError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('UnassignPlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + cy.wait('@UnassignPlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( + 'not.exist' + ); + + // Click "Delete" button next to the mock Placement Group to reopen the dialog + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm deletion warning appears and that form cannot be submitted + // while Linodes are assigned. + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + // ensure error message not exist when reopening the dialog + cy.findByText(PlacementGroupErrorMessage).should('not.exist'); + + // Unassign each Linode. + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + }); + }); }); 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/placementGroups/update-placement-group-label.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts new file mode 100644 index 00000000000..5ee32465d36 --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts @@ -0,0 +1,163 @@ +/** + * @file Integration tests for Placement Group update label flows. + */ + +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber } from 'support/util/random'; +import { + mockGetPlacementGroups, + mockUpdatePlacementGroup, + mockUpdatePlacementGroupError, +} from 'support/intercepts/placement-groups'; +import { accountFactory, placementGroupFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; +import type { Flags } from 'src/featureFlags'; +import { chooseRegion } from 'support/util/regions'; +import { ui } from 'support/ui'; + +const mockAccount = accountFactory.build(); + +describe('Placement Group update label flow', () => { + // Mock the VM Placement Groups feature flag to be enabled for each test in this block. + beforeEach(() => { + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + }); + mockGetFeatureFlagClientstream(); + mockGetAccount(mockAccount); + }); + + /** + * - Confirms that a Placement Group's label can be updated from the landing page. + * - Confirms that clicking "Edit" opens PG edit drawer. + * - Only the label field is shown in the edit drawer. + * - A new value can be entered into the label field. + * - Confirms that Placement Groups landing page updates to reflect successful label update. + * - Confirms a toast notification is shown upon successful label update. + */ + it("update to a Placement Group's label is successful", () => { + const mockPlacementGroupCompliantRegion = chooseRegion(); + + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockPlacementGroupCompliantRegion.id, + affinity_type: 'anti_affinity:local', + is_compliant: true, + is_strict: false, + members: [], + }); + + const mockPlacementGroupUpdated = { + ...mockPlacementGroup, + label: randomLabel(), + }; + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + + mockUpdatePlacementGroup( + mockPlacementGroup.id, + mockPlacementGroupUpdated.label + ).as('updatePlacementGroupLabel'); + + cy.visitWithLogin('/placement-groups'); + cy.wait(['@getPlacementGroups']); + + // Confirm that Placement Group is listed on landing page, click "Edit" to open drawer. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Edit').click(); + }); + + // Enter new label, click "Edit". + mockGetPlacementGroups([mockPlacementGroupUpdated]).as( + 'getPlacementGroups' + ); + cy.get('[data-qa-drawer="true"]').within(() => { + cy.findByText('Edit').should('be.visible'); + cy.findByDisplayValue(mockPlacementGroup.label) + .should('be.visible') + .click() + .type(`{selectall}{backspace}${mockPlacementGroupUpdated.label}`); + + cy.findByText('Edit').should('be.visible').click(); + + cy.wait('@updatePlacementGroupLabel').then((intercept) => { + expect(intercept.request.body['label']).to.equal( + mockPlacementGroupUpdated.label + ); + }); + }); + + ui.toast.assertMessage( + `Placement Group ${mockPlacementGroupUpdated.label} successfully updated.` + ); + }); + + /** + * - Confirms that an http error is handled gracefully for Placement Group label update. + * - A new value can be entered into the label field. + * - Confirms an error notice is shown upon failure to label update. + */ + it("update to a Placement Group's label fails with error message", () => { + const mockPlacementGroupCompliantRegion = chooseRegion(); + + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockPlacementGroupCompliantRegion.id, + affinity_type: 'anti_affinity:local', + is_compliant: true, + is_strict: false, + members: [], + }); + + const mockPlacementGroupUpdated = { + ...mockPlacementGroup, + label: randomLabel(), + }; + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + + mockUpdatePlacementGroupError( + mockPlacementGroup.id, + 'An unexpected error occurred.', + 400 + ).as('updatePlacementGroupLabelError'); + + cy.visitWithLogin('/placement-groups'); + cy.wait(['@getPlacementGroups']); + + // Confirm that Placement Group is listed on landing page, click "Edit" to open drawer. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Edit').click(); + }); + + // Enter new label, click "Edit". + cy.get('[data-qa-drawer="true"]').within(() => { + cy.findByText('Edit').should('be.visible'); + cy.findByDisplayValue(mockPlacementGroup.label) + .should('be.visible') + .click() + .type(`{selectall}{backspace}${mockPlacementGroupUpdated.label}`); + + cy.findByText('Edit').should('be.visible').click(); + + // Confirm error message is displayed in the drawer. + cy.wait('@updatePlacementGroupLabelError'); + cy.findByText('An unexpected error occurred.').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 926843d8496..2733a68d940 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -12,12 +12,11 @@ import { import { interceptCreateLinode } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { createLinodeRequestFactory } from 'src/factories'; -import { createLinode, getLinodeDisks } from '@linode/api-v4/lib/linodes'; -import { createImage } from '@linode/api-v4/lib/images'; +import { createImage, getLinodeDisks, resizeLinodeDisk } from '@linode/api-v4'; import { chooseRegion } from 'support/util/regions'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; -import { resizeLinodeDisk } from '@linode/api-v4/lib'; +import { createTestLinode } from 'support/util/linodes'; // StackScript fixture paths. const stackscriptBasicPath = 'stackscripts/stackscript-basic.sh'; @@ -113,7 +112,7 @@ const createLinodeAndImage = async () => { // 1.5GB // Shout out to Debian for fitting on a 1.5GB disk. const resizedDiskSize = 1536; - const linode = await createLinode( + const linode = await createTestLinode( createLinodeRequestFactory.build({ label: randomLabel(), region: chooseRegion().id, @@ -134,6 +133,7 @@ const createLinodeAndImage = async () => { const image = await createImage({ disk_id: diskId, + label: randomLabel(), }); await pollImageStatus( @@ -169,7 +169,7 @@ describe('Create stackscripts', () => { const stackscriptImageTag = 'alpine3.19'; const linodeLabel = randomLabel(); - const linodeRegion = chooseRegion(); + const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); interceptCreateStackScript().as('createStackScript'); interceptGetStackScripts().as('getStackScripts'); @@ -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) => { @@ -372,7 +372,10 @@ describe('Create stackscripts', () => { .click(); interceptCreateLinode().as('createLinode'); - fillOutLinodeForm(linodeLabel, chooseRegion().label); + fillOutLinodeForm( + linodeLabel, + chooseRegion({ capabilities: ['Vlans'] }).label + ); ui.button .findByTitle('Create Linode') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts index a8176d26e80..4738d91e962 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts @@ -23,7 +23,7 @@ describe('Delete stackscripts', () => { cy.wait('@getStackScripts'); // Do nothing when cancelling - cy.get(`[aria-label="${stackScripts[0].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`) .closest('tr') .within(() => { ui.actionMenu @@ -47,7 +47,7 @@ describe('Delete stackscripts', () => { }); // The StackScript is deleted successfully. - cy.get(`[aria-label="${stackScripts[0].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`) .closest('tr') .within(() => { ui.actionMenu @@ -73,7 +73,7 @@ describe('Delete stackscripts', () => { cy.findByText(stackScripts[0].label).should('not.exist'); // The "Automate Deployment with StackScripts!" welcome page appears when no StackScript exists. - cy.get(`[aria-label="${stackScripts[1].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[1].label}"]`) .closest('tr') .within(() => { ui.actionMenu 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 2ebd072b40e..9bc796d2c6e 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 @@ -11,10 +11,12 @@ import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; import { interceptCreateLinode } from 'support/intercepts/linodes'; -import { getProfile } from '@linode/api-v4/lib'; -import { Profile, StackScript } from '@linode/api-v4'; +import { getProfile } 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'); + }); + }); + } }); /* @@ -254,7 +260,7 @@ describe('Community Stackscripts integration tests', () => { const fairPassword = 'Akamai123'; const rootPassword = randomString(16); const image = 'AlmaLinux 9'; - const region = chooseRegion(); + const region = chooseRegion({ capabilities: ['Vlans'] }); const linodeLabel = randomLabel(); interceptGetStackScripts().as('getStackScripts'); diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts index 2727f612e2c..dd039c7bb1d 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts @@ -36,7 +36,7 @@ describe('Display stackscripts', () => { cy.wait('@getStackScripts'); stackScripts.forEach((stackScript) => { - cy.get(`[aria-label="${stackScript.label}"]`) + cy.get(`[data-qa-table-row="${stackScript.label}"]`) .closest('tr') .within(() => { cy.findByText(stackScript.deployments_total).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index 32144f33cab..7e52b8c6362 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -95,14 +95,12 @@ describe('Update stackscripts', () => { cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); mockGetStackScript(stackScripts[0].id, stackScripts[0]).as( 'getStackScript' ); @@ -205,14 +203,12 @@ describe('Update stackscripts', () => { cy.wait('@getStackScripts'); // Do nothing when cancelling - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); ui.actionMenuItem .findByTitle('Make StackScript Public') .should('be.visible') @@ -234,14 +230,12 @@ describe('Update stackscripts', () => { }); // The status of the StackScript will become public - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); ui.actionMenuItem .findByTitle('Make StackScript Public') .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 42adfdfd0ec..6e2835aa20b 100644 --- a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts @@ -1,4 +1,3 @@ -import { createLinode } from '@linode/api-v4/lib/linodes'; import { createVolume } from '@linode/api-v4/lib/volumes'; import { Linode, Volume } from '@linode/api-v4'; import { createLinodeRequestFactory } from 'src/factories/linodes'; @@ -13,6 +12,7 @@ import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; import { interceptGetLinodeConfigs } from 'support/intercepts/configs'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -48,7 +48,7 @@ const pageSizeOverride = { authenticate(); describe('volume attach and detach flows', () => { before(() => { - cleanUp('volumes'); + cleanUp(['volumes', 'linodes']); }); /* @@ -66,14 +66,15 @@ describe('volume attach and detach flows', () => { label: randomLabel(), region: commonRegion.id, root_pass: randomString(32), + booted: false, }); const entityPromise = Promise.all([ createVolume(volumeRequest), - createLinode(linodeRequest), + 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 5304208c626..3041575a061 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -1,5 +1,5 @@ import type { Linode } from '@linode/api-v4'; -import { createLinode } from '@linode/api-v4/lib/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { createLinodeRequestFactory } from 'src/factories/linodes'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; @@ -76,6 +76,7 @@ describe('volume create flow', () => { label: randomLabel(), region: region.id, root_pass: randomString(16), + booted: false, }); const volume = { @@ -85,54 +86,56 @@ describe('volume create flow', () => { regionLabel: region.label, }; - cy.defer(createLinode(linodeRequest), 'creating Linode').then((linode) => { - interceptCreateVolume().as('createVolume'); + cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( + (linode) => { + interceptCreateVolume().as('createVolume'); - cy.visitWithLogin('/volumes/create', { - localStorageOverrides: pageSizeOverride, - }); - - // Fill out and submit volume create form. - containsClick('Label').type(volume.label); - containsClick('Size').type(`{selectall}{backspace}${volume.size}`); - ui.regionSelect.find().click().type(`${volume.region}{enter}`); - - cy.findByLabelText('Linode') - .should('be.visible') - .click() - .type(linode.label); - - ui.autocompletePopper - .findByTitle(linode.label) - .should('be.visible') - .click(); - - fbtClick('Create Volume'); - cy.wait('@createVolume'); - - // Confirm volume configuration drawer opens, then close it. - fbtVisible('Volume scheduled for creation.'); - getClick('[data-qa-close-drawer="true"]'); - - // Confirm that volume is listed on landing page with expected configuration. - cy.findByText(volume.label) - .closest('tr') - .within(() => { - cy.findByText(volume.label).should('be.visible'); - cy.findByText(`${volume.size} GB`).should('be.visible'); - cy.findByText(volume.regionLabel).should('be.visible'); - cy.findByText(linode.label).should('be.visible'); + cy.visitWithLogin('/volumes/create', { + localStorageOverrides: pageSizeOverride, }); - // Confirm that volume is listed on Linode 'Storage' details page. - cy.visitWithLogin(`/linodes/${linode.id}/storage`); - cy.findByText(volume.label) - .closest('tr') - .within(() => { - fbtVisible(volume.label); - fbtVisible(`${volume.size} GB`); - }); - }); + // Fill out and submit volume create form. + containsClick('Label').type(volume.label); + containsClick('Size').type(`{selectall}{backspace}${volume.size}`); + ui.regionSelect.find().click().type(`${volume.region}{enter}`); + + cy.findByLabelText('Linode') + .should('be.visible') + .click() + .type(linode.label); + + ui.autocompletePopper + .findByTitle(linode.label) + .should('be.visible') + .click(); + + fbtClick('Create Volume'); + cy.wait('@createVolume'); + + // Confirm volume configuration drawer opens, then close it. + fbtVisible('Volume scheduled for creation.'); + getClick('[data-qa-close-drawer="true"]'); + + // Confirm that volume is listed on landing page with expected configuration. + cy.findByText(volume.label) + .closest('tr') + .within(() => { + cy.findByText(volume.label).should('be.visible'); + cy.findByText(`${volume.size} GB`).should('be.visible'); + cy.findByText(volume.regionLabel).should('be.visible'); + cy.findByText(linode.label).should('be.visible'); + }); + + // Confirm that volume is listed on Linode 'Storage' details page. + cy.visitWithLogin(`/linodes/${linode.id}/storage`); + cy.findByText(volume.label) + .closest('tr') + .within(() => { + fbtVisible(volume.label); + fbtVisible(`${volume.size} GB`); + }); + } + ); }); /* @@ -145,9 +148,10 @@ describe('volume create flow', () => { label: randomLabel(), root_pass: randomString(16), region: chooseRegion().id, + booted: false, }); - cy.defer(createLinode(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/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index cc8341ef251..e7672f90c3c 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -120,87 +120,18 @@ describe('VPC details page', () => { cy.findByText('Create a private and isolated network'); }); - /** - * - Confirms Subnets section and table is shown on the VPC details page - * - Confirms UI flow when deleting a subnet from a VPC's detail page - */ - it('can delete a subnet from the VPC details page', () => { - const mockSubnet = subnetFactory.build({ - id: randomNumber(), - label: randomLabel(), - linodes: [], - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - label: randomLabel(), - subnets: [mockSubnet], - }); - - const mockVPCAfterSubnetDeletion = vpcFactory.build({ - ...mockVPC, - subnets: [], - }); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - - mockGetVPC(mockVPC).as('getVPC'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); - mockDeleteSubnet(mockVPC.id, mockSubnet.id).as('deleteSubnet'); - - cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); - - // confirm that vpc and subnet details get displayed - cy.findByText(mockVPC.label).should('be.visible'); - cy.findByText('Subnets (1)').should('be.visible'); - cy.findByText(mockSubnet.label).should('be.visible'); - - // confirm that subnet can be deleted and that page reflects changes - ui.actionMenu - .findByTitle(`Action menu for Subnet ${mockSubnet.label}`) - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - - mockGetVPC(mockVPCAfterSubnetDeletion).as('getVPC'); - mockGetSubnets(mockVPC.id, []).as('getSubnets'); - - ui.dialog - .findByTitle(`Delete Subnet ${mockSubnet.label}`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Subnet Label') - .should('be.visible') - .click() - .type(mockSubnet.label); - - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait(['@deleteSubnet', '@getVPC', '@getSubnets']); - - // confirm that user should still be on VPC's detail page - // confirm there are no remaining subnets - cy.url().should('endWith', `/${mockVPC.id}`); - cy.findByText('Subnets (0)'); - cy.findByText('No Subnets are assigned.'); - cy.findByText(mockSubnet.label).should('not.exist'); - }); - /** * - Confirms UI flow when creating a subnet on a VPC's detail page. + * - Confirms UI flow for editing a subnet. + * - Confirms Subnets section and table is shown on the VPC details page. + * - Confirms UI flow when deleting a subnet from a VPC's detail page. */ - it('can create a subnet', () => { + it('can create, edit, and delete a subnet from the VPC details page', () => { + // create a subnet const mockSubnet = subnetFactory.build({ id: randomNumber(), label: randomLabel(), + linodes: [], }); const mockVPC = vpcFactory.build({ @@ -256,22 +187,8 @@ describe('VPC details page', () => { cy.findByText(mockVPC.label).should('be.visible'); cy.findByText('Subnets (1)').should('be.visible'); cy.findByText(mockSubnet.label).should('be.visible'); - }); - - /** - * - Confirms UI flow for editing a subnet - */ - it('can edit a subnet', () => { - const mockSubnet = subnetFactory.build({ - id: randomNumber(), - label: randomLabel(), - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - label: randomLabel(), - subnets: [mockSubnet], - }); + // edit a subnet const mockEditedSubnet = subnetFactory.build({ ...mockSubnet, label: randomLabel(), @@ -282,22 +199,6 @@ describe('VPC details page', () => { subnets: [mockEditedSubnet], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - - mockGetVPC(mockVPC).as('getVPC'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); - - cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); - - // confirm that vpc and subnet details get displayed - cy.findByText(mockVPC.label).should('be.visible'); - cy.findByText('Subnets (1)').should('be.visible'); - cy.findByText(mockSubnet.label).should('be.visible'); - // confirm that subnet can be edited and that page reflects changes mockEditSubnet(mockVPC.id, mockEditedSubnet.id, mockEditedSubnet).as( 'editSubnet' @@ -336,5 +237,47 @@ describe('VPC details page', () => { cy.findByText(mockVPC.label).should('be.visible'); cy.findByText('Subnets (1)').should('be.visible'); cy.findByText(mockEditedSubnet.label).should('be.visible'); + + // delete a subnet + const mockVPCAfterSubnetDeletion = vpcFactory.build({ + ...mockVPC, + subnets: [], + }); + mockDeleteSubnet(mockVPC.id, mockEditedSubnet.id).as('deleteSubnet'); + + // confirm that subnet can be deleted and that page reflects changes + ui.actionMenu + .findByTitle(`Action menu for Subnet ${mockEditedSubnet.label}`) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + mockGetVPC(mockVPCAfterSubnetDeletion).as('getVPC'); + mockGetSubnets(mockVPC.id, []).as('getSubnets'); + + ui.dialog + .findByTitle(`Delete Subnet ${mockEditedSubnet.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Subnet Label') + .should('be.visible') + .click() + .type(mockEditedSubnet.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSubnet', '@getVPC', '@getSubnets']); + + // confirm that user should still be on VPC's detail page + // confirm there are no remaining subnets + cy.url().should('endWith', `/${mockVPC.id}`); + cy.findByText('Subnets (0)'); + cy.findByText('No Subnets are assigned.'); + cy.findByText(mockEditedSubnet.label).should('not.exist'); }); }); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index cf132d44e2c..1c030b31225 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -192,8 +192,8 @@ describe('VPC assign/unassign flows', () => { .click(); }); - cy.get('[aria-label="View Details"]') - .closest('tbody') + cy.get('[data-qa-table-row="collapsible-table-headers-row"]') + .siblings('tbody') .within(() => { // after assigning Linode(s) to a VPC, VPC page increases number in 'Linodes' column cy.findByText('1').should('be.visible'); 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 e324a416e03..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 @@ -1,43 +1,17 @@ -import type { CreateLinodeRequest, Disk, Linode } from '@linode/api-v4'; -import { createLinode, getLinodeDisks } from '@linode/api-v4'; +import type { Disk, Linode } from '@linode/api-v4'; +import { createTestLinode } from 'support/util/linodes'; import { createLinodeRequestFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { imageCaptureProcessingTimeout } from 'support/constants/images'; import { ui } from 'support/ui'; -import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; -import { depaginate } from 'support/util/paginate'; -import { pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomPhrase, randomString } from 'support/util/random'; import { testRegions } from 'support/util/regions'; -/** - * Creates a Linode, waits for it to boot, and returns the Linode and its disk. - * - * @param linodePayload - Linode create API request payload. - * - * @returns Promise that resolves to a tuple containing the created Linode and its disk. - */ -const createAndBootLinode = async ( - linodePayload: CreateLinodeRequest -): Promise<[Linode, Disk]> => { - const linode = await createLinode(linodePayload); - // Wait 25 seconds to begin polling, then poll every 5 seconds until Linode boots. - await pollLinodeStatus( - linode.id, - 'running', - new SimpleBackoffMethod(5000, { - initialDelay: 25000, - }) - ); - const disks = await depaginate((page) => getLinodeDisks(linode.id, { page })); - return [linode, disks[0]]; -}; - authenticate(); describe('Capture Machine Images', () => { before(() => { - cleanUp('images'); + cleanUp(['images', 'linodes']); }); /* @@ -54,10 +28,11 @@ describe('Capture Machine Images', () => { label: randomLabel(), root_pass: randomString(32), region: region.id, + booted: true, }); cy.defer( - createAndBootLinode(linodePayload), + () => 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 0c92f09ce01..c0ccb540084 100644 --- a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts @@ -2,7 +2,6 @@ import { createLinodeRequestFactory } from '@src/factories'; import { describeRegions } from 'support/util/regions'; import { randomLabel, randomString } from 'support/util/random'; import { Region } from '@linode/api-v4'; -import { createLinode } from '@linode/api-v4'; import type { Linode } from '@linode/api-v4'; import { ui } from 'support/ui'; import { authenticate } from 'support/api/authentication'; @@ -11,6 +10,7 @@ import { interceptGetLinodes, } from 'support/intercepts/linodes'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; authenticate(); describeRegions('Delete Linodes', (region: Region) => { @@ -28,11 +28,12 @@ describeRegions('Delete Linodes', (region: Region) => { label: randomLabel(), region: region.id, root_pass: randomString(32), + booted: false, }); // Create a Linode before navigating to its details page to delete it. cy.defer( - createLinode(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 a62e9e7bdd5..5bfdfb78130 100644 --- a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts @@ -1,5 +1,5 @@ import type { Disk, Linode } from '@linode/api-v4'; -import { createLinode, getLinodeDisks } from '@linode/api-v4'; +import { getLinodeDisks } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { interceptGetLinodeDetails } from 'support/intercepts/linodes'; @@ -8,6 +8,7 @@ import { cleanUp } from 'support/util/cleanup'; import { depaginate } from 'support/util/paginate'; import { randomLabel, randomString } from 'support/util/random'; import { describeRegions } from 'support/util/regions'; +import { createTestLinode } from 'support/util/linodes'; /* * Returns a Linode create payload for the given region. @@ -34,7 +35,7 @@ describeRegions('Can update Linodes', (region) => { */ it('can update a Linode label', () => { cy.defer( - createLinode(makeLinodePayload(region.id, true)), + () => createTestLinode(makeLinodePayload(region.id, true)), 'creating Linode' ).then((linode: Linode) => { const newLabel = randomLabel(); @@ -89,7 +90,9 @@ describeRegions('Can update Linodes', (region) => { const newPassword = randomString(32); const createLinodeAndGetDisk = async (): Promise<[Linode, Disk]> => { - const linode = await createLinode(makeLinodePayload(region.id, false)); + const linode = await createTestLinode( + makeLinodePayload(region.id, false) + ); const disks = await depaginate((page) => getLinodeDisks(linode.id, { page }) ); @@ -101,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/fixtures/user-data/user-data-config-basic.yml b/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml new file mode 100644 index 00000000000..b8bc4e3163e --- /dev/null +++ b/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml @@ -0,0 +1,11 @@ +#cloud-config + +# Sample cloud-init config data file. +# See also: https://cloudinit.readthedocs.io/en/latest/explanation/format.html + +groups: + - foo-group + +users: + - name: foo + primary_group: foo-group diff --git a/packages/manager/cypress/support/api/domains.ts b/packages/manager/cypress/support/api/domains.ts index f57b470c229..036906273e2 100644 --- a/packages/manager/cypress/support/api/domains.ts +++ b/packages/manager/cypress/support/api/domains.ts @@ -1,17 +1,15 @@ -import { - Domain, - deleteDomain, - getDomains, - CreateDomainPayload, -} from '@linode/api-v4'; -import { createDomainPayloadFactory } from 'src/factories'; +import { deleteDomain, getDomains } from '@linode/api-v4'; import { isTestLabel } from 'support/api/common'; import { oauthToken, pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; import { randomDomainName } from 'support/util/random'; +import { createDomainPayloadFactory } from 'src/factories'; + import { apiCheckErrors } from './common'; +import type { CreateDomainPayload, Domain } from '@linode/api-v4'; + /** * Deletes all domains which are prefixed with the test entity prefix. * diff --git a/packages/manager/cypress/support/api/firewalls.ts b/packages/manager/cypress/support/api/firewalls.ts index 4c48bf3c16d..5a31465b87d 100644 --- a/packages/manager/cypress/support/api/firewalls.ts +++ b/packages/manager/cypress/support/api/firewalls.ts @@ -1,9 +1,71 @@ -import { Firewall, deleteFirewall, getFirewalls } from '@linode/api-v4'; +import { + Firewall, + deleteFirewall, + getFirewalls, + createFirewall, + FirewallRules, +} from '@linode/api-v4'; import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; +import { randomLabel } from 'support/util/random'; import { isTestLabel } from './common'; +/** + * Determines if Firewall rules are sufficiently locked down to use for a test resource. + * + * Returns `true` if the rules have default inbound and outbound policies to + * drop connections and do not have any additional rules. + * + * @param rules - Firewall rules to assess. + * + * @returns `true` if Firewall rules are locked down, `false` otherwise. + */ +export const areFirewallRulesLockedDown = (rules: FirewallRules) => { + const { outbound, outbound_policy, inbound, inbound_policy } = rules; + + const hasOutboundRules = !!outbound && outbound.length > 0; + const hasInboundRules = !!inbound && inbound.length > 0; + + return ( + outbound_policy === 'DROP' && + inbound_policy === 'DROP' && + !hasInboundRules && + !hasOutboundRules + ); +}; + +/** + * Returns a firewall to use for a test resource, creating it if one does not already exist. + * + * @returns Promise that resolves to existing or new Firewall. + */ +export const findOrCreateDependencyFirewall = async () => { + const firewalls = await depaginate((page: number) => + getFirewalls({ page, page_size: pageSize }) + ); + + const suitableFirewalls = firewalls.filter( + ({ label, rules }: Firewall) => + isTestLabel(label) && areFirewallRulesLockedDown(rules) + ); + + if (suitableFirewalls.length > 0) { + return suitableFirewalls[0]; + } + + // No suitable firewalls exist, so we'll create one and return it. + return createFirewall({ + label: randomLabel(), + rules: { + inbound: [], + outbound: [], + inbound_policy: 'DROP', + outbound_policy: 'DROP', + }, + }); +}; + /** * Deletes all Firewalls whose labels are prefixed "cy-test-". * diff --git a/packages/manager/cypress/support/api/linodes.ts b/packages/manager/cypress/support/api/linodes.ts index 6865519b8ac..9fbe30f3302 100644 --- a/packages/manager/cypress/support/api/linodes.ts +++ b/packages/manager/cypress/support/api/linodes.ts @@ -1,14 +1,10 @@ import { Linode, deleteLinode, getLinodes } from '@linode/api-v4'; -import { CreateLinodeRequest } from '@linode/api-v4'; import { linodeFactory } from '@src/factories'; import { makeResourcePage } from '@src/mocks/serverHandlers'; -import { oauthToken, pageSize } from 'support/constants/api'; -import { entityTag } from 'support/constants/cypress'; +import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; -import { randomLabel, randomString } from 'support/util/random'; -import { chooseRegion } from 'support/util/regions'; -import { apiCheckErrors, deleteById, isTestLabel } from './common'; +import { deleteById, isTestLabel } from './common'; export const createMockLinodeList = (data?: {}, listNumber: number = 1) => { return makeResourcePage( @@ -18,47 +14,6 @@ export const createMockLinodeList = (data?: {}, listNumber: number = 1) => { ); }; -const defaultLinodeRequestBody = { - authorized_users: [], - backups_enabled: false, - booted: true, - image: 'linode/debian10', - private_ip: true, - region: chooseRegion().id, - root_pass: randomString(32), - tags: [entityTag], - type: 'g6-standard-2', -}; - -const linodeRequest = (linodeData: CreateLinodeRequest) => { - return cy.request({ - auth: { - bearer: oauthToken, - }, - body: linodeData, - method: 'POST', - url: Cypress.env('REACT_APP_API_ROOT') + '/linode/instances', - }); -}; - -export const requestBody = (data: Partial) => { - const label = randomLabel(); - return linodeRequest({ label, ...defaultLinodeRequestBody, ...data }); -}; - -/** - * Deprecated. Use `createTestLinode()` with `cy.defer()` instead. - * - * @deprecated - */ -export const createLinode = (data = {}) => { - return requestBody(data).then((resp) => { - apiCheckErrors(resp); - console.log(`Created Linode ${resp.body.label} successfully`, resp); - return resp.body; - }); -}; - export const deleteLinodeById = (linodeId: number) => deleteById('linode/instances', linodeId); diff --git a/packages/manager/cypress/support/constants/account.ts b/packages/manager/cypress/support/constants/account.ts index 7ca6940fbf1..2ef3525eaf7 100644 --- a/packages/manager/cypress/support/constants/account.ts +++ b/packages/manager/cypress/support/constants/account.ts @@ -12,3 +12,20 @@ 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.'; + +/** + * Helper text that appears above the login history table. + */ +export const loginHelperText = + 'Logins across all users on your account over the last 90 days.'; + +/** + * Empty state message that appears when there is no item in the login history table. + */ +export const loginEmptyStateMessageText = 'No account logins'; diff --git a/packages/manager/cypress/support/constants/cypress.ts b/packages/manager/cypress/support/constants/cypress.ts index 3df9ce61a59..c5ab5c563da 100644 --- a/packages/manager/cypress/support/constants/cypress.ts +++ b/packages/manager/cypress/support/constants/cypress.ts @@ -3,7 +3,7 @@ */ /** - * Tag to use to identify test entities, resources, etc. + * Tag to identify test entities, resources, etc. */ export const entityTag = 'cy-test'; diff --git a/packages/manager/cypress/support/constants/databases.ts b/packages/manager/cypress/support/constants/databases.ts index 4957b0cec4a..e463f331750 100644 --- a/packages/manager/cypress/support/constants/databases.ts +++ b/packages/manager/cypress/support/constants/databases.ts @@ -46,10 +46,281 @@ export const mockDatabaseNodeTypes: DatabaseType[] = [ databaseTypeFactory.build({ class: 'nanode', id: 'g6-nanode-1', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.0225, + monthly: 15, + }, + quantity: 3, + }, + ], + }, + memory: 1024, + disk: 25600, + vcpus: 1, + label: 'Nanode 1 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-2', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.2925, + monthly: 195, + }, + quantity: 3, + }, + ], + }, + memory: 4096, + disk: 81920, + vcpus: 2, + label: 'Dedicated 4 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-4', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.585, + monthly: 390.0, + }, + quantity: 3, + }, + ], + }, + memory: 8192, + disk: 163840, + vcpus: 4, + label: 'Dedicated 8 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-8', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 1.17, + monthly: 780, + }, + quantity: 3, + }, + ], + }, + memory: 16384, + disk: 327680, + vcpus: 6, + label: 'Dedicated 16 GB', }), databaseTypeFactory.build({ class: 'dedicated', id: 'g6-dedicated-16', + engines: { + mysql: [ + { + price: { + hourly: 2.34, + monthly: 1560.0, + }, + quantity: 3, + }, + ], + }, + memory: 32768, + disk: 655360, + vcpus: 8, + label: 'Dedicated 32 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-32', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 4.68, + monthly: 3120.0, + }, + quantity: 3, + }, + ], + }, + memory: 65536, + disk: 1310720, + vcpus: 16, + label: 'Dedicated 64 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-48', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 7.02, + monthly: 4680, + }, + quantity: 3, + }, + ], + }, + memory: 98304, + disk: 1966080, + vcpus: 20, + label: 'Dedicated 96 GB', + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-1', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.09, + monthly: 60, + }, + quantity: 3, + }, + ], + }, + disk: 51200, + label: 'Linode 2 GB', + memory: 2048, + vcpus: 1, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-2', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.18, + monthly: 120, + }, + quantity: 3, + }, + ], + }, + disk: 81920, + label: 'Linode 4 GB', + memory: 4096, + vcpus: 2, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-4', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.36, + monthly: 240, + }, + quantity: 3, + }, + ], + }, + disk: 163840, + label: 'Linode 8 GB', + memory: 8192, + vcpus: 4, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-6', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.84, + monthly: 560.0, + }, + quantity: 3, + }, + ], + }, + disk: 327680, + label: 'Linode 16 GB', + memory: 16384, + vcpus: 6, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-8', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 1.68, + monthly: 1120.0, + }, + quantity: 3, + }, + ], + }, + disk: 655360, + label: 'Linode 32 GB', + memory: 32768, + vcpus: 8, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-16', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 3.336, + monthly: 2224.0, + }, + quantity: 3, + }, + ], + }, + disk: 1310720, + label: 'Linode 64 GB', + memory: 65536, + vcpus: 16, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-20', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 5.04, + monthly: 3360.0, + }, + quantity: 3, + }, + ], + }, + disk: 1966080, + label: 'Linode 96 GB', + memory: 98304, + vcpus: 20, }), ]; @@ -69,7 +340,7 @@ export const databaseConfigurations: databaseClusterConfiguration[] = [ dbType: 'mysql', engine: 'MySQL', label: randomLabel(), - linodeType: 'g6-dedicated-16', + linodeType: 'g6-dedicated-2', region: chooseRegion({ capabilities: ['Managed Databases'] }), version: '5', }, @@ -93,3 +364,24 @@ export const databaseConfigurations: databaseClusterConfiguration[] = [ version: '13', }, ]; + +export const databaseConfigurationsResize: databaseClusterConfiguration[] = [ + { + clusterSize: 3, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g6-standard-6', + region: chooseRegion({ capabilities: ['Managed Databases'] }), + version: '8', + }, + { + clusterSize: 3, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g6-dedicated-16', + region: chooseRegion({ capabilities: ['Managed Databases'] }), + version: '5', + }, +]; diff --git a/packages/manager/cypress/support/constants/domains.ts b/packages/manager/cypress/support/constants/domains.ts index 0eedbece6c2..e08003c7dab 100644 --- a/packages/manager/cypress/support/constants/domains.ts +++ b/packages/manager/cypress/support/constants/domains.ts @@ -1,90 +1,91 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import { - randomLabel, + randomDomainName, randomIp, + randomLabel, randomString, - randomDomainName, } from 'support/util/random'; // Array of domain records for which to test creation. export const createDomainRecords = () => [ { - name: 'Add an A/AAAA Record', - tableAriaLabel: 'List of Domains A/AAAA Record', fields: [ { name: '[data-qa-target="Hostname"]', - value: randomLabel(), skipCheck: false, + value: randomLabel(), }, { name: '[data-qa-target="IP Address"]', - value: `${randomIp()}`, skipCheck: false, + value: randomIp(), }, ], + name: 'Add an A/AAAA Record', + tableAriaLabel: 'List of Domains A/AAAA Record', }, { - name: 'Add a CNAME Record', - tableAriaLabel: 'List of Domains CNAME Record', fields: [ { name: '[data-qa-target="Hostname"]', - value: randomLabel(), skipCheck: false, + value: randomLabel(), }, { name: '[data-qa-target="Alias to"]', - value: `${randomLabel()}.net`, skipCheck: false, + value: `${randomLabel()}.net`, }, ], + name: 'Add a CNAME Record', + tableAriaLabel: 'List of Domains CNAME Record', }, { - name: 'Add a TXT Record', - tableAriaLabel: 'List of Domains TXT Record', fields: [ { name: '[data-qa-target="Hostname"]', - value: randomLabel(), skipCheck: false, + value: randomLabel(), }, { name: '[data-qa-target="Value"]', - value: `${randomLabel()}=${randomString()}`, skipCheck: false, + value: `${randomLabel()}=${randomString()}`, }, ], + name: 'Add a TXT Record', + tableAriaLabel: 'List of Domains TXT Record', }, { - name: 'Add an SRV Record', - tableAriaLabel: 'List of Domains SRV Record', fields: [ { name: '[data-qa-target="Service"]', - value: randomLabel(), skipCheck: true, + value: randomLabel(), }, { + approximate: true, name: '[data-qa-target="Target"]', value: randomLabel(), - approximate: true, }, ], + name: 'Add an SRV Record', + tableAriaLabel: 'List of Domains SRV Record', }, { - name: 'Add a CAA Record', - tableAriaLabel: 'List of Domains CAA Record', fields: [ { name: '[data-qa-target="Name"]', - value: randomLabel(), skipCheck: false, + value: randomLabel(), }, { name: '[data-qa-target="Value"]', - value: randomDomainName(), skipCheck: false, + value: randomDomainName(), }, ], + name: 'Add a CAA Record', + tableAriaLabel: 'List of Domains CAA Record', }, ]; diff --git a/packages/manager/cypress/support/constants/environment.ts b/packages/manager/cypress/support/constants/environment.ts new file mode 100644 index 00000000000..545bcd9bc8a --- /dev/null +++ b/packages/manager/cypress/support/constants/environment.ts @@ -0,0 +1,21 @@ +/** + * @file Constants related to test environment. + */ + +export interface ViewportSize { + width: number; + height: number; + label?: string; +} + +// Array of common mobile viewports against which to test. +export const MOBILE_VIEWPORTS: ViewportSize[] = [ + { + // iPhone 14 Pro, iPhone 15, iPhone 15 Pro, etc. + label: 'iPhone 15', + width: 393, + height: 852, + }, + // TODO Evaluate what devices to include here and how long to allow this list to be. Tablets? + // Do we want to keep this short, or make it long and just choose a random subset each time we do mobile testing? +]; diff --git a/packages/manager/cypress/support/constants/linodes.ts b/packages/manager/cypress/support/constants/linodes.ts new file mode 100644 index 00000000000..f6eb377242c --- /dev/null +++ b/packages/manager/cypress/support/constants/linodes.ts @@ -0,0 +1,10 @@ +/** + * @file Constants related to Linode tests. + */ + +/** + * Length of time to wait for a Linode to be created. + * + * Equals 3 minutes. + */ +export const LINODE_CREATE_TIMEOUT = 180_000; 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/databases.ts b/packages/manager/cypress/support/intercepts/databases.ts index 4c9ce05951a..72cff995b53 100644 --- a/packages/manager/cypress/support/intercepts/databases.ts +++ b/packages/manager/cypress/support/intercepts/databases.ts @@ -126,6 +126,42 @@ export const mockUpdateDatabase = ( ); }; +/** + * Intercepts POST request to reset an active database's password and mocks response. + * + * @param id - Database ID. + * @param engine - Database engine type. + * + * @returns Cypress chainable. + */ + +export const mockResize = ( + id: number, + engine: string, + responseData: any = {} +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`databases/${engine}/instances/${id}`), + responseData + ); +}; + +export const mockResizeProvisioningDatabase = ( + id: number, + engine: string, + responseErrorMessage?: string | undefined +): Cypress.Chainable => { + const error = makeErrorResponse( + responseErrorMessage || defaultErrorMessageProvisioning + ); + return cy.intercept( + 'PUT', + apiMatcher(`databases/${engine}/instances/${id}`), + error + ); +}; + /** * Intercepts PUT request to update a provisioning database and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/images.ts b/packages/manager/cypress/support/intercepts/images.ts index 244e969867a..9e4a0f7a2bb 100644 --- a/packages/manager/cypress/support/intercepts/images.ts +++ b/packages/manager/cypress/support/intercepts/images.ts @@ -2,12 +2,12 @@ * @file Cypress intercepts and mocks for Image API requests. */ -import { imageFactory } from '@src/factories'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { getFilters } from 'support/util/request'; -import type { Image, ImageStatus } from '@linode/api-v4'; +import type { Image } from '@linode/api-v4'; +import { makeResponse } from 'support/util/response'; /** * Intercepts POST request to create a machine image and mocks the response. @@ -54,9 +54,7 @@ export const mockGetCustomImages = ( const filters = getFilters(req); if (filters?.type === 'manual') { req.reply(paginateResponse(images)); - return; } - req.continue(); }); }; @@ -74,9 +72,7 @@ export const mockGetRecoveryImages = ( const filters = getFilters(req); if (filters?.type === 'automatic') { req.reply(paginateResponse(images)); - return; } - req.continue(); }); }; @@ -92,20 +88,15 @@ export const mockGetRecoveryImages = ( * @returns Cypress chainable. */ export const mockGetImage = ( - label: string, - id: string, - status: ImageStatus + imageId: string, + image: Image ): Cypress.Chainable => { - const encodedId = encodeURIComponent(id); - return cy.intercept('GET', apiMatcher(`images/${encodedId}*`), (req) => { - return req.reply( - imageFactory.build({ - id, - label, - status, - }) - ); - }); + const encodedId = encodeURIComponent(imageId); + return cy.intercept( + 'GET', + apiMatcher(`images/${encodedId}*`), + makeResponse(image) + ); }; /** @@ -135,3 +126,23 @@ export const mockDeleteImage = (id: string): Cypress.Chainable => { const encodedId = encodeURIComponent(id); return cy.intercept('DELETE', apiMatcher(`images/${encodedId}`), {}); }; + +/** + * Intercepts POST request to update an image's regions and mocks the response. + * + * @param id - ID of image + * @param updatedImage - Updated image with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateImageRegions = ( + id: string, + updatedImage: Image +): Cypress.Chainable => { + const encodedId = encodeURIComponent(id); + return cy.intercept( + 'POST', + apiMatcher(`images/${encodedId}/regions`), + updatedImage + ); +}; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 932e1bc0bec..8dfa481462a 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -2,20 +2,29 @@ * @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 { linodeVlanNoInternetConfig } from 'support/util/linodes'; -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. * + * The outgoing request payload is modified to create a Linode without access + * to the internet. + * * @returns Cypress chainable. */ export const interceptCreateLinode = (): Cypress.Chainable => { - return cy.intercept('POST', apiMatcher('linode/instances')); + return cy.intercept('POST', apiMatcher('linode/instances'), (req) => { + req.body = { + ...req.body, + interfaces: linodeVlanNoInternetConfig, + }; + }); }; /** @@ -210,6 +219,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/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts index d0cb2c742fc..2bc3eaf27a9 100644 --- a/packages/manager/cypress/support/intercepts/longview.ts +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -2,7 +2,10 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import { LongviewClient } from '@linode/api-v4'; -import { LongviewResponse } from 'src/features/Longview/request.types'; +import type { + LongviewAction, + LongviewResponse, +} from 'src/features/Longview/request.types'; /** * Intercepts request to retrieve Longview status for a Longview client. @@ -16,15 +19,38 @@ export const interceptFetchLongviewStatus = (): Cypress.Chainable => { /** * Mocks request to retrieve Longview status for a Longview client. * + * @param client - Longview Client for which to intercept Longview fetch request. + * @param apiAction - Longview API action to intercept. + * @param mockStatus - + * * @returns Cypress chainable. */ export const mockFetchLongviewStatus = ( - status: LongviewResponse + client: LongviewClient, + apiAction: LongviewAction, + mockStatus: LongviewResponse ): Cypress.Chainable => { return cy.intercept( - 'POST', - 'https://longview.linode.com/fetch', - makeResponse(status) + { + url: 'https://longview.linode.com/fetch', + method: 'POST', + }, + async (req) => { + const payload = req.body; + const response = new Response(payload, { + headers: { + 'content-type': req.headers['content-type'] as string, + }, + }); + const formData = await response.formData(); + + if ( + formData.get('api_key') === client.api_key && + formData.get('api_action') === apiAction + ) { + req.reply(makeResponse([mockStatus])); + } + } ); }; diff --git a/packages/manager/cypress/support/intercepts/placement-groups.ts b/packages/manager/cypress/support/intercepts/placement-groups.ts index 73c1f92d6b7..be6777dc4b0 100644 --- a/packages/manager/cypress/support/intercepts/placement-groups.ts +++ b/packages/manager/cypress/support/intercepts/placement-groups.ts @@ -1,9 +1,9 @@ +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 { PlacementGroup } from '@linode/api-v4'; -import { makeResponse } from 'support/util/response'; -import { makeErrorResponse } from 'support/util/errors'; /** * Intercepts GET request to fetch Placement Groups and mocks response. @@ -154,3 +154,67 @@ export const mockUnassignPlacementGroupLinodesError = ( makeErrorResponse(errorMessage, errorCode) ); }; + +/** + * Intercepts POST request to delete a Placement Group and mocks an HTTP error response. + * + * By default, a 500 response is mocked. + * + * @param errorMessage - Optional error message with which to mock response. + * @param errorCode - Optional error code with which to mock response. Default is `500`. + * + * @returns Cypress chainable. + */ +export const mockDeletePlacementGroupError = ( + placementGroupId: number, + errorMessage: string = 'An error has occurred', + errorCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`placement/groups/${placementGroupId}`), + makeErrorResponse(errorMessage, errorCode) + ); +}; + +/** + * Intercepts PUT request to update Placement Group label and mocks response. + * + * @param placementGroupId - ID of Placement Group for which to intercept update label request. + * @param placementGroupData - Placement Group object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdatePlacementGroup = ( + placementGroupId: number, + placementGroupData: string +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`placement/groups/${placementGroupId}`), + makeResponse(placementGroupData) + ); +}; + +/** + * Intercepts PUT request to update Placement Group label and mocks HTTP error response. + * + * By default, a 500 response is mocked. + * + * @param placementGroupId - ID of Placement Group for which to intercept update label request. + * @param errorMessage - Optional error message with which to mock response. + * @param errorCode - Optional error code with which to mock response. Default is `500`. + * + * @returns Cypress chainable. + */ +export const mockUpdatePlacementGroupError = ( + placementGroupId: number, + errorMessage: string = 'An error has occurred', + errorCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`placement/groups/${placementGroupId}`), + makeErrorResponse(errorMessage, errorCode) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index 3ed9a751483..e2493f09868 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -15,6 +15,7 @@ import type { SecurityQuestionsPayload, Token, UserPreferences, + SSHKey, } from '@linode/api-v4'; /** @@ -388,3 +389,98 @@ export const mockResetOAuthApps = ( oauthApp ); }; + +/** + * Intercepts GET request to fetch SSH keys and mocks the response. + * + * @param sshKeys - Array of SSH key objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSSHKeys = (sshKeys: SSHKey[]): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('/profile/sshkeys*'), + paginateResponse(sshKeys) + ); +}; + +/** + * Intercepts GET request to fetch an SSH key and mocks the response. + * + * @param sshKey - SSH key object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/profile/sshkeys/${sshKey.id}`), + makeResponse(sshKey) + ); +}; + +/** + * Intercepts POST request to create an SSH key. + * + * @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'), + makeErrorResponse(errorMessage, status) + ); +}; + +/** + * Intercepts PUT request to update an SSH key and mocks response. + * + * @param sshKeyId - The SSH key ID to update + * @param sshKey - An SSH key with which to update. + * + * @returns Cypress chainable. + */ +export const mockUpdateSSHKey = ( + sshKeyId: number, + sshKey: SSHKey +): Cypress.Chainable => { + return cy.intercept('PUT', apiMatcher(`profile/sshkeys/${sshKeyId}`), sshKey); +}; + +/** + * Intercepts DELETE request to delete an SSH key and mocks response. + * + * @param sshKeyId - The SSH key ID to delete + * + * @returns Cypress chainable. + */ +export const mockDeleteSSHKey = (sshKeyId: number): Cypress.Chainable => { + return cy.intercept('DELETE', apiMatcher(`profile/sshkeys/${sshKeyId}`), {}); +}; 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/accordion.ts b/packages/manager/cypress/support/ui/accordion.ts index 523de4ed3fa..31e8f25cd32 100644 --- a/packages/manager/cypress/support/ui/accordion.ts +++ b/packages/manager/cypress/support/ui/accordion.ts @@ -1,3 +1,23 @@ +/** + * UI helpers for accordion panel headings. + */ +export const accordionHeading = { + /** + * Finds an accordion with the given title. + * + * @param title - Title of the accordion header to find. + * + * @returns Cypress chainable. + */ + findByTitle: (title: string) => { + // We have to rely on the selector because some accordion titles contain + // other React components within them. + return cy.findByText(title, { + selector: '[data-qa-panel-subheading], [data-qa-panel-subheading] *', + }); + }, +}; + /** * UI helpers for accordion panels. */ @@ -19,6 +39,13 @@ export const accordion = { * @returns Cypress chainable. */ findByTitle: (title: string) => { - return cy.get(`[data-qa-panel="${title}"]`).find('[data-qa-panel-details]'); + // We have to rely on the selector because some accordion titles contain + // other React components within them. + return cy + .findByText(title, { + selector: '[data-qa-panel-subheading], [data-qa-panel-subheading] *', + }) + .closest('[data-qa-panel]') + .find('[data-qa-panel-details]'); }, }; diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index 9e791402da7..6b69ac11300 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -1,4 +1,3 @@ -import { containsClick, containsVisible, getVisible } from '../helpers'; import { waitForAppLoad } from './common'; export const loadAppNoLogin = (path: string) => waitForAppLoad(path, false); @@ -31,18 +30,15 @@ interface GoWithUI { export interface Page { assertIsLoaded: () => void; - first?: boolean; goWithUI?: GoWithUI[]; name: string; - skip?: boolean; url: string; } // 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: () => { @@ -118,10 +114,10 @@ export const pages: Page[] = [ go: () => { const url = `${routes.profile}/auth`; loadAppNoLogin(url); - getVisible('[data-qa-header="My Profile"]'); - containsVisible( + cy.get('[data-qa-header="My Profile"]').should('be.visible'); + cy.contains( 'How to Enable Third Party Authentication on Your User Account' - ); + ).should('be.visible'); waitDoubleRerender(); cy.contains('Display').should('be.visible').click(); }, @@ -150,7 +146,7 @@ export const pages: Page[] = [ loadAppNoLogin(routes.profile); cy.findByText('Username').should('be.visible'); waitDoubleRerender(); - containsClick('Login & Authentication'); + cy.contains('Login & Authentication').click(); }, name: 'Tab', }, @@ -183,7 +179,7 @@ export const pages: Page[] = [ loadAppNoLogin(routes.profile); cy.findByText('Username'); waitDoubleRerender(); - containsClick('LISH Console Settings'); + cy.contains('LISH Console Settings').click(); }, name: 'Tab', }, @@ -205,7 +201,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'Profile/API tokens', + name: 'Profile/API Tokens', url: `${routes.profile}/tokens`, }, { @@ -216,7 +212,7 @@ export const pages: Page[] = [ }, { assertIsLoaded: () => cy.findByText('Open New Ticket').should('be.visible'), - name: 'Support/tickets', + name: 'Support/Tickets', url: `${routes.supportTickets}`, }, { @@ -230,7 +226,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'Support/tickets/open', + name: 'Support/Tickets/Open', url: `${routes.supportTicketsOpen}`, }, { @@ -244,7 +240,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'Support/tickets/closed', + name: 'Support/Tickets/Closed', url: `${routes.supportTicketsClosed}`, }, { @@ -282,7 +278,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'account/Users', + name: 'Account/Users', url: `${routes.account}/users`, }, { @@ -299,7 +295,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'account/Settings', + name: 'Account/Settings', url: `${routes.account}/settings`, }, ]; diff --git a/packages/manager/cypress/support/ui/pages/index.ts b/packages/manager/cypress/support/ui/pages/index.ts new file mode 100644 index 00000000000..f08f42cd5a9 --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/index.ts @@ -0,0 +1,15 @@ +/** + * @file Index file for Cypress page utility re-exports. + * + * Page utilities are basic JavaScript objects containing functions to perform + * common page-specific interactions. They allow us to minimize code duplication + * across tests that interact with similar pages. + * + * Page utilities are NOT page objects in the traditional UI testing sense. + * Specifically, page utility objects should NOT have state, and page utilities + * should only be concerned with interacting with or asserting the state of + * the DOM. + */ + +export * from './linode-create-page'; +export * from './vpc-create-drawer'; diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts new file mode 100644 index 00000000000..ddd46b2702c --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -0,0 +1,94 @@ +/** + * @file Page utilities for Linode Create page (v2 implementation). + */ + +import { ui } from 'support/ui'; + +/** + * Page utilities for interacting with the Linode create page. + */ +export const linodeCreatePage = { + /** + * Selects the Image with the given name. + * + * @param imageName - Name of Image to select. + */ + selectImage: (imageName: string) => { + cy.findByText('Choose an OS') + .closest('[data-qa-paper]') + .within(() => { + ui.autocomplete.find().click(); + + ui.autocompletePopper + .findByTitle(imageName) + .should('be.visible') + .click(); + }); + }, + + /** + * Select the given Linode plan. + * + * Assumes that plans are displayed in a table. + * + * @param planTabTitle - Title of tab where desired plan is located. + * @param planTitle - Title of desired plan. + */ + selectPlan: (planTabTitle: string, planTitle: string) => { + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + ui.tabList.findTabByTitle(planTabTitle).click(); + cy.get(`[data-qa-plan-row="${planTitle}"]`) + .closest('tr') + .should('be.visible') + .click(); + }); + }, + + /** + * Select the given Linode plan selection card. + * + * Useful for testing Linode create page against mobile viewports. + * + * Assumes that plans are displayed as selection cards. + */ + selectPlanCard: (planTabTitle: string, planTitle: string) => { + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + ui.tabList.findTabByTitle(planTabTitle).click(); + cy.findByText(planTitle) + .should('be.visible') + .as('selectionCard') + .scrollIntoView(); + + 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/ui/pages/vpc-create-drawer.ts b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts new file mode 100644 index 00000000000..20c5635dc4b --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts @@ -0,0 +1,80 @@ +import { ui } from 'support/ui'; + +/** + * Page utilities for interacting with the VPC create drawer. + * + * Assumes that selection context is limited to only the drawer. + */ +export const vpcCreateDrawer = { + /** + * Sets the VPC create form's label field. + * + * @param vpcLabel - VPC label to set. + */ + setLabel: (vpcLabel: string) => { + cy.findByLabelText('VPC Label') + .should('be.visible') + .type(`{selectall}{del}${vpcLabel}`); + }, + + /** + * Sets the VPC create form's description field. + * + * @param vpcDescription - VPC description to set. + */ + setDescription: (vpcDescription: string) => { + cy.findByLabelText('Description', { exact: false }) + .should('be.visible') + .type(`{selectall}{del}${vpcDescription}`); + }, + + /** + * Sets the VPC create form's subnet label. + * + * When handling more than one subnet, an index can be provided to control + * which field is being modified. + * + * @param subnetLabel - Label to set. + * @param subnetIndex - Optional index of subnet for which to update label. + */ + setSubnetLabel: (subnetLabel: string, subnetIndex: number = 0) => { + cy.findByText('Subnet Label', { + selector: `label[for="subnet-label-${subnetIndex}"]`, + }) + .should('be.visible') + .click(); + + cy.focused().type(`{selectall}{del}${subnetLabel}`); + }, + + /** + * Sets the VPC create form's subnet IP address. + * + * When handling more than one subnet, an index can be provided to control + * which field is being modified. + * + * @param subnetIpRange - IP range to set. + * @param subnetIndex - Optional index of subnet for which to update IP range. + */ + setSubnetIpRange: (subnetIpRange: string, subnetIndex: number = 0) => { + cy.findByText('Subnet IP Address Range', { + selector: `label[for="subnet-ipv4-${subnetIndex}"]`, + }) + .should('be.visible') + .click(); + + cy.focused().type(`{selectall}{del}${subnetIpRange}`); + }, + + /** + * Submits the VPC create form. + */ + submit: () => { + ui.buttonGroup + .findButtonByTitle('Create VPC') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }, +}; 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/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 68d33007a7b..ad6e6b538d4 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -2,13 +2,46 @@ import { createLinode, getLinodeConfigs } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@src/factories'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { pollLinodeDiskStatuses, pollLinodeStatus } from 'support/util/polling'; -import { randomLabel } from 'support/util/random'; +import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { depaginate } from './paginate'; import { pageSize } from 'support/constants/api'; -import type { Config, Linode } from '@linode/api-v4'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { + Config, + CreateLinodeRequest, + InterfacePayload, + Linode, +} from '@linode/api-v4'; +import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; + +/** + * Linode create interface to configure a Linode with no public internet access. + */ +export const linodeVlanNoInternetConfig: InterfacePayload[] = [ + { + purpose: 'vlan', + primary: false, + label: randomLabel(), + ipam_address: null, + }, +]; + +/** + * Methods used to secure test Linodes. + * + * - `firewall`: A firewall is used to secure the created Linode. If a suitable + * firewall does not exist, one is created first. + * + * - `vlan_no_internet`: The created Linode's `eth0` network interface is set to + * a VLAN, and no public internet interface is configured. + * + * - `powered_off`: The created Linode is not booted upon creation. + */ +export type CreateTestLinodeSecurityMethod = + | 'firewall' + | 'vlan_no_internet' + | 'powered_off'; /** * Options to control the behavior of test Linode creation. @@ -19,6 +52,9 @@ export interface CreateTestLinodeOptions { /** Whether to wait for created Linode to boot before resolving. */ waitForBoot: boolean; + + /** Method to use to secure the test Linode. */ + securityMethod: CreateTestLinodeSecurityMethod; } /** @@ -27,6 +63,7 @@ export interface CreateTestLinodeOptions { export const defaultCreateTestLinodeOptions = { waitForDisks: false, waitForBoot: false, + securityMethod: 'firewall', }; /** @@ -46,13 +83,45 @@ export const createTestLinode = async ( ...(options || {}), }; + const securityMethodPayload: Partial = await (async () => { + switch (resolvedOptions.securityMethod) { + case 'firewall': + default: + const firewall = await findOrCreateDependencyFirewall(); + return { + firewall_id: firewall.id, + }; + + case 'vlan_no_internet': + return { + interfaces: linodeVlanNoInternetConfig, + }; + + case 'powered_off': + return { + booted: false, + }; + } + })(); + const resolvedCreatePayload = { ...createLinodeRequestFactory.build({ label: randomLabel(), image: 'linode/debian11', region: chooseRegion().id, + booted: false, }), ...(createRequestPayload || {}), + ...securityMethodPayload, + + // Override given root password; mitigate against using default factory password, inadvertent logging, etc. + root_pass: randomString(64, { + spaces: true, + symbols: true, + numbers: true, + lowercase: true, + uppercase: true, + }), }; // Display warnings for certain combinations of options/request payloads... @@ -105,7 +174,10 @@ export const createTestLinode = async ( consoleProps: () => { return { options: resolvedOptions, - payload: resolvedCreatePayload, + payload: { + ...resolvedCreatePayload, + root_pass: '(redacted)', + }, linode, }; }, @@ -114,26 +186,6 @@ export const createTestLinode = async ( return linode; }; -/** - * Creates a Linode and waits for it to be in "running" state. - * - * Deprecated. Use `createTestLinode` with `waitForBoot` set to `true`. - * - * @param createPayload - Optional Linode create payload options. - * - * @deprecated - * - * @returns Promis that resolves when Linode is created and booted. - */ -export const createAndBootLinode = async ( - createPayload?: Partial -): Promise => { - console.warn( - '`createAndBootLinode()` is deprecated. Use `createTestLinode()` instead.' - ); - return createTestLinode(createPayload, { waitForBoot: true }); -}; - /** * Retrieves all Config objects belonging to a Linode. * 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 ede2b9a9049..9c03007ac00 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.119.0", + "version": "1.123.0", "private": true, "type": "module", "bugs": { @@ -18,7 +18,9 @@ "@emotion/styled": "^11.11.0", "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", + "@linode/design-language-system": "^2.3.0", "@linode/validation": "*", + "@linode/search": "*", "@lukemorales/query-key-factory": "^1.3.4", "@mui/icons-material": "^5.14.7", "@mui/material": "^5.14.7", @@ -94,7 +96,6 @@ "precommit": "lint-staged && yarn typecheck", "test": "vitest run", "test:debug": "node --inspect-brk scripts/test.js --runInBand", - "vitest-preview": "vitest-preview", "storybook": "storybook dev -p 6006", "storybook-static": "storybook build -c .storybook -o .out", "build-storybook": "storybook build", @@ -128,9 +129,10 @@ "@storybook/react-vite": "^8.1.0", "@storybook/theming": "^8.1.0", "@swc/core": "^1.3.1", - "@testing-library/cypress": "^10.0.0", + "@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", @@ -165,22 +167,22 @@ "@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", "css-mediaquery": "^0.1.2", - "cypress": "13.5.0", - "cypress-axe": "^1.0.0", - "cypress-file-upload": "^5.0.7", - "cypress-real-events": "^1.11.0", + "cypress": "13.11.0", + "cypress-axe": "^1.5.0", + "cypress-file-upload": "^5.0.8", + "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", @@ -213,8 +215,7 @@ "ts-node": "^10.9.2", "vite": "^5.1.7", "vite-plugin-svgr": "^3.2.0", - "vitest": "^1.2.0", - "vitest-preview": "^0.0.1" + "vitest": "^1.6.0" }, "browserslist": [ ">1%", diff --git a/packages/manager/public/assets/apachekafka.svg b/packages/manager/public/assets/apachekafka.svg new file mode 100755 index 00000000000..fd66ea792dd --- /dev/null +++ b/packages/manager/public/assets/apachekafka.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/couchbase.svg b/packages/manager/public/assets/couchbase.svg new file mode 100755 index 00000000000..1e0f601b1fb --- /dev/null +++ b/packages/manager/public/assets/couchbase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/apachekafka.svg b/packages/manager/public/assets/white/apachekafka.svg new file mode 100755 index 00000000000..fd495096ea9 --- /dev/null +++ b/packages/manager/public/assets/white/apachekafka.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/couchbase.svg b/packages/manager/public/assets/white/couchbase.svg new file mode 100755 index 00000000000..eb60907d03b --- /dev/null +++ b/packages/manager/public/assets/white/couchbase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/lamp_flame.svg b/packages/manager/public/assets/white/lamp.svg similarity index 100% rename from packages/manager/public/assets/white/lamp_flame.svg rename to packages/manager/public/assets/white/lamp.svg diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 0594cef7b01..c73a627e77d 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -21,7 +21,10 @@ import { } from 'src/features/NotificationCenter/NotificationContext'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; import { useFlags } from 'src/hooks/useFlags'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { ENABLE_MAINTENANCE_MODE } from './constants'; import { complianceUpdateContext } from './context/complianceUpdateContext'; @@ -32,7 +35,7 @@ import { useIsACLBEnabled } from './features/LoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { useAccountSettings } from './queries/account/settings'; -import { useProfile } from './queries/profile'; +import { useProfile } from './queries/profile/profile'; const useStyles = makeStyles()((theme: Theme) => ({ activationWrapper: { @@ -127,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(() => @@ -179,6 +186,12 @@ const PlacementGroups = React.lazy(() => })) ); +const CloudPulse = React.lazy(() => + import('src/features/CloudPulse/CloudPulseLanding').then((module) => ({ + default: module.CloudPulseLanding, + })) +); + export const MainContent = () => { const { classes, cx } = useStyles(); const flags = useFlags(); @@ -213,9 +226,12 @@ export const MainContent = () => { const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { data: accountSettings } = useAccountSettings(); - const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; + const showCloudPulse = Boolean(flags.aclp?.enabled); + // the followed comment is for later use, the showCloudPulse will be removed and isACLPEnabled will be used + // const { isACLPEnabled } = useIsACLPEnabled(); + /** * this is the case where the user has successfully completed signup * but needs a manual review from Customer Support. In this case, @@ -349,6 +365,12 @@ export const MainContent = () => { )} + {showCloudPulse && ( + + )} {/** We don't want to break any bookmarks. This can probably be removed eventually. */} diff --git a/packages/manager/src/__data__/linodes.ts b/packages/manager/src/__data__/linodes.ts index 22ffdfee42d..ab5ad7a97ca 100644 --- a/packages/manager/src/__data__/linodes.ts +++ b/packages/manager/src/__data__/linodes.ts @@ -24,6 +24,7 @@ export const linode1: Linode = { ipv4: ['97.107.143.78', '98.107.143.78', '99.107.143.78'], ipv6: '2600:3c03::f03c:91ff:fe0a:109a/64', label: 'test', + lke_cluster_id: null, placement_group: { affinity_type: 'anti_affinity:local', id: 1, @@ -69,6 +70,7 @@ export const linode2: Linode = { ipv4: ['97.107.143.49'], ipv6: '2600:3c03::f03c:91ff:fe0a:0d7a/64', label: 'another-test', + lke_cluster_id: null, placement_group: { affinity_type: 'anti_affinity:local', id: 1, @@ -114,6 +116,7 @@ export const linode3: Linode = { ipv4: ['97.107.143.49'], ipv6: '2600:3c03::f03c:91ff:fe0a:0d7a/64', label: 'another-test', + lke_cluster_id: null, placement_group: { affinity_type: 'anti_affinity:local', id: 1, @@ -159,6 +162,7 @@ export const linode4: Linode = { ipv4: ['97.107.143.49'], ipv6: '2600:3c03::f03c:91ff:fe0a:0d7a/64', label: 'another-test-eu', + lke_cluster_id: null, placement_group: { affinity_type: 'anti_affinity:local', id: 1, diff --git a/packages/manager/src/__data__/regionsData.ts b/packages/manager/src/__data__/regionsData.ts index 37d3909ce5b..0a3ab6eaf2e 100644 --- a/packages/manager/src/__data__/regionsData.ts +++ b/packages/manager/src/__data__/regionsData.ts @@ -683,8 +683,8 @@ export const regions: Region[] = [ { capabilities: ['Linodes'], country: 'us', - id: 'us-edge-1', - label: 'Gecko Edge Test', + id: 'us-den-10', + label: 'Gecko Distributed Region Test', placement_group_limits: { maximum_linodes_per_pg: 10, maximum_pgs_per_customer: 5, @@ -695,14 +695,14 @@ export const regions: Region[] = [ ipv6: '2a01:7e01::5, 2a01:7e01::9, 2a01:7e01::7, 2a01:7e01::c, 2a01:7e01::2, 2a01:7e01::4, 2a01:7e01::3, 2a01:7e01::6, 2a01:7e01::b, 2a01:7e01::8', }, - site_type: 'edge', + site_type: 'distributed', status: 'ok', }, { capabilities: ['Linodes'], country: 'us', - id: 'us-edge-2', - label: 'Gecko Edge Test 2', + id: 'us-den-11', + label: 'Gecko Distributed Region Test 2', placement_group_limits: { maximum_linodes_per_pg: 10, maximum_pgs_per_customer: 5, @@ -713,7 +713,7 @@ export const regions: Region[] = [ ipv6: '2a01:7e01::5, 2a01:7e01::9, 2a01:7e01::7, 2a01:7e01::c, 2a01:7e01::2, 2a01:7e01::4, 2a01:7e01::3, 2a01:7e01::6, 2a01:7e01::b, 2a01:7e01::8', }, - site_type: 'edge', + site_type: 'distributed', status: 'ok', }, ]; 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/assets/icons/cloudpulse.svg b/packages/manager/src/assets/icons/cloudpulse.svg new file mode 100644 index 00000000000..49058da0882 --- /dev/null +++ b/packages/manager/src/assets/icons/cloudpulse.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/divider-vertical.svg b/packages/manager/src/assets/icons/divider-vertical.svg new file mode 100644 index 00000000000..79add159022 --- /dev/null +++ b/packages/manager/src/assets/icons/divider-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/assets/icons/entityIcons/edge-region.svg b/packages/manager/src/assets/icons/entityIcons/distributed-region.svg similarity index 94% rename from packages/manager/src/assets/icons/entityIcons/edge-region.svg rename to packages/manager/src/assets/icons/entityIcons/distributed-region.svg index 6a8889a6581..a9e44da4e6d 100644 --- a/packages/manager/src/assets/icons/entityIcons/edge-region.svg +++ b/packages/manager/src/assets/icons/entityIcons/distributed-region.svg @@ -1,6 +1,6 @@ -edge-region +distributed-region diff --git a/packages/manager/src/assets/icons/lock.svg b/packages/manager/src/assets/icons/lock.svg new file mode 100644 index 00000000000..62caf228e55 --- /dev/null +++ b/packages/manager/src/assets/icons/lock.svg @@ -0,0 +1,4 @@ + +Encrypted + + diff --git a/packages/manager/src/assets/icons/unlock.svg b/packages/manager/src/assets/icons/unlock.svg new file mode 100644 index 00000000000..ae99e8d9a24 --- /dev/null +++ b/packages/manager/src/assets/icons/unlock.svg @@ -0,0 +1,5 @@ + +Not Encrypted + + + diff --git a/packages/manager/src/components/AccessPanel/AccessPanel.tsx b/packages/manager/src/components/AccessPanel/AccessPanel.tsx index 94f444657a0..f21c9e146da 100644 --- a/packages/manager/src/components/AccessPanel/AccessPanel.tsx +++ b/packages/manager/src/components/AccessPanel/AccessPanel.tsx @@ -1,13 +1,31 @@ -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'; +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') ); @@ -31,19 +49,40 @@ interface Props { className?: string; disabled?: boolean; disabledReason?: JSX.Element | string; + diskEncryptionEnabled?: boolean; + displayDiskEncryption?: boolean; error?: string; handleChange: (value: string) => void; heading?: string; hideStrengthLabel?: boolean; + isInRebuildFlow?: boolean; + isLKELinode?: boolean; isOptional?: boolean; label?: string; + linodeIsInDistributedRegion?: boolean; password: null | string; passwordHelperText?: string; placeholder?: string; required?: boolean; + selectedRegion?: string; setAuthorizedUsers?: (usernames: string[]) => void; small?: boolean; - tooltipInteractive?: boolean; + toggleDiskEncryptionEnabled?: () => void; +} + +interface DiskEncryptionDescriptionDeterminants { + isDistributedRegion: boolean | undefined; // Linode Create flow (region selected for a not-yet-created linode) + isInRebuildFlow: boolean | undefined; + isLKELinode: boolean | undefined; + linodeIsInDistributedRegion: boolean | undefined; // Linode Rebuild flow (linode exists already) +} + +interface DiskEncryptionDisabledReasonDeterminants { + isDistributedRegion: boolean | undefined; // Linode Create flow (region selected for a not-yet-created linode) + isInRebuildFlow: boolean | undefined; + isLKELinode: boolean | undefined; + linodeIsInDistributedRegion: boolean | undefined; // Linode Rebuild flow (linode exists already) + regionSupportsDiskEncryption: boolean; } export const AccessPanel = (props: Props) => { @@ -52,24 +91,141 @@ export const AccessPanel = (props: Props) => { className, disabled, disabledReason, + diskEncryptionEnabled, + displayDiskEncryption, error, handleChange: _handleChange, hideStrengthLabel, + isInRebuildFlow, + isLKELinode, isOptional, label, + linodeIsInDistributedRegion, password, passwordHelperText, placeholder, required, + selectedRegion, setAuthorizedUsers, - tooltipInteractive, + toggleDiskEncryptionEnabled, } = props; const { classes, cx } = useStyles(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const regions = useRegionsQuery().data ?? []; + + const regionSupportsDiskEncryption = doesRegionSupportFeature( + selectedRegion ?? '', + regions, + 'Disk Encryption' + ); + + const isDistributedRegion = getIsDistributedRegion( + regions ?? [], + selectedRegion ?? '' + ); + const handleChange = (e: React.ChangeEvent) => _handleChange(e.target.value); + const determineDiskEncryptionDescription = ({ + isDistributedRegion, + isInRebuildFlow, + isLKELinode, + linodeIsInDistributedRegion, + }: DiskEncryptionDescriptionDeterminants) => { + // Linode Rebuild flow descriptions + if (isInRebuildFlow) { + // the order is significant: all Distributed instances are encrypted (broadest) + if (linodeIsInDistributedRegion) { + return ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY; + } + + if (isLKELinode) { + return ENCRYPT_DISK_REBUILD_LKE_COPY; + } + + if (!isLKELinode && !linodeIsInDistributedRegion) { + return ENCRYPT_DISK_REBUILD_STANDARD_COPY; + } + } + + // Linode Create flow descriptions + return isDistributedRegion + ? DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION + : DISK_ENCRYPTION_GENERAL_DESCRIPTION; + }; + + const determineDiskEncryptionDisabledReason = ({ + isDistributedRegion, + isInRebuildFlow, + isLKELinode, + linodeIsInDistributedRegion, + regionSupportsDiskEncryption, + }: DiskEncryptionDisabledReasonDeterminants) => { + if (isInRebuildFlow) { + // the order is significant: setting can't be changed for *any* Distributed instances (broadest) + if (linodeIsInDistributedRegion) { + return ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON; + } + + if (isLKELinode) { + return ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON; + } + + if (!regionSupportsDiskEncryption) { + return DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; + } + } + + // Linode Create flow disabled reasons + return isDistributedRegion + ? DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES + : DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; + }; + + /** + * Display the "Disk Encryption" section if: + * 1) the feature is enabled + * 2) "displayDiskEncryption" is explicitly passed -- + * gets used in several places, but we don't want to display Disk Encryption in all + * 3) toggleDiskEncryptionEnabled is defined + */ + const diskEncryptionJSX = + isDiskEncryptionFeatureEnabled && + displayDiskEncryption && + toggleDiskEncryptionEnabled !== undefined ? ( + <> + + toggleDiskEncryptionEnabled()} + /> + + ) : null; + return ( { className )} > + {isDiskEncryptionFeatureEnabled && ( + ({ paddingBottom: theme.spacing(2) })} + variant="h2" + > + Security + + )} }> { onChange={handleChange} placeholder={placeholder || 'Enter a password.'} required={required} - tooltipInteractive={tooltipInteractive} value={password || ''} /> @@ -110,6 +273,7 @@ export const AccessPanel = (props: Props) => { /> ) : null} + {diskEncryptionJSX} ); }; diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx index ac3d3805786..c3c8d41ebf4 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx @@ -15,7 +15,7 @@ import { Typography } from 'src/components/Typography'; import { CreateSSHKeyDrawer } from 'src/features/Profile/SSHKeys/CreateSSHKeyDrawer'; import { usePagination } from 'src/hooks/usePagination'; import { useAccountUsers } from 'src/queries/account/users'; -import { useProfile, useSSHKeysQuery } from 'src/queries/profile'; +import { useProfile, useSSHKeysQuery } from 'src/queries/profile/profile'; import { truncateAndJoinList } from 'src/utilities/stringUtils'; import { GravatarByEmail } from '../GravatarByEmail'; diff --git a/packages/manager/src/components/AccountActivation/AccountActivationError.tsx b/packages/manager/src/components/AccountActivation/AccountActivationError.tsx deleted file mode 100644 index e59d548c46a..00000000000 --- a/packages/manager/src/components/AccountActivation/AccountActivationError.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import * as React from 'react'; -import { compose } from 'recompose'; - -import withGlobalErrors, { Props } from 'src/containers/globalErrors.container'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; - -interface InnerProps { - errors: APIError[]; -} - -interface CombinedProps extends Props, InnerProps {} - -const AccountActivationError = (props: CombinedProps) => { - React.useEffect(() => { - /** set an account_unactivated error if one hasn't already been set */ - if (!props.globalErrors.account_unactivated) { - props.setErrors({ - account_unactivated: true, - }); - } - }, [props.globalErrors]); - - return ( - - { - getAPIErrorOrDefault( - props.errors, - 'Your account is not yet activated. Please reach out to support@linode.com for more information' - )[0].reason - } - - ); -}; - -export default compose( - React.memo, - withGlobalErrors() -)(AccountActivationError); diff --git a/packages/manager/src/components/AccountActivation/index.ts b/packages/manager/src/components/AccountActivation/index.ts deleted file mode 100644 index 6573f4ffb60..00000000000 --- a/packages/manager/src/components/AccountActivation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as AccountActivationError } from './AccountActivationError'; diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index 679c0b34df9..6f5faf359b1 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -1,4 +1,4 @@ -import { IconButton, ListItemText, useTheme } from '@mui/material'; +import { IconButton, ListItemText } from '@mui/material'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import * as React from 'react'; @@ -37,7 +37,6 @@ export interface ActionMenuProps { */ export const ActionMenu = React.memo((props: ActionMenuProps) => { const { actionsList, ariaLabel, onOpen } = props; - const theme = useTheme(); const menuId = convertToKebabCase(ariaLabel); const buttonId = `${convertToKebabCase(ariaLabel)}-button`; @@ -70,16 +69,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { } const sxTooltipIcon = { - '& :hover': { - color: '#4d99f1', - }, - '&& .MuiSvgIcon-root': { - fill: theme.color.disabledText, - height: '20px', - width: '20px', - }, - - color: '#fff', padding: '0 0 0 8px', pointerEvents: 'all', // Allows the tooltip to be hovered on a disabled MenuItem }; @@ -89,12 +78,12 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { ({ ':hover': { - backgroundColor: theme.palette.primary.main, - color: '#fff', + backgroundColor: theme.color.buttonPrimaryHover, + color: theme.color.white, }, - backgroundColor: open ? theme.palette.primary.main : undefined, + backgroundColor: open ? theme.color.buttonPrimaryHover : undefined, borderRadius: 'unset', - color: open ? '#fff' : theme.textColors.linkActiveLight, + color: open ? theme.color.white : theme.textColors.linkActiveLight, height: '100%', minWidth: '40px', padding: '10px', @@ -122,7 +111,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { paper: { sx: (theme) => ({ backgroundColor: theme.palette.primary.main, - boxShadow: 'none', }), }, }} @@ -147,15 +135,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { a.onClick(); } }} - sx={{ - '&:hover': { - background: '#226dc3', - }, - background: '#3683dc', - borderBottom: '1px solid #5294e0', - color: '#fff', - padding: '10px 10px 10px 16px', - }} data-qa-action-menu-item={a.title} data-testid={a.title} disabled={a.disabled} diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index cc9a4100c73..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 = '', @@ -120,7 +120,7 @@ export const Autocomplete = < <> {loading && ( - + )} {textFieldProps?.InputProps?.endAdornment} diff --git a/packages/manager/src/components/BackupStatus/BackupStatus.tsx b/packages/manager/src/components/BackupStatus/BackupStatus.tsx index db476a48411..7b6123890b3 100644 --- a/packages/manager/src/components/BackupStatus/BackupStatus.tsx +++ b/packages/manager/src/components/BackupStatus/BackupStatus.tsx @@ -114,7 +114,6 @@ const BackupStatus = (props: Props) => { padding: 0, }} classes={{ tooltip: classes.tooltip }} - interactive status="help" text={backupsUnavailableMessage} /> diff --git a/packages/manager/src/components/BetaChip/BetaChip.test.tsx b/packages/manager/src/components/BetaChip/BetaChip.test.tsx index 47a86d03207..39d28178640 100644 --- a/packages/manager/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/manager/src/components/BetaChip/BetaChip.test.tsx @@ -17,7 +17,7 @@ describe('BetaChip', () => { const { getByTestId } = renderWithTheme(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: #3683dc'); + expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)'); }); it('triggers an onClick callback', () => { diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx index 8fe3480a3a9..848a0a164bc 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx @@ -4,11 +4,10 @@ import { Typography } from 'src/components/Typography'; export const StyledTypography = styled(Typography, { label: 'StyledTypography', -})(({ theme }) => ({ +})(({}) => ({ '&:hover': { textDecoration: 'underline', }, - color: theme.textColors.tableHeader, fontSize: '1.125rem', lineHeight: 'normal', textTransform: 'capitalize', diff --git a/packages/manager/src/components/Button/StyledActionButton.ts b/packages/manager/src/components/Button/StyledActionButton.ts index aa711d36626..008257f5a50 100644 --- a/packages/manager/src/components/Button/StyledActionButton.ts +++ b/packages/manager/src/components/Button/StyledActionButton.ts @@ -15,10 +15,12 @@ export const StyledActionButton = styled(Button, { })(({ theme, ...props }) => ({ ...(!props.disabled && { '&:hover': { - backgroundColor: theme.palette.primary.main, - color: theme.name === 'dark' ? theme.color.black : theme.color.white, + backgroundColor: theme.color.buttonPrimaryHover, + color: theme.color.white, }, }), + background: 'transparent', + color: theme.textColors.linkActiveLight, fontFamily: latoWeb.normal, fontSize: '14px', lineHeight: '16px', diff --git a/packages/manager/src/components/Button/StyledLinkButton.ts b/packages/manager/src/components/Button/StyledLinkButton.ts index 8c4cec0b4a8..1688a156f79 100644 --- a/packages/manager/src/components/Button/StyledLinkButton.ts +++ b/packages/manager/src/components/Button/StyledLinkButton.ts @@ -10,20 +10,5 @@ import { styled } from '@mui/material/styles'; export const StyledLinkButton = styled('button', { label: 'StyledLinkButton', })(({ theme }) => ({ - '&:disabled': { - color: theme.palette.text.disabled, - cursor: 'not-allowed', - }, - '&:hover:not(:disabled)': { - backgroundColor: 'transparent', - color: theme.palette.primary.main, - textDecoration: 'underline', - }, - background: 'none', - border: 'none', - color: theme.textColors.linkActiveLight, - cursor: 'pointer', - font: 'inherit', - minWidth: 0, - padding: 0, + ...theme.applyLinkStyles, })); diff --git a/packages/manager/src/components/Button/StyledTagButton.ts b/packages/manager/src/components/Button/StyledTagButton.ts index df83fcc4c88..d0dae58b7cd 100644 --- a/packages/manager/src/components/Button/StyledTagButton.ts +++ b/packages/manager/src/components/Button/StyledTagButton.ts @@ -24,11 +24,12 @@ export const StyledTagButton = styled(Button, { }), ...(!props.disabled && { '&:hover, &:focus': { - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, border: 'none', + color: theme.color.tagButtonText, }, - backgroundColor: theme.color.tagButton, - color: theme.textColors.linkActiveLight, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, }), })); diff --git a/packages/manager/src/components/Checkbox.tsx b/packages/manager/src/components/Checkbox.tsx index 5161f1536f9..fd2db5a9b8c 100644 --- a/packages/manager/src/components/Checkbox.tsx +++ b/packages/manager/src/components/Checkbox.tsx @@ -5,8 +5,8 @@ import * as React from 'react'; import CheckboxIcon from 'src/assets/icons/checkbox.svg'; import CheckboxCheckedIcon from 'src/assets/icons/checkboxChecked.svg'; -import { TooltipIcon } from 'src/components/TooltipIcon'; import { FormControlLabel } from 'src/components/FormControlLabel'; +import { TooltipIcon } from 'src/components/TooltipIcon'; interface Props extends CheckboxProps { /** @@ -17,11 +17,6 @@ interface Props extends CheckboxProps { * Renders a `FormControlLabel` that controls the underlying Checkbox with a label of `text` */ text?: JSX.Element | string; - /** - * Whether or not the tooltip is interactive - * @default false - */ - toolTipInteractive?: boolean; /** * Renders a tooltip to the right of the Checkbox */ @@ -44,7 +39,7 @@ interface Props extends CheckboxProps { * - If the user clicks the Back button, any changes made to checkboxes should be discarded and the original settings reinstated. */ export const Checkbox = (props: Props) => { - const { sxFormLabel, text, toolTipInteractive, toolTipText, ...rest } = props; + const { sxFormLabel, text, toolTipText, ...rest } = props; const BaseCheckbox = ( { return ( <> {CheckboxComponent} - {toolTipText ? ( - - ) : null} + {toolTipText ? : null} ); }; diff --git a/packages/manager/src/components/CheckoutBar/styles.ts b/packages/manager/src/components/CheckoutBar/styles.ts index a85cf4d30b5..2e10b63f99e 100644 --- a/packages/manager/src/components/CheckoutBar/styles.ts +++ b/packages/manager/src/components/CheckoutBar/styles.ts @@ -16,7 +16,7 @@ const StyledRoot = styled('div')(() => { return { minHeight: '24px', minWidth: '24px', - [theme.breakpoints.down('md')]: { + [theme.breakpoints.down(1280)]: { background: theme.color.white, bottom: '0 !important' as '0', left: '0 !important' as '0', diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx b/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx index b9416f0f1e6..989113ef285 100644 --- a/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx +++ b/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx @@ -15,40 +15,21 @@ describe('CircleProgress', () => { const circle = screen.getByTestId('circle-progress'); expect(circle).toBeInTheDocument(); expect(circle).toHaveStyle('width: 124px; height: 124px;'); - const innerCircle = screen.getByTestId('inner-circle-progress'); - expect(innerCircle).toBeInTheDocument(); }); - it('renders a mini CircleProgress', () => { - const screen = renderWithTheme(); + it('renders a small CircleProgress', () => { + const screen = renderWithTheme(); const circleProgress = screen.getByLabelText(CONTENT_LOADING); expect(circleProgress).toBeVisible(); expect(circleProgress).toHaveStyle('width: 40px; height: 40px;'); }); - it('sets a mini CircleProgress with no padding', () => { - const screen = renderWithTheme(); + it('sets a small CircleProgress with no padding', () => { + const screen = renderWithTheme(); const circleProgress = screen.getByLabelText(CONTENT_LOADING); expect(circleProgress).toBeVisible(); - expect(circleProgress).toHaveStyle('width: 22px; height: 22px;'); - }); - - it('sets a mini CircleProgress with a custom size', () => { - const screen = renderWithTheme(); - - const circleProgress = screen.getByLabelText(CONTENT_LOADING); - expect(circleProgress).toBeVisible(); - expect(circleProgress).toHaveStyle('width: 25px; height: 25px;'); - }); - - it('renders a CircleProgress without the inner circle', () => { - const screen = renderWithTheme(); - - const circleProgress = screen.getByLabelText(CONTENT_LOADING); - expect(circleProgress).toBeVisible(); - const innerCircle = screen.queryByTestId('inner-circle-progress'); - expect(innerCircle).not.toBeInTheDocument(); + expect(circleProgress).toHaveStyle('width: 20px; height: 20px;'); }); }); diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.tsx b/packages/manager/src/components/CircleProgress/CircleProgress.tsx index 24fe6e240fb..115f41c5422 100644 --- a/packages/manager/src/components/CircleProgress/CircleProgress.tsx +++ b/packages/manager/src/components/CircleProgress/CircleProgress.tsx @@ -1,58 +1,63 @@ +import _CircularProgress from '@mui/material/CircularProgress'; import { SxProps, styled } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; -import { - CircularProgress, - CircularProgressProps, -} from 'src/components/CircularProgress'; import { omittedProps } from 'src/utilities/omittedProps'; -interface CircleProgressProps extends CircularProgressProps { +import type { CircularProgressProps } from '@mui/material/CircularProgress'; + +interface CircleProgressProps extends Omit { /** * Additional child elements to pass in */ children?: JSX.Element; /** - * Displays a smaller version of the circle progress. - */ - mini?: boolean; - /** - * If true, will not show an inner circle beneath the spinning circle - */ - noInner?: boolean; - /** - * Removes the padding for `mini` circle progresses only. + * Removes the padding */ noPadding?: boolean; /** - * To be primarily used with mini and noPadding. Set spinner to a custom size. + * Sets the size of the spinner + * @default "lg" */ - size?: number; + size?: 'lg' | 'md' | 'sm' | 'xs'; /** * Additional styles to apply to the root element. */ sx?: SxProps; } +const SIZE_MAP = { + lg: 124, + md: 40, + sm: 20, + xs: 14, +}; + /** - * Use for short, indeterminate activities requiring user attention. + * Use for short, indeterminate activities requiring user attention. Defaults to large. + * + * sizes: + * xs = 14 + * md = 20 + * md = 40 + * lg = 124 */ const CircleProgress = (props: CircleProgressProps) => { - const { children, mini, noInner, noPadding, size, sx, ...rest } = props; + const { children, noPadding, size, sx, ...rest } = props; const variant = typeof props.value === 'number' ? 'determinate' : 'indeterminate'; const value = typeof props.value === 'number' ? props.value : 0; - if (mini) { + if (size) { return ( - ); @@ -63,16 +68,11 @@ const CircleProgress = (props: CircleProgressProps) => { {children !== undefined && ( {children} )} - {noInner !== true && ( - - - - )} ({ width: '100%', })); -const StyledTopWrapperDiv = styled('div')(({}) => ({ - alignItems: 'center', - display: 'flex', - height: '100%', - justifyContent: 'center', - position: 'absolute', - width: '100%', -})); - -const StyledTopDiv = styled('div')(({ theme }) => ({ - border: '1px solid #999', - borderRadius: '50%', - height: 70, - [theme.breakpoints.up('sm')]: { - height: 120, - width: 120, +const StyledCircularProgress = styled(_CircularProgress)(({ theme }) => ({ + position: 'relative', + [theme.breakpoints.down('sm')]: { + height: '72px !important', + width: '72px !important', }, - width: 70, })); -const StyledCircularProgress = styled(CircularProgress)( - ({ theme }) => ({ - position: 'relative', - [theme.breakpoints.down('sm')]: { - height: '72px !important', - width: '72px !important', - }, - }) -); - -const StyledMiniCircularProgress = styled(CircularProgress, { +const StyledCustomCircularProgress = styled(_CircularProgress, { shouldForwardProp: omittedProps(['noPadding']), -})(({ theme, ...props }) => ({ +})<{ noPadding: boolean | undefined }>(({ theme, ...props }) => ({ padding: `calc(${theme.spacing()} * 1.3)`, ...(props.noPadding && { padding: 0, diff --git a/packages/manager/src/components/CircularProgress.stories.tsx b/packages/manager/src/components/CircularProgress.stories.tsx deleted file mode 100644 index db6c4271610..00000000000 --- a/packages/manager/src/components/CircularProgress.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import React from 'react'; - -import { CircularProgress } from './CircularProgress'; - -const meta: Meta = { - component: CircularProgress, - title: 'Components/Loading States/Circular Progress', -}; - -type Story = StoryObj; - -export const Default: Story = { - render: (args) => , -}; - -export default meta; diff --git a/packages/manager/src/components/CircularProgress.tsx b/packages/manager/src/components/CircularProgress.tsx deleted file mode 100644 index 95ab3dd9391..00000000000 --- a/packages/manager/src/components/CircularProgress.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import _CircularProgress from '@mui/material/CircularProgress'; -import React from 'react'; - -import type { CircularProgressProps } from '@mui/material/CircularProgress'; - -/** - * Not to be confused with ``. - * @todo Consolidate these two components - */ -export const CircularProgress = (props: CircularProgressProps) => { - return <_CircularProgress {...props} />; -}; - -export type { CircularProgressProps }; diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx index e266cbb1e9c..bd844227670 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx @@ -1,5 +1,3 @@ -import Paper from '@mui/material/Paper'; -import TableContainer from '@mui/material/TableContainer'; import * as React from 'react'; import { Table } from 'src/components/Table'; @@ -25,23 +23,23 @@ export const CollapsibleTable = (props: Props) => { const { TableItems, TableRowEmpty, TableRowHead } = props; return ( - - - {TableRowHead} - - {TableItems.length === 0 && TableRowEmpty} - {TableItems.map((item) => { - return ( - - ); - })} - -
- + + + {TableRowHead} + + + {TableItems.length === 0 && TableRowEmpty} + {TableItems.map((item) => { + return ( + + ); + })} + +
); }; diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx deleted file mode 100644 index a9f6024520e..00000000000 --- a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { ColorPalette } from './ColorPalette'; - -describe('Color Palette', () => { - it('renders the Color Palette', () => { - const { getAllByText, getByText } = renderWithTheme(); - - // primary colors - getByText('Primary Colors'); - getByText('theme.palette.primary.main'); - const mainHash = getAllByText('#3683dc'); - expect(mainHash).toHaveLength(2); - getByText('theme.palette.primary.light'); - getByText('#4d99f1'); - getByText('theme.palette.primary.dark'); - getByText('#2466b3'); - getByText('theme.palette.text.primary'); - const primaryHash = getAllByText('#606469'); - expect(primaryHash).toHaveLength(3); - getByText('theme.color.headline'); - const headlineHash = getAllByText('#32363c'); - expect(headlineHash).toHaveLength(2); - getByText('theme.palette.divider'); - const dividerHash = getAllByText('#f4f4f4'); - expect(dividerHash).toHaveLength(2); - const whiteColor = getAllByText('theme.color.white'); - expect(whiteColor).toHaveLength(2); - const whiteHash = getAllByText('#fff'); - expect(whiteHash).toHaveLength(3); - - // etc - getByText('Etc.'); - getByText('theme.color.red'); - getByText('#ca0813'); - getByText('theme.color.orange'); - getByText('#ffb31a'); - getByText('theme.color.yellow'); - getByText('#fecf2f'); - getByText('theme.color.green'); - getByText('#00b159'); - getByText('theme.color.teal'); - getByText('#17cf73'); - getByText('theme.color.border2'); - getByText('#c5c6c8'); - getByText('theme.color.border3'); - getByText('#eee'); - getByText('theme.color.grey1'); - getByText('#abadaf'); - getByText('theme.color.grey2'); - getByText('#e7e7e7'); - getByText('theme.color.grey3'); - getByText('#ccc'); - getByText('theme.color.grey4'); - getByText('#8C929D'); - getByText('theme.color.grey5'); - getByText('#f5f5f5'); - getByText('theme.color.grey6'); - const borderGreyHash = getAllByText('#e3e5e8'); - expect(borderGreyHash).toHaveLength(3); - getByText('theme.color.grey7'); - getByText('#e9eaef'); - getByText('theme.color.grey8'); - getByText('#dbdde1'); - getByText('theme.color.grey9'); - const borderGrey9Hash = getAllByText('#f4f5f6'); - expect(borderGrey9Hash).toHaveLength(3); - getByText('theme.color.black'); - getByText('#222'); - getByText('theme.color.offBlack'); - getByText('#444'); - getByText('theme.color.boxShadow'); - getByText('#ddd'); - getByText('theme.color.boxShadowDark'); - getByText('#aaa'); - getByText('theme.color.blueDTwhite'); - getByText('theme.color.tableHeaderText'); - getByText('rgba(0, 0, 0, 0.54)'); - getByText('theme.color.drawerBackdrop'); - getByText('rgba(255, 255, 255, 0.5)'); - getByText('theme.color.label'); - getByText('#555'); - getByText('theme.color.disabledText'); - getByText('#c9cacb'); - getByText('theme.color.tagButton'); - getByText('#f1f7fd'); - getByText('theme.color.tagIcon'); - getByText('#7daee8'); - - // background colors - getByText('Background Colors'); - getByText('theme.bg.app'); - getByText('theme.bg.main'); - getByText('theme.bg.offWhite'); - getByText('#fbfbfb'); - getByText('theme.bg.lightBlue1'); - getByText('#f0f7ff'); - getByText('theme.bg.lightBlue2'); - getByText('#e5f1ff'); - getByText('theme.bg.white'); - getByText('theme.bg.tableHeader'); - getByText('#f9fafa'); - getByText('theme.bg.primaryNavPaper'); - getByText('#3a3f46'); - getByText('theme.bg.mainContentBanner'); - getByText('#33373d'); - getByText('theme.bg.bgPaper'); - getByText('#ffffff'); - getByText('theme.bg.bgAccessRow'); - getByText('#fafafa'); - getByText('theme.bg.bgAccessRowTransparentGradient'); - getByText('rgb(255, 255, 255, .001)'); - - // typography colors - getByText('Typography Colors'); - getByText('theme.textColors.linkActiveLight'); - getByText('#2575d0'); - getByText('theme.textColors.headlineStatic'); - getByText('theme.textColors.tableHeader'); - getByText('#888f91'); - getByText('theme.textColors.tableStatic'); - getByText('theme.textColors.textAccessTable'); - - // border colors - getByText('Border Colors'); - getByText('theme.borderColors.borderTypography'); - getByText('theme.borderColors.borderTable'); - getByText('theme.borderColors.divider'); - }); -}); diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index a3bfaadb121..9404371eea9 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -1,12 +1,12 @@ -// eslint-disable-next-line no-restricted-imports import { useTheme } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { Typography } from 'src/components/Typography'; +import type { Theme } from '@mui/material/styles'; + interface Color { alias: string; color: string; @@ -45,7 +45,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ /** * Add a new color to the palette, especially another tint of gray or blue, only after exhausting the option of using an existing color. * - * - Colors used in light mode are located in `foundations/light.ts + * - Colors used in light mode are located in `foundations/light.ts` * - Colors used in dark mode are located in `foundations/dark.ts` * * If a color does not exist in the current palette and is only used once, consider applying the color conditionally: @@ -102,7 +102,7 @@ export const ColorPalette = () => { { alias: 'theme.color.drawerBackdrop', color: theme.color.drawerBackdrop }, { alias: 'theme.color.label', color: theme.color.label }, { alias: 'theme.color.disabledText', color: theme.color.disabledText }, - { alias: 'theme.color.tagButton', color: theme.color.tagButton }, + { alias: 'theme.color.tagButton', color: theme.color.tagButtonBg }, { alias: 'theme.color.tagIcon', color: theme.color.tagIcon }, ]; diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx index 20a294d8303..cb86b4b93b0 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx @@ -1,16 +1,23 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { + CopyTooltip, + CopyTooltipProps, +} from 'src/components/CopyTooltip/CopyTooltip'; import { TextField, TextFieldProps } from 'src/components/TextField'; interface CopyableTextFieldProps extends TextFieldProps { + /** + * Optional props that are passed to the underlying CopyTooltip component + */ + CopyTooltipProps?: Partial; className?: string; hideIcon?: boolean; } export const CopyableTextField = (props: CopyableTextFieldProps) => { - const { className, hideIcon, value, ...restProps } = props; + const { CopyTooltipProps, className, hideIcon, value, ...restProps } = props; return ( { {...restProps} InputProps={{ endAdornment: hideIcon ? undefined : ( - + ), }} className={`${className} copy removeDisabledStyles`} diff --git a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx index d40e6deabb7..33d66afc056 100644 --- a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx +++ b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { TimeInterval, formatDate } from 'src/utilities/formatDate'; export interface DateTimeDisplayProps { diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index 0c215e5cf97..cb5c23eafc7 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -82,7 +82,7 @@ export const DebouncedSearchTextField = React.memo( InputProps={{ endAdornment: isSearching ? ( - + ) : ( clearable && diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx index 3fa23cb336d..b33df97103d 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx @@ -7,7 +7,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; import { Typography } from 'src/components/Typography'; import { titlecase } from 'src/features/Linodes/presentation'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { capitalize } from 'src/utilities/capitalize'; import { DialogProps } from '../Dialog/Dialog'; diff --git a/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx b/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx index 7d99a8b9e46..3226a4677de 100644 --- a/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx +++ b/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx @@ -12,7 +12,11 @@ import { describe('DiskEncryption', () => { it('should render a header', () => { const { getByTestId } = renderWithTheme( - + ); const heading = getByTestId(headerTestId); @@ -23,7 +27,11 @@ describe('DiskEncryption', () => { it('should render a description', () => { const { getByTestId } = renderWithTheme( - + ); const description = getByTestId(descriptionTestId); @@ -33,7 +41,11 @@ describe('DiskEncryption', () => { it('should render a checkbox', () => { const { getByTestId } = renderWithTheme( - + ); const checkbox = getByTestId(checkboxTestId); diff --git a/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx b/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx index 3847cdd223d..ba4f236e881 100644 --- a/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx +++ b/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx @@ -3,13 +3,15 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Checkbox } from 'src/components/Checkbox'; import { Typography } from 'src/components/Typography'; +import { Notice } from '../Notice/Notice'; export interface DiskEncryptionProps { descriptionCopy: JSX.Element | string; disabled?: boolean; disabledReason?: string; - // encryptionStatus - // toggleEncryption + error?: string; + isEncryptDiskChecked: boolean; + onChange: (checked: boolean) => void; } export const headerTestId = 'disk-encryption-header'; @@ -17,15 +19,23 @@ export const descriptionTestId = 'disk-encryption-description'; export const checkboxTestId = 'encrypt-disk-checkbox'; export const DiskEncryption = (props: DiskEncryptionProps) => { - const { descriptionCopy, disabled, disabledReason } = props; - - const [checked, setChecked] = React.useState(false); // @TODO LDE: temporary placeholder until toggleEncryption logic is in place + const { + descriptionCopy, + disabled, + disabledReason, + error, + isEncryptDiskChecked, + onChange, + } = props; return ( <> Disk Encryption + {error && ( + + )} ({ padding: `${theme.spacing()} 0` })} @@ -41,10 +51,10 @@ export const DiskEncryption = (props: DiskEncryptionProps) => { flexDirection="row" > setChecked(!checked)} // @TODO LDE: toggleEncryption will be used here + onChange={(e, checked) => onChange(checked)} text="Encrypt Disk" toolTipText={disabled ? disabledReason : ''} /> diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/DiskEncryption/constants.tsx index e38849fe918..aff4981ad13 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/DiskEncryption/constants.tsx @@ -2,17 +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_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. Select another region to use Disk Encryption.'; + +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_UNAVAILABLE_IN_REGION_COPY = - 'Disk encryption is not available in the selected region.'; +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/DiskEncryption/utils.ts b/packages/manager/src/components/DiskEncryption/utils.ts new file mode 100644 index 00000000000..8beaab70d68 --- /dev/null +++ b/packages/manager/src/components/DiskEncryption/utils.ts @@ -0,0 +1,31 @@ +import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account/account'; + +/** + * Hook to determine if the Disk Encryption feature should be visible to the user. + * Based on the user's account capability and the feature flag. + * + * @returns { boolean } - Whether the Disk Encryption feature is enabled for the current user. + */ +export const useIsDiskEncryptionFeatureEnabled = (): { + isDiskEncryptionFeatureEnabled: boolean; +} => { + const { data: account, error } = useAccount(); + const flags = useFlags(); + + if (error || !flags) { + return { isDiskEncryptionFeatureEnabled: false }; + } + + const hasAccountCapability = account?.capabilities?.includes( + 'Disk Encryption' + ); + + const isFeatureFlagEnabled = flags.linodeDiskEncryption; + + const isDiskEncryptionFeatureEnabled = Boolean( + hasAccountCapability && isFeatureFlagEnabled + ); + + return { isDiskEncryptionFeatureEnabled }; +}; diff --git a/packages/manager/src/components/Divider.tsx b/packages/manager/src/components/Divider.tsx index 6daa2d34fdb..cfd18a7fe5a 100644 --- a/packages/manager/src/components/Divider.tsx +++ b/packages/manager/src/components/Divider.tsx @@ -24,13 +24,6 @@ const StyledDivider = styled(_Divider, { 'dark', ]), })(({ theme, ...props }) => ({ - borderColor: props.dark - ? theme.color.border2 - : props.light - ? theme.name === 'light' - ? '#e3e5e8' - : '#2e3238' - : '', marginBottom: props.spacingBottom, marginTop: props.spacingTop, })); diff --git a/packages/manager/src/components/DocsLink/DocsLink.tsx b/packages/manager/src/components/DocsLink/DocsLink.tsx index fc13e6b3baf..aba71077a05 100644 --- a/packages/manager/src/components/DocsLink/DocsLink.tsx +++ b/packages/manager/src/components/DocsLink/DocsLink.tsx @@ -50,13 +50,10 @@ export const DocsLink = (props: DocsLinkProps) => { const StyledDocsLink = styled(Link, { label: 'StyledDocsLink', })(({ theme }) => ({ + ...theme.applyLinkStyles, '& svg': { marginRight: theme.spacing(), }, - '&:hover': { - color: theme.textColors.linkActiveLight, - textDecoration: 'underline', - }, alignItems: 'center', display: 'flex', fontFamily: theme.font.normal, diff --git a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx index 9f291496d6b..6d8d47f0307 100644 --- a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx +++ b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx @@ -1,22 +1,22 @@ -import { Box } from 'src/components/Box'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import Minus from 'src/assets/icons/LKEminusSign.svg'; import Plus from 'src/assets/icons/LKEplusSign.svg'; +import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { TextField } from 'src/components/TextField'; const sxTextFieldBase = { '&::-webkit-inner-spin-button': { - '-webkit-appearance': 'none', + WebkitAppearance: 'none', margin: 0, }, '&::-webkit-outer-spin-button': { - '-webkit-appearance': 'none', + WebkitAppearance: 'none', margin: 0, }, - '-moz-appearance': 'textfield', + MozAppearance: 'textfield', padding: '0 8px', textAlign: 'right', }; diff --git a/packages/manager/src/components/EnhancedSelect/Select.styles.ts b/packages/manager/src/components/EnhancedSelect/Select.styles.ts index 9ffe05b76e6..597687971d1 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.styles.ts +++ b/packages/manager/src/components/EnhancedSelect/Select.styles.ts @@ -1,6 +1,7 @@ -import { Theme } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; +import type { Theme } from '@mui/material/styles'; + // TODO jss-to-tss-react codemod: usages of this hook outside of this file will not be converted. export const useStyles = makeStyles()((theme: Theme) => ({ algoliaRoot: { @@ -225,6 +226,9 @@ export const useStyles = makeStyles()((theme: Theme) => ({ }, width: '100%', }, + '& .select-placeholder': { + color: theme.color.grey1, + }, '& [class*="MuiFormHelperText-error"]': { paddingBottom: theme.spacing(1), }, diff --git a/packages/manager/src/components/EnhancedSelect/Select.tsx b/packages/manager/src/components/EnhancedSelect/Select.tsx index 085210785f1..77f4ec721e5 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.tsx +++ b/packages/manager/src/components/EnhancedSelect/Select.tsx @@ -1,18 +1,10 @@ -import { Theme, useTheme } from '@mui/material'; +import { useTheme } from '@mui/material'; import * as React from 'react'; -import ReactSelect, { - ActionMeta, - NamedProps as SelectProps, - ValueType, -} from 'react-select'; -import CreatableSelect, { - CreatableProps as CreatableSelectProps, -} from 'react-select/creatable'; +import ReactSelect from 'react-select'; +import CreatableSelect from 'react-select/creatable'; -import { TextFieldProps } from 'src/components/TextField'; import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; -import { reactSelectStyles, useStyles } from './Select.styles'; import { DropdownIndicator } from './components/DropdownIndicator'; import Input from './components/Input'; import { LoadingIndicator } from './components/LoadingIndicator'; @@ -23,6 +15,16 @@ import NoOptionsMessage from './components/NoOptionsMessage'; import { Option } from './components/Option'; import Control from './components/SelectControl'; import { SelectPlaceholder as Placeholder } from './components/SelectPlaceholder'; +import { reactSelectStyles, useStyles } from './Select.styles'; + +import type { Theme } from '@mui/material'; +import type { + ActionMeta, + NamedProps as SelectProps, + ValueType, +} from 'react-select'; +import type { CreatableProps as CreatableSelectProps } from 'react-select/creatable'; +import type { TextFieldProps } from 'src/components/TextField'; export interface Item { data?: any; diff --git a/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx b/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx index 14a1283fdf7..88cbadaeeb3 100644 --- a/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx @@ -1,13 +1,13 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { CircularProgress } from 'src/components/CircularProgress'; +import { CircleProgress } from 'src/components/CircleProgress'; export const LoadingIndicator = () => { - return ; + return ; }; -const StyledCircularProgress = styled(CircularProgress)(() => ({ +const StyledCircleProgress = styled(CircleProgress)(() => ({ position: 'relative', right: 20, })); diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx index 795daa1010f..e1b05f120ad 100644 --- a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx +++ b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx @@ -40,32 +40,14 @@ export const Default: Story = { variant: 'h2', }, render: (args) => { - const sxActionItem = { - '&:hover': { - backgroundColor: '#3683dc', - color: '#fff', - }, - color: '#2575d0', - fontFamily: '"LatoWeb", sans-serif', - fontSize: '0.875rem', - height: '34px', - minWidth: 'auto', - }; - return ( Chip / Progress Go Here - - - + + + { const { - captureAnalytics, className, email, height = DEFAULT_AVATAR_SIZE, @@ -31,12 +23,6 @@ export const GravatarByEmail = (props: Props) => { const url = getGravatarUrl(email); - React.useEffect(() => { - if (captureAnalytics) { - checkForGravatarAndSendEvent(url); - } - }, []); - return ( { ); }; - -/** - * Given a Gravatar URL, this function waits for Adobe Analytics - * to load (if it is not already loaded) and captures an Analytics - * event saying whether or not the user has a Gravatar. - * - * Make sure the URL passed has `?d=404` - */ -async function checkForGravatarAndSendEvent(url: string) { - try { - await waitForAdobeAnalyticsToBeLoaded(); - - const response = await fetch(url); - - if (response.status === 200) { - sendHasGravatarEvent(true); - } - if (response.status === 404) { - sendHasGravatarEvent(false); - } - } catch (error) { - // Analytics didn't load or the fetch to Gravatar - // failed. Event won't be logged. - } -} diff --git a/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx b/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx index 8ebd0dd9c49..dc9e9cc87c1 100644 --- a/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx +++ b/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx @@ -1,4 +1,4 @@ -import * as hljs from 'highlight.js/lib/core'; +import * as hljs from 'highlight.js'; import apache from 'highlight.js/lib/languages/apache'; import bash from 'highlight.js/lib/languages/bash'; import javascript from 'highlight.js/lib/languages/javascript'; @@ -10,11 +10,11 @@ import * as React from 'react'; import { Typography } from 'src/components/Typography'; import 'src/formatted-text.css'; -import { ThemeName } from 'src/foundations/themes'; import { unsafe_MarkdownIt } from 'src/utilities/markdown'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; import { useColorMode } from 'src/utilities/theme'; +import type { ThemeName } from 'src/foundations/themes'; import type { SanitizeOptions } from 'src/utilities/sanitizeHTML'; hljs.registerLanguage('apache', apache); @@ -98,7 +98,8 @@ export const HighlightedMarkdown = (props: HighlightedMarkdownProps) => { React.useEffect(() => { try { if (rootRef.current) { - const blocks = rootRef.current.querySelectorAll('pre code') ?? []; + const blocks: NodeListOf = + rootRef.current.querySelectorAll('pre code') ?? []; const len = blocks.length ?? 0; let i = 0; for (i; i < len; i++) { diff --git a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap index 94033594dc5..238b90d44c9 100644 --- a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap +++ b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap @@ -3,7 +3,7 @@ exports[`HighlightedMarkdown component > should highlight text consistently 1`] = `

Some markdown diff --git a/packages/manager/src/components/HighlightedMarkdown/highlight.d.ts b/packages/manager/src/components/HighlightedMarkdown/highlight.d.ts deleted file mode 100644 index da3a13c1ae1..00000000000 --- a/packages/manager/src/components/HighlightedMarkdown/highlight.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module 'highlight.js/lib/core'; -declare module 'highlight.js/lib/languages/apache'; -declare module 'highlight.js/lib/languages/javascript'; -declare module 'highlight.js/lib/languages/yaml'; -declare module 'highlight.js/lib/languages/bash'; -declare module 'highlight.js/lib/languages/nginx'; diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index 2180c9a9e81..c619d845fa2 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..76b63511e1d 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx @@ -1,8 +1,10 @@ import { DateTime } from 'luxon'; +import React from 'react'; import { imageFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; -import { imagesToGroupedItems } from './ImageSelect'; +import { ImageSelect, imagesToGroupedItems } from './ImageSelect'; describe('imagesToGroupedItems', () => { it('should filter deprecated images when end of life is past beyond 6 months ', () => { @@ -34,21 +36,25 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2022-10-20T14:05:30', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Slackware 14.1', - value: 'private/4', + value: 'private/5', }, { className: 'fl-tux', created: '2022-10-20T14:05:30', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Slackware 14.1', - value: 'private/5', + value: 'private/6', }, ], }, ]; + expect(imagesToGroupedItems(images)).toStrictEqual(expected); }); + it('should add suffix `deprecated` to images at end of life ', () => { const images = [ ...imageFactory.buildList(2, { @@ -72,15 +78,17 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2017-06-16T20:02:29', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Debian 9 (deprecated)', - value: 'private/6', + value: 'private/7', }, { className: 'fl-tux', created: '2017-06-16T20:02:29', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Debian 9 (deprecated)', - value: 'private/7', + value: 'private/8', }, ], }, @@ -88,3 +96,26 @@ describe('imagesToGroupedItems', () => { expect(imagesToGroupedItems(images)).toStrictEqual(expected); }); }); + +describe('ImageSelect', () => { + it('renders a "Indicates compatibility with distributed compute regions." notice if the user has at least one image with the distributed capability', async () => { + const images = [ + imageFactory.build({ capabilities: [] }), + imageFactory.build({ capabilities: ['distributed-images'] }), + imageFactory.build({ capabilities: [] }), + ]; + + const { getByText } = renderWithTheme( + + ); + + expect( + getByText('Indicates compatibility with distributed compute regions.') + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index e4fbc8e9bac..1fdbb9a4498 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -1,13 +1,11 @@ -import { Image } from '@linode/api-v4/lib/images'; -import Grid from '@mui/material/Unstable_Grid2'; import produce from 'immer'; import { DateTime } from 'luxon'; import { equals, groupBy } from 'ramda'; import * as React from 'react'; -import Select, { GroupType, Item } from 'src/components/EnhancedSelect'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; +import Select from 'src/components/EnhancedSelect'; import { _SingleValue } from 'src/components/EnhancedSelect/components/SingleValue'; -import { BaseSelectProps } from 'src/components/EnhancedSelect/Select'; import { ImageOption } from 'src/components/ImageSelect/ImageOption'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; @@ -17,21 +15,31 @@ import { arePropsEqual } from 'src/utilities/arePropsEqual'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; +import { Box } from '../Box'; import { distroIcons } from '../DistributionIcon'; +import { Stack } from '../Stack'; + +import type { Image } from '@linode/api-v4/lib/images'; +import type { GroupType, Item } from 'src/components/EnhancedSelect'; +import type { BaseSelectProps } from 'src/components/EnhancedSelect/Select'; 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 { classNames?: string; disabled?: boolean; error?: string; - handleSelectImage: (selection?: string) => void; + handleSelectImage: ( + selection: string | undefined, + image: Image | undefined + ) => void; images: Image[]; selectedImageID?: string; title: string; @@ -111,6 +119,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, @@ -144,12 +155,12 @@ export const ImageSelect = React.memo((props: ImageSelectProps) => { const { classNames, disabled, + error: errorText, handleSelectImage, images, selectedImageID, title, variant, - ...reactSelectProps } = props; // Check for loading status and request errors in React Query @@ -188,37 +199,55 @@ export const ImageSelect = React.memo((props: ImageSelectProps) => { const onChange = (selection: ImageItem | null) => { if (selection === null) { - return handleSelectImage(undefined); + return handleSelectImage(undefined, undefined); } - return handleSelectImage(selection.value); + const selectedImage = images.find((i) => i.id === selection.value); + + return handleSelectImage(selection.value, selectedImage); }; + const showDistributedCapabilityNotice = + variant === 'private' && + filteredImages.some((image) => + image.capabilities.includes('distributed-images') + ); + return ( {title} - - - + {showDistributedCapabilityNotice && ( + + + + Indicates compatibility with distributed compute regions. + + + )} + ); }, isMemo); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index f74e3570601..67da3a0bbf6 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -36,4 +36,17 @@ 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 compute regions.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index 4f38225e331..c1d8c139f3f 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/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index 94e0f7d417f..dba7a9ae442 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -52,7 +52,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { = { - argTypes: {}, - args: { - children: undefined, - delayInMS: DEFAULT_DELAY, - shouldDelay: false, - }, - component: LandingLoading, - title: 'Components/Loading States/LandingLoading', -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: {}, - render: (args) => , -}; diff --git a/packages/manager/src/components/LandingLoading/LandingLoading.test.tsx b/packages/manager/src/components/LandingLoading/LandingLoading.test.tsx deleted file mode 100644 index ac5944a63a3..00000000000 --- a/packages/manager/src/components/LandingLoading/LandingLoading.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import * as React from 'react'; - -import { DEFAULT_DELAY, LandingLoading } from './LandingLoading'; - -vi.useFakeTimers(); - -const LOADING_ICON = 'circle-progress'; - -describe('LandingLoading', () => { - afterEach(() => { - vi.clearAllTimers(); - }); - - it('renders the loading indicator by default', () => { - render(); - expect(screen.getByTestId(LOADING_ICON)).toBeInTheDocument(); - }); - - it('renders custom loading indicator when children are provided', () => { - render( - -
    Loading...
    -
    - ); - expect(screen.getByTestId('custom-loading-indicator')).toBeInTheDocument(); - expect(screen.queryByTestId(LOADING_ICON)).toBeNull(); - }); - - it('does not render the loading indicator when shouldDelay is true', () => { - render(); - expect(screen.queryByTestId(LOADING_ICON)).toBeNull(); - }); - - it('renders the loading indicator after the delay', () => { - render(); - expect(screen.queryByTestId(LOADING_ICON)).toBeNull(); - act(() => { - vi.advanceTimersByTime(DEFAULT_DELAY); - }); - expect(screen.getByTestId(LOADING_ICON)).toBeInTheDocument(); - }); - - it('renders the loading indicator after the specified delayInMS', () => { - render(); - expect(screen.queryByTestId(LOADING_ICON)).toBeNull(); - act(() => { - vi.advanceTimersByTime(2000); - }); - expect(screen.getByTestId(LOADING_ICON)).toBeInTheDocument(); - }); - - it('does not render the loading indicator when shouldDelay is false and no delayInMS is provided', () => { - render(); - expect(screen.getByTestId(LOADING_ICON)).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/components/LandingLoading/LandingLoading.tsx b/packages/manager/src/components/LandingLoading/LandingLoading.tsx deleted file mode 100644 index 520b04fd21f..00000000000 --- a/packages/manager/src/components/LandingLoading/LandingLoading.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; - -import { CircleProgress } from 'src/components/CircleProgress'; - -export const DEFAULT_DELAY = 1000; - -interface LandingLoadingProps { - /** Allow children to be passed in to override the default loading indicator */ - children?: JSX.Element; - /** If given, the loading indicator will not be rendered for the given duration in milliseconds */ - delayInMS?: number; - /** If true, the loading indicator will not be rendered for 1 second which may give user's with fast connections a more fluid experience. */ - shouldDelay?: boolean; -} - -export const LandingLoading = ({ - children, - delayInMS, - shouldDelay, -}: LandingLoadingProps): JSX.Element | null => { - const [showLoading, setShowLoading] = React.useState(false); - - React.useEffect(() => { - /* This `didCancel` business is to prevent a warning from React. - * See: https://github.com/facebook/react/issues/14369#issuecomment-468267798 - */ - let didCancel = false; - // Reference to the timeoutId so we can cancel it - let timeoutId: NodeJS.Timeout | null = null; - - if (shouldDelay || typeof delayInMS === 'number') { - // Used specified duration or default - const delayDuration = - typeof delayInMS === 'number' ? delayInMS : DEFAULT_DELAY; - - timeoutId = setTimeout(() => { - if (!didCancel) { - setShowLoading(true); - } - }, delayDuration); - } else { - setShowLoading(true); - } - return () => { - didCancel = true; - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [shouldDelay, delayInMS]); - - return showLoading - ? children || - : null; -}; diff --git a/packages/manager/src/components/LinkButton.tsx b/packages/manager/src/components/LinkButton.tsx index 55d118bbc42..4d4c21c25f9 100644 --- a/packages/manager/src/components/LinkButton.tsx +++ b/packages/manager/src/components/LinkButton.tsx @@ -1,10 +1,11 @@ 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 { Box } from './Box'; import { StyledLinkButton } from './Button/StyledLinkButton'; -import { CircularProgress } from './CircularProgress'; const useStyles = makeStyles()((theme: Theme) => ({ disabled: { @@ -12,9 +13,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ cursor: 'default', pointerEvents: 'none', }, - spinner: { - marginLeft: theme.spacing(), - }, })); interface Props { @@ -58,7 +56,9 @@ export const LinkButton = (props: Props) => { return ( {Button} - + + + ); } diff --git a/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx b/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx deleted file mode 100644 index 75e4110188a..00000000000 --- a/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { Dialog } from 'src/components/Dialog/Dialog'; -import { sendCLIClickEvent } from 'src/utilities/analytics/customEventAnalytics'; - -export interface ImageUploadSuccessDialogProps { - analyticsKey?: string; - command: string; - isOpen: boolean; - onClose: () => void; -} - -export const LinodeCLIModal = React.memo( - (props: ImageUploadSuccessDialogProps) => { - const { analyticsKey, command, isOpen, onClose } = props; - - return ( - - - {command}{' '} - sendCLIClickEvent(analyticsKey) : undefined - } - text={command} - /> - - - ); - } -); - -const StyledLinodeCLIModal = styled(Dialog, { - label: 'StyledLinodeCLIModal', -})(({ theme }) => ({ - '& [data-qa-copied]': { - zIndex: 2, - }, - padding: `${theme.spacing()} ${theme.spacing(2)}`, - width: '100%', -})); - -const StyledCommandDisplay = styled('div', { - label: 'StyledCommandDisplay', -})(({ theme }) => ({ - alignItems: 'center', - backgroundColor: theme.bg.main, - border: `1px solid ${theme.color.border2}`, - display: 'flex', - fontFamily: '"UbuntuMono", monospace, sans-serif', - fontSize: '0.875rem', - justifyContent: 'space-between', - lineHeight: 1, - padding: theme.spacing(), - position: 'relative', - whiteSpace: 'nowrap', - width: '100%', - wordBreak: 'break-all', -})); - -const StyledCLIText = styled('div', { - label: 'StyledCLIText', -})(() => ({ - height: '1rem', - overflowX: 'auto', - overflowY: 'hidden', // For Edge - paddingRight: 15, -})); - -const StyledCopyTooltip = styled(CopyTooltip, { - label: 'StyledCopyTooltip', -})(() => ({ - '& svg': { - height: '1em', - width: '1em', - }, - display: 'flex', -})); diff --git a/packages/manager/src/components/MainContentBanner.tsx b/packages/manager/src/components/MainContentBanner.tsx index e639ce257b6..1746e4107fd 100644 --- a/packages/manager/src/components/MainContentBanner.tsx +++ b/packages/manager/src/components/MainContentBanner.tsx @@ -5,7 +5,10 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; import { useFlags } from 'src/hooks/useFlags'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { Box } from './Box'; diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx index 19668bd69bf..c01acbaf9ba 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx @@ -5,7 +5,7 @@ import { Link } from 'react-router-dom'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useAllAccountMaintenanceQuery } from 'src/queries/account/maintenance'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { isPast } from 'src/utilities/isPast'; diff --git a/packages/manager/src/components/MenuItem/MenuItem.tsx b/packages/manager/src/components/MenuItem/MenuItem.tsx index 2473835f403..3d153a1fda0 100644 --- a/packages/manager/src/components/MenuItem/MenuItem.tsx +++ b/packages/manager/src/components/MenuItem/MenuItem.tsx @@ -3,7 +3,6 @@ import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { CircularProgress } from 'src/components/CircularProgress'; import { IconButton } from 'src/components/IconButton'; import { MenuItem, MenuItemProps } from 'src/components/MenuItem'; @@ -88,9 +87,6 @@ export const WrapperMenuItem = (props: WrapperMenuItemCombinedProps) => { {...rest} className={`${classes.root} ${className} ${tooltip && 'hasTooltip'}`} > - {isLoading && ( - - )} {props.children} diff --git a/packages/manager/src/components/Notice/Notice.stories.tsx b/packages/manager/src/components/Notice/Notice.stories.tsx index ffbc41e9042..1ffe5dc5281 100644 --- a/packages/manager/src/components/Notice/Notice.stories.tsx +++ b/packages/manager/src/components/Notice/Notice.stories.tsx @@ -95,7 +95,5 @@ const meta: Meta = { export default meta; const StyledWrapper = styled('div')(({ theme }) => ({ - backgroundColor: theme.color.grey2, - padding: theme.spacing(2), })); diff --git a/packages/manager/src/components/Notice/Notice.test.tsx b/packages/manager/src/components/Notice/Notice.test.tsx index cf12ad28ca2..e7d536cd907 100644 --- a/packages/manager/src/components/Notice/Notice.test.tsx +++ b/packages/manager/src/components/Notice/Notice.test.tsx @@ -58,7 +58,7 @@ describe('Notice Component', () => { it('applies variant prop', () => { const { container } = renderWithTheme(); - expect(container.firstChild).toHaveStyle('border-left: 5px solid #ca0813;'); + expect(container.firstChild).toHaveStyle('border-left: 5px solid #d63c42;'); }); it('displays icon for important notices', () => { diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index a4afc094d55..dc05c89a622 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -5,7 +5,10 @@ import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { usePrevious } from 'src/hooks/usePrevious'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { ManagerPreferences } from 'src/types/ManagerPreferences'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { @@ -95,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. @@ -152,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/Paper.tsx b/packages/manager/src/components/Paper.tsx index c12f8ce9530..61bc8f129c3 100644 --- a/packages/manager/src/components/Paper.tsx +++ b/packages/manager/src/components/Paper.tsx @@ -30,6 +30,7 @@ export const Paper = (props: Props) => { {props.error && {props.error}} diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.tsx index 7f76d06bf24..a8e092169f2 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.tsx @@ -19,7 +19,6 @@ const PasswordInput = (props: Props) => { hideStrengthLabel, hideValidation, required, - tooltipInteractive, value, ...rest } = props; @@ -33,7 +32,6 @@ const PasswordInput = (props: Props) => { {...rest} fullWidth required={required} - tooltipInteractive={tooltipInteractive} tooltipText={disabledReason} value={value} /> diff --git a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx index b279ec36f83..b4494ec9a26 100644 --- a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx +++ b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx @@ -1,4 +1,7 @@ -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; export interface PreferenceToggleProps { preference: T; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts index c62e5c2d996..03912e910e5 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts @@ -1,8 +1,9 @@ -import { Theme } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ active: { @@ -10,7 +11,7 @@ const useStyles = makeStyles()( opacity: 1, }, '& svg': { - color: theme.color.teal, + color: theme.palette.success.dark, }, backgroundImage: 'linear-gradient(98deg, #38584B 1%, #3A5049 166%)', textDecoration: 'none', @@ -22,12 +23,6 @@ const useStyles = makeStyles()( backgroundColor: 'rgba(0, 0, 0, 0.12)', color: '#222', }, - fadeContainer: { - display: 'flex', - flexDirection: 'column', - height: 'calc(100% - 90px)', - width: '100%', - }, linkItem: { '&.hiddenWhenCollapsed': { maxHeight: 36, @@ -70,8 +65,8 @@ const useStyles = makeStyles()( opacity: 1, }, '& svg': { - color: theme.color.teal, - fill: theme.color.teal, + color: theme.palette.success.dark, + fill: theme.palette.success.dark, }, [`& .${classes.linkItem}`]: { color: 'white', @@ -86,7 +81,6 @@ const useStyles = makeStyles()( minWidth: SIDEBAR_WIDTH, padding: '8px 13px', position: 'relative', - transition: theme.transitions.create(['background-color']), }, logo: { '& .akamai-logo-name': { @@ -96,7 +90,7 @@ const useStyles = makeStyles()( transition: 'width .1s linear', }, logoAkamaiCollapsed: { - background: theme.bg.primaryNavPaper, + background: theme.bg.appBar, width: 83, }, logoContainer: { @@ -111,6 +105,7 @@ const useStyles = makeStyles()( }, logoItemAkamai: { alignItems: 'center', + backgroundColor: theme.name === 'dark' ? theme.bg.appBar : undefined, display: 'flex', height: 50, paddingLeft: 13, diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index b23c8067b41..180c6c5a32b 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -1,8 +1,9 @@ import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { Link, LinkProps, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import Account from 'src/assets/icons/account.svg'; +import CloudPulse from 'src/assets/icons/cloudpulse.svg'; import Beta from 'src/assets/icons/entityIcons/beta.svg'; import Storage from 'src/assets/icons/entityIcons/bucket.svg'; import Database from 'src/assets/icons/entityIcons/database.svg'; @@ -42,6 +43,8 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; import { linkIsActive } from './utils'; +import type { LinkProps } from 'react-router-dom'; + type NavEntity = | 'Account' | 'Account' @@ -58,6 +61,7 @@ type NavEntity = | 'Longview' | 'Managed' | 'Marketplace' + | 'Monitor' | 'NodeBalancers' | 'Object Storage' | 'Placement Groups' @@ -156,6 +160,10 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const allowMarketplacePrefetch = !oneClickApps && !oneClickAppsLoading && !oneClickAppsError; + const showCloudPulse = Boolean(flags.aclp?.enabled); + // the followed comment is for later use, the showCloudPulse will be removed and isACLPEnabled will be used + // const { isACLPEnabled } = useIsACLPEnabled(); + const { isACLBEnabled } = useIsACLBEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); @@ -275,6 +283,13 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/longview', icon: , }, + { + display: 'Monitor', + hide: !showCloudPulse, + href: '/monitor/cloudpulse', + icon: , + isBeta: flags.aclp?.beta, + }, { attr: { 'data-qa-one-click-nav-btn': true }, display: 'Marketplace', @@ -313,6 +328,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isACLBEnabled, isPlacementGroupsEnabled, flags.placementGroups, + showCloudPulse, ] ); @@ -329,7 +345,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { spacing={0} wrap="nowrap" > - + { -
    - {primaryLinkGroups.map((thisGroup, idx) => { - const filteredLinks = thisGroup.filter((thisLink) => !thisLink.hide); - if (filteredLinks.length === 0) { - return null; - } - return ( -
    - - {filteredLinks.map((thisLink) => { - const props = { - closeMenu, - isCollapsed, - key: thisLink.display, - locationPathname: location.pathname, - locationSearch: location.search, - ...thisLink, - }; - - // PrefetchPrimaryLink and PrimaryLink are two separate components because invocation of - // hooks cannot be conditional. is a wrapper around - // that includes the usePrefetch hook. - return thisLink.prefetchRequestFn && - thisLink.prefetchRequestCondition !== undefined ? ( - - ) : ( - - ); + + {primaryLinkGroups.map((thisGroup, idx) => { + const filteredLinks = thisGroup.filter((thisLink) => !thisLink.hide); + if (filteredLinks.length === 0) { + return null; + } + return ( +
    + ({ + borderColor: + theme.name === 'light' + ? theme.borderColors.dividerDark + : 'rgba(0, 0, 0, 0.19)', })} -
    - ); - })} -
    + className={classes.divider} + spacingBottom={11} + /> + {filteredLinks.map((thisLink) => { + const props = { + closeMenu, + isCollapsed, + key: thisLink.display, + locationPathname: location.pathname, + locationSearch: location.search, + ...thisLink, + }; + + // PrefetchPrimaryLink and PrimaryLink are two separate components because invocation of + // hooks cannot be conditional. is a wrapper around + // that includes the usePrefetch hook. + return thisLink.prefetchRequestFn && + thisLink.prefetchRequestCondition !== undefined ? ( + + ) : ( + + ); + })} +
    + ); + })}
    ); }; diff --git a/packages/manager/src/components/PrimaryNav/SideMenu.tsx b/packages/manager/src/components/PrimaryNav/SideMenu.tsx index e901df9883c..5be1241600f 100644 --- a/packages/manager/src/components/PrimaryNav/SideMenu.tsx +++ b/packages/manager/src/components/PrimaryNav/SideMenu.tsx @@ -66,7 +66,8 @@ const StyledDrawer = styled(Drawer, { shouldForwardProp: (prop) => prop !== 'collapse', })<{ collapse?: boolean }>(({ theme, ...props }) => ({ '& .MuiDrawer-paper': { - backgroundColor: theme.bg.primaryNavPaper, + backgroundColor: + theme.name === 'dark' ? theme.bg.appBar : theme.bg.primaryNavPaper, borderRight: 'none', boxShadow: 'none', height: '100%', 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..2d3126e008a 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,16 +57,17 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { currentCapability, disabled, errorText, - handleSelection, helperText, isClearable, label, + onChange, placeholder, regions, required, selectedIds, sortRegionOptions, width, + onClose, } = props; const { @@ -72,84 +75,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 +138,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,13 +164,15 @@ 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} + onClose={onClose} /> {selectedRegions.length > 0 && SelectedRegionsList && ( diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 3e8918c70aa..718f43358dc 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -1,9 +1,10 @@ import { visuallyHidden } from '@mui/utils'; import React from 'react'; -import EdgeRegion from 'src/assets/icons/entityIcons/edge-region.svg'; +import DistributedRegion from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Tooltip } from 'src/components/Tooltip'; import { TooltipIcon } from 'src/components/TooltipIcon'; @@ -11,36 +12,39 @@ import { SelectedIcon, StyledFlagContainer, StyledListItem, - sxEdgeIcon, + 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 = { - displayEdgeRegionIcon?: boolean; - option: RegionSelectOption; +interface Props { + disabledOptions?: DisableRegionOption; props: React.HTMLAttributes; + region: Region; selected?: boolean; -}; +} export const RegionOption = ({ - displayEdgeRegionIcon, - 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 { isGeckoBetaEnabled, isGeckoGAEnabled } = useIsGeckoEnabled(); + const displayDistributedRegionIcon = + isGeckoBetaEnabled && region.site_type === 'distributed'; return ( @@ -72,25 +75,26 @@ export const RegionOption = ({ <> - + - {label} - {displayEdgeRegionIcon && ( + {isGeckoGAEnabled ? region.label : `${region.label} (${region.id})`} + {displayDistributedRegionIcon && ( -  (This region is an edge region.) +  (This region is a distributed region.) )} {isRegionDisabled && isRegionDisabledReason && ( {isRegionDisabledReason} )} - {selected && } - {displayEdgeRegionIcon && ( + {isGeckoGAEnabled && `(${region.id})`} + {selected && } + {displayDistributedRegionIcon && ( } + icon={} status="other" - sxTooltipIcon={sxEdgeIcon} - text="This region is an edge region." + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." /> )} 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.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts index f155d0a96e2..1d53b231832 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts @@ -30,7 +30,7 @@ export const StyledAutocompleteContainer = styled(Box, { }, })); -export const sxEdgeIcon = { +export const sxDistributedRegionIcon = { '& svg': { color: 'inherit !important', height: 21, @@ -43,28 +43,28 @@ export const sxEdgeIcon = { padding: 0, }; -export const StyledEdgeBox = styled(Box, { label: 'StyledEdgeBox' })( - ({ theme }) => ({ - '& svg': { - height: 21, - marginLeft: 8, - marginRight: 8, - width: 24, - }, - alignSelf: 'end', - color: 'inherit', - display: 'flex', +export const StyledDistributedRegionBox = styled(Box, { + label: 'StyledDistributedRegionBox', +})(({ theme }) => ({ + '& svg': { + height: 21, marginLeft: 8, - padding: '8px 0', - [theme.breakpoints.down('md')]: { - '& svg': { - marginLeft: 0, - }, - alignSelf: 'start', + marginRight: 8, + width: 24, + }, + alignSelf: 'end', + color: 'inherit', + display: 'flex', + marginLeft: 8, + padding: '8px 0', + [theme.breakpoints.down('md')]: { + '& svg': { marginLeft: 0, }, - }) -); + alignSelf: 'start', + marginLeft: 0, + }, +})); export const StyledFlagContainer = styled('div', { label: 'RegionSelectFlagContainer', diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx index 079579855fb..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, }; @@ -66,21 +65,25 @@ describe('RegionSelect', () => { expect(getByTestId('textfield-input')).toBeDisabled(); }); - it('should render a Select component with edge region text', () => { + it('should render a Select component with distributed region text', () => { const newProps = { ...props, - showEdgeIconHelperText: true, + showDistributedRegionIconHelperText: true, }; const { getByTestId } = renderWithTheme(); - expect(getByTestId('region-select-edge-text')).toBeInTheDocument(); + expect( + getByTestId('region-select-distributed-region-text') + ).toBeInTheDocument(); }); - it('should render a Select component with no edge region text', () => { + it('should render a Select component with no distributed region text', () => { const newProps = { ...props, - showEdgeIconHelperText: false, + showDistributedRegionIconHelperText: false, }; const { queryByTestId } = renderWithTheme(); - expect(queryByTestId('region-select-edge-text')).not.toBeInTheDocument(); + expect( + queryByTestId('region-select-distributed-region-text') + ).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 71a8451ab38..c05d90afd2e 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -1,135 +1,136 @@ import { Typography } from '@mui/material'; import * as React from 'react'; -import EdgeRegion from 'src/assets/icons/entityIcons/edge-region.svg'; +import DistributedRegion from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Flag } from 'src/components/Flag'; import { Link } from 'src/components/Link'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import { RegionOption } from './RegionOption'; import { StyledAutocompleteContainer, - StyledEdgeBox, + StyledDistributedRegionBox, StyledFlagContainer, - sxEdgeIcon, + 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. * * The RegionSelect automatically filters regions based on capability using its `currentCapability` prop. For example, if - * `currentCapability="VPCs"`, only regions that support VPCs will appear in the RegionSelect dropdown. Edge regions are filtered based on the `regionFilter` prop. + * `currentCapability="VPCs"`, only regions that support VPCs will appear in the RegionSelect dropdown. Distributed regions are filtered based on the `regionFilter` prop. * There is no need to pre-filter regions when passing them to the RegionSelect. See the description of `currentCapability` prop for more information. * * 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, - showEdgeIconHelperText, + showDistributedRegionIconHelperText, tooltipText, + value, width, } = props; + const { isGeckoBetaEnabled, isGeckoGAEnabled } = useIsGeckoEnabled(); + const { data: accountAvailability, 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, + isGeckoGAEnabled, + 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; + }, {}); + + const EndAdornment = React.useMemo(() => { + // @TODO Gecko: Remove adornment after GA + if (isGeckoBetaEnabled && selectedRegion?.site_type === 'distributed') { + return ( + } + status="other" + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." + /> + ); + } + if (isGeckoGAEnabled && selectedRegion) { + return `(${selectedRegion?.id})`; + } + return null; + }, [isGeckoBetaEnabled, isGeckoGAEnabled, selectedRegion]); return ( - - Boolean(option.disabledProps?.disabled) + + getOptionLabel={(region) => + isGeckoGAEnabled ? region.label : `${region.label} (${region.id})` } - isOptionEqualToValue={( - option: RegionSelectOption, - { value }: RegionSelectOption - ) => option.value === value} - onChange={(_, selectedOption: RegionSelectOption) => { - handleRegionChange(selectedOption); - }} - onKeyDown={(e) => { - if (e.key !== 'Tab') { - setSelectedRegion(null); - handleRegionChange(null); - } - }} - renderOption={(props, option) => { - return ( - - ); - }} + renderOption={(props, region) => ( + + )} sx={(theme) => ({ [theme.breakpoints.up('md')]: { width: '416px', @@ -138,19 +139,11 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { textFieldProps={{ ...props.textFieldProps, InputProps: { - endAdornment: regionFilter !== 'core' && - selectedRegion?.site_type === 'edge' && ( - } - status="other" - sxTooltipIcon={sxEdgeIcon} - text="This region is an edge region." - /> - ), + endAdornment: EndAdornment, required, startAdornment: selectedRegion && ( - + ), }, @@ -159,35 +152,40 @@ 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])} + 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} /> - {showEdgeIconHelperText && ( // @TODO Gecko Beta: Add docs link - - + {showDistributedRegionIconHelperText && ( // @TODO Gecko Beta: Add docs link + + {' '} - Indicates an edge region.{' '} - + Indicates a distributed region.{' '} + Learn more . - + )} ); -}); +}; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index e8a32a92190..0b01fc594c0 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -1,33 +1,44 @@ -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 type RegionFilterValue = + | 'distributed-AF' + | 'distributed-ALL' + | 'distributed-AS' + | 'distributed-EU' + | 'distributed-NA' + | 'distributed-OC' + | 'distributed-SA' + | RegionSite; + +export interface GetRegionLabel { + includeSlug?: boolean; + region: Region; +} +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 +48,48 @@ 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; + regionFilter?: RegionFilterValue; regions: Region[]; required?: boolean; - selectedId: null | string; - showEdgeIconHelperText?: boolean; + 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 SupportedEdgeTypes = '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 8bdf1472598..1681e0bb59b 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -2,274 +2,283 @@ 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 regionsWithEdge = [ - ...regions, - regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'us-edge-1', - label: 'Gecko Edge Test', - site_type: 'edge', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'us-edge-2', - label: 'Gecko Edge Test 2', - site_type: 'edge', - }), -]; - -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 expectedEdgeRegions = [ - { - data: { country: 'us', region: 'North America' }, - disabledProps: { - disabled: false, - }, - label: 'Gecko Edge Test (us-edge-1)', - site_type: 'edge', - value: 'us-edge-1', - }, - { - data: { country: 'us', region: 'North America' }, - disabledProps: { - disabled: false, - }, - label: 'Gecko Edge Test 2 (us-edge-2)', - site_type: 'edge', - value: 'us-edge-2', - }, -]; - 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, + const result = getRegionOptions({ currentCapability: 'Linodes', - regions: regionsToFilter, + regions: distributedRegions, }); - expect(result).toEqual(expectedRegions); + expect(result).toEqual([ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'us-den-10', + label: 'Gecko Distributed Region Test', + site_type: 'distributed', + }), + ]); }); - it('should filter out edge regions if regionFilter is core', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', + 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: regionsWithEdge, + regions, }); - expect(result).toEqual(expectedRegions); + expect(result).toEqual([ + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]); }); - it('should filter out core regions if regionFilter is edge', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regionFilter: 'edge', - regions: regionsWithEdge, + 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 = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed', + regions, }); - expect(result).toEqual(expectedEdgeRegions); + expect(result).toEqual([ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + ]); }); it('should not filter out any regions if regionFilter is undefined', () => { - const expectedRegionsWithEdge = [ - ...expectedEdgeRegions, - ...expectedRegions, + 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: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', + const result = getRegionOptions({ + currentCapability: undefined, regionFilter: undefined, - regions: regionsWithEdge, + regions, }); - expect(result).toEqual(expectedRegionsWithEdge); + expect(result).toEqual(regions); }); - it('should have its option disabled if the region is unavailable', () => { - const _regions = [ - ...regions, + it('should filter out distributed regions by continent if the regionFilter includes continent', () => { + const regions2 = [ regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'ap-south', - label: 'US Location 2', + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + regionFactory.build({ + id: 'us-1', + label: 'US Site 2', + site_type: 'core', + }), + regionFactory.build({ + country: 'de', + id: 'eu-2', + label: 'EU Site 2', + site_type: 'distributed', }), ]; - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions: _regions, + const resultNA = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed-NA', + regions: regions2, }); - - 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, - }), - regions, + const resultEU = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed-EU', + regions: regions2, }); - const unavailableRegion = result.find((region) => region.value === 'us-1'); - - expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + expect(resultNA).toEqual([ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + ]); + expect(resultEU).toEqual([ + regionFactory.build({ + country: 'de', + id: 'eu-2', + label: 'EU Site 2', + site_type: 'distributed', + }), + ]); }); -}); -describe('getSelectedRegionById', () => { - it('should return the correct OptionType for a selected region', () => { - const selectedRegionId = 'us-1'; - - const result = getSelectedRegionById({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions, - selectedRegionId, - }); - - // Expected result - const expected = { - data: { + it('should not filter out distributed regions by continent if the regionFilter includes all', () => { + const regions: Region[] = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'core', + }), + regionFactory.build({ + country: 'de', + id: 'eu-2', + label: 'EU Site 2', + site_type: 'distributed', + }), + regionFactory.build({ country: 'us', - region: 'North America', - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }; - - expect(result).toEqual(expected); - }); - - it('should return undefined for an unknown region', () => { - const selectedRegionId = 'unknown'; + id: 'us-2', + label: 'US Site 2', + site_type: 'distributed', + }), + ]; - const result = getSelectedRegionById({ - accountAvailabilityData, - currentCapability: 'Linodes', + const resultAll = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed-ALL', regions, - selectedRegionId, }); - expect(result).toBeUndefined(); + expect(resultAll).toEqual([ + regionFactory.build({ + country: 'us', + id: 'us-2', + label: 'US Site 2', + site_type: 'distributed', + }), + regionFactory.build({ + country: 'de', + id: 'eu-2', + label: 'EU Site 2', + site_type: 'distributed', + }), + ]); }); }); +const accountAvailabilityData = [ + accountAvailabilityFactory.build({ + region: 'ap-south', + unavailable: ['Linodes'], + }), +]; + describe('getRegionOptionAvailability', () => { it('should return true if the region is not available', () => { const result = isRegionOptionUnavailable({ @@ -295,64 +304,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 7c9d84b3b1a..7d7c21d8e9b 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -1,154 +1,101 @@ import { CONTINENT_CODE_TO_CONTINENT } from '@linode/api-v4'; -import { - getRegionCountryGroup, - getSelectedRegion, -} from 'src/utilities/formatRegion'; +import { useFlags } from 'src/hooks/useFlags'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import type { + GetRegionLabel, GetRegionOptionAvailability, - GetRegionOptions, - GetSelectedRegionById, - GetSelectedRegionsByIdsArgs, - RegionSelectOption, - SupportedEdgeTypes, + RegionFilterValue, } from './RegionSelect.types'; -import type { AccountAvailability, Region } from '@linode/api-v4'; +import type { AccountAvailability, Capabilities, Region } from '@linode/api-v4'; 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; + isGeckoGAEnabled?: boolean; + regionFilter?: RegionFilterValue; + regions: Region[]; +} + export const getRegionOptions = ({ - accountAvailabilityData, currentCapability, - handleDisabledRegion, + isGeckoGAEnabled, 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) { + const [, distributedContinentCode] = regionFilter.split('distributed-'); + // Filter distributed regions by geographical area + if (distributedContinentCode && distributedContinentCode !== 'ALL') { + const group = getRegionCountryGroup(region); + return ( + region.site_type === 'edge' || + (region.site_type === 'distributed' && + CONTINENT_CODE_TO_CONTINENT[distributedContinentCode] === group) + ); + } + return regionFilter.includes(region.site_type); + } + 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) { - return 1; - } - if (region1.data.country > region2.data.country) { - return -1; + // We want to group by label for Gecko GA + if (!isGeckoGAEnabled) { + // Then we group by country + if (region1.country < region2.country) { + return 1; + } + if (region1.country > region2.country) { + return -1; + } } - // If regions are in the same group or country, sort alphabetically by label + // Then we group by label if (region1.label < region2.label) { return -1; } + if (region1.label > region2.label) { + return 1; + } return 1; }); }; -/** - * 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. * @@ -178,59 +125,54 @@ export const isRegionOptionUnavailable = ({ }; /** - * This utility function takes an array of region IDs and returns an array of corresponding RegionSelectOption objects. + * Util to determine whether a create type has support for distributed regions. * - * @returns An array of RegionSelectOption objects corresponding to the selected region IDs. + * @returns a boolean indicating whether or not the create type supports distributed regions. */ -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 edge regions. - * - * @returns a boolean indicating whether or not the create type is edge supported. - */ -export const getIsLinodeCreateTypeEdgeSupported = ( - createType: LinodeCreateType -) => { - const supportedEdgeTypes: SupportedEdgeTypes[] = [ +export const isDistributedRegionSupported = (createType: LinodeCreateType) => { + const supportedDistributedRegionTypes = [ 'Distributions', 'StackScripts', + 'Images', + undefined, // /linodes/create route ]; - return ( - supportedEdgeTypes.includes(createType as SupportedEdgeTypes) || - typeof createType === 'undefined' // /linodes/create route - ); + return supportedDistributedRegionTypes.includes(createType); }; /** - * Util to determine whether a selected region is an edge region. + * Util to determine whether a selected region is a distributed region. * - * @returns a boolean indicating whether or not the selected region is an edge region. + * @returns a boolean indicating whether or not the selected region is a distributed region. */ -export const getIsEdgeRegion = ( +export const getIsDistributedRegion = ( regionsData: Region[], selectedRegion: string ) => { - return ( - regionsData.find( - (region) => - region.id === selectedRegion || region.label === selectedRegion - )?.site_type === 'edge' + const region = regionsData.find( + (region) => region.id === selectedRegion || region.label === selectedRegion ); + return region?.site_type === 'distributed' || region?.site_type === 'edge'; +}; + +export const getNewRegionLabel = ({ includeSlug, region }: GetRegionLabel) => { + const [city] = region.label.split(', '); + if (includeSlug) { + return `${region.country.toUpperCase()}, ${city} ${`(${region.id})`}`; + } + return `${region.country.toUpperCase()}, ${city}`; +}; + +export const useIsGeckoEnabled = () => { + const flags = useFlags(); + const isGeckoGA = flags?.gecko2?.enabled && flags.gecko2.ga; + const isGeckoBeta = flags.gecko2?.enabled && !flags.gecko2?.ga; + const { data: regions } = useRegionsQuery(isGeckoGA); + + const hasDistributedRegionCapability = regions?.some((region: Region) => + region.capabilities.includes('Distributed Plans') + ); + const isGeckoGAEnabled = hasDistributedRegionCapability && isGeckoGA; + const isGeckoBetaEnabled = hasDistributedRegionCapability && isGeckoBeta; + + return { isGeckoBetaEnabled, isGeckoGAEnabled }; }; diff --git a/packages/manager/src/components/RegionSelect/TwoStepRegionSelect.tsx b/packages/manager/src/components/RegionSelect/TwoStepRegionSelect.tsx new file mode 100644 index 00000000000..c651d2d2a5d --- /dev/null +++ b/packages/manager/src/components/RegionSelect/TwoStepRegionSelect.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Box } from 'src/components/Box'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { Tab } from 'src/components/Tabs/Tab'; +import { TabList } from 'src/components/Tabs/TabList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; + +import type { + DisableRegionOption, + RegionFilterValue, +} from './RegionSelect.types'; +import type { Region } from '@linode/api-v4'; +import type { SelectRegionPanelProps } from 'src/components/SelectRegionPanel/SelectRegionPanel'; + +interface TwoStepRegionSelectProps + extends Omit { + disabledRegions: Record; + regions: Region[]; + value?: string; +} + +interface GeographicalAreaOption { + label: string; + value: RegionFilterValue; +} + +const GEOGRAPHICAL_AREA_OPTIONS: GeographicalAreaOption[] = [ + { + label: 'All', + value: 'distributed-ALL', + }, + { + label: 'North America', + value: 'distributed-NA', + }, + { + label: 'Africa', + value: 'distributed-AF', + }, + { + label: 'Asia', + value: 'distributed-AS', + }, + { + label: 'Europe', + value: 'distributed-EU', + }, + { + label: 'Oceania', + value: 'distributed-OC', + }, + { + label: 'South America', + value: 'distributed-SA', + }, +]; + +export const TwoStepRegionSelect = (props: TwoStepRegionSelectProps) => { + const { + RegionSelectProps, + currentCapability, + disabled, + disabledRegions, + error, + handleSelection, + helperText, + regions, + value, + } = props; + + const [regionFilter, setRegionFilter] = React.useState( + 'distributed' + ); + + return ( + + + Core + Distributed + + + + + sendLinodeCreateDocsEvent('Speedtest')} + /> + + handleSelection(region.id)} + regionFilter="core" + regions={regions ?? []} + showDistributedRegionIconHelperText={false} + value={value} + {...RegionSelectProps} + /> + + + { + if (selectedOption?.value) { + setRegionFilter(selectedOption.value); + } + }} + defaultValue={GEOGRAPHICAL_AREA_OPTIONS[0]} + disableClearable + label="Geographical Area" + options={GEOGRAPHICAL_AREA_OPTIONS} + /> + handleSelection(region.id)} + regionFilter={regionFilter} + regions={regions ?? []} + showDistributedRegionIconHelperText={false} + value={value} + {...RegionSelectProps} + /> + + + + ); +}; 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.test.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx index ab77c8c36be..48d8a822850 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx @@ -1,40 +1,27 @@ -import { Capabilities } from '@linode/api-v4'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { regionFactory } from 'src/factories'; +import { imageFactory, 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 { SelectRegionPanel } from './SelectRegionPanel'; +import type { Capabilities } from '@linode/api-v4'; + const pricingMocks = vi.hoisted(() => ({ isLinodeTypeDifferentPriceInSelectedRegion: vi.fn().mockReturnValue(false), })); -const queryParamMocks = vi.hoisted(() => ({ - getQueryParamFromQueryString: vi.fn().mockReturnValue({}), - getQueryParamsFromQueryString: vi.fn().mockReturnValue({}), -})); - vi.mock('src/utilities/pricing/linodes', () => ({ isLinodeTypeDifferentPriceInSelectedRegion: pricingMocks.isLinodeTypeDifferentPriceInSelectedRegion, })); -vi.mock('src/utilities/queryParams', () => ({ - getQueryParamFromQueryString: queryParamMocks.getQueryParamFromQueryString, - getQueryParamsFromQueryString: queryParamMocks.getQueryParamsFromQueryString, -})); - const createPath = '/linodes/create'; describe('SelectRegionPanel on the Clone Flow', () => { - beforeEach(() => { - queryParamMocks.getQueryParamsFromQueryString.mockReturnValue({ - regionID: 'us-east', - type: 'Clone+Linode', - }); - }); - const regions = [...regionFactory.buildList(3)]; const mockedProps = { currentCapability: 'Linodes' as Capabilities, @@ -92,7 +79,9 @@ describe('SelectRegionPanel on the Clone Flow', () => { , { MemoryRouter: { - initialEntries: [createPath], + initialEntries: [ + '/linodes/create?regionID=us-east&type=Clone+Linode', + ], }, } ); @@ -110,7 +99,9 @@ describe('SelectRegionPanel on the Clone Flow', () => { , { MemoryRouter: { - initialEntries: [createPath], + initialEntries: [ + '/linodes/create?regionID=us-east&type=Clone+Linode', + ], }, } ); @@ -129,7 +120,9 @@ describe('SelectRegionPanel on the Clone Flow', () => { , { MemoryRouter: { - initialEntries: [createPath], + initialEntries: [ + '/linodes/create?regionID=us-east&type=Clone+Linode', + ], }, } ); @@ -139,4 +132,51 @@ describe('SelectRegionPanel on the Clone Flow', () => { expect(getByTestId('cross-data-center-notice')).toBeInTheDocument(); expect(getByTestId('different-price-structure-notice')).toBeInTheDocument(); }); + + it('should disable distributed regions if the selected image does not have the `distributed-images` capability', async () => { + const image = imageFactory.build({ capabilities: [] }); + + const distributedRegion = regionFactory.build({ + capabilities: ['Linodes'], + site_type: 'distributed', + }); + const coreRegion = regionFactory.build({ + capabilities: ['Linodes'], + site_type: 'core', + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json( + makeResourcePage([coreRegion, distributedRegion]) + ); + }), + http.get('*/v4/images', () => { + return HttpResponse.json(makeResourcePage([image])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme( + , + { + MemoryRouter: { initialEntries: ['/linodes/create?type=Images'] }, + } + ); + + const regionSelect = getByLabelText('Region'); + + await userEvent.click(regionSelect); + + const distributedRegionOption = await findByText(distributedRegion.id, { + exact: false, + }); + + expect(distributedRegionOption.closest('li')?.textContent).toContain( + 'The selected image cannot be deployed to a distributed region.' + ); + }); }); diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 2f6c9fe21b0..b78fcdc2e42 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'; @@ -6,11 +5,15 @@ import { useLocation } from 'react-router-dom'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { getIsLinodeCreateTypeEdgeSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; +import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { TwoStepRegionSelect } from 'src/components/RegionSelect/TwoStepRegionSelect'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { Typography } from 'src/components/Typography'; +import { getDisabledRegions } from 'src/features/Linodes/LinodeCreatev2/Region.utils'; import { CROSS_DATA_CENTER_CLONE_WARNING } from 'src/features/Linodes/LinodesCreate/constants'; import { useFlags } from 'src/hooks/useFlags'; +import { useImageQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; @@ -25,22 +28,25 @@ 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; +export interface SelectRegionPanelProps { + RegionSelectProps?: Partial>; currentCapability: Capabilities; disabled?: boolean; error?: string; handleSelection: (id: string) => void; helperText?: string; selectedId?: string; + selectedImageId?: string; /** * Include a `selectedLinodeTypeId` so we can tell if the region selection will have an affect on price */ selectedLinodeTypeId?: string; + updateTypeID?: (key: string) => void; } export const SelectRegionPanel = (props: SelectRegionPanelProps) => { @@ -52,14 +58,19 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { handleSelection, helperText, selectedId, + selectedImageId, selectedLinodeTypeId, + updateTypeID, } = props; const flags = useFlags(); const location = useLocation(); const theme = useTheme(); const params = getQueryParamsFromQueryString(location.search); - const { data: regions } = useRegionsQuery(); + + const { isGeckoGAEnabled } = useIsGeckoEnabled(); + + const { data: regions } = useRegionsQuery(isGeckoGAEnabled); const isCloning = /clone/i.test(params.type); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); @@ -69,6 +80,11 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { Boolean(selectedLinodeTypeId) ); + const { data: image } = useImageQuery( + selectedImageId ?? '', + Boolean(selectedImageId) + ); + const currentLinodeRegion = params.regionID; const showCrossDataCenterCloneWarning = @@ -82,25 +98,38 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { type, }); - const hideEdgeRegions = + const hideDistributedRegions = !flags.gecko2?.enabled || - flags.gecko2?.ga || - !getIsLinodeCreateTypeEdgeSupported(params.type as LinodeCreateType); + !isDistributedRegionSupported(params.type as LinodeCreateType); - const showEdgeIconHelperText = Boolean( - !hideEdgeRegions && + const showDistributedRegionIconHelperText = Boolean( + !hideDistributedRegions && currentCapability && regions?.find( (region) => - region.site_type === 'edge' && + (region.site_type === 'distributed' || region.site_type === 'edge') && region.capabilities.includes(currentCapability) ) ); + const disabledRegions = getDisabledRegions({ + linodeCreateTab: params.type as LinodeCreateType, + regions: regions ?? [], + selectedImage: image, + }); + if (regions?.length === 0) { return null; } + const handleRegionSelection = (regionId: string) => { + handleSelection(regionId); + // Reset plan selection on region change to prevent creation of an edge plan in a core region and vice versa + if (updateTypeID) { + updateTypeID(''); + } + }; + return ( { label={DOCS_LINK_LABEL_DC_PRICING} /> - sendLinodeCreateDocsEvent('Speedtest')} - /> + {!isGeckoGAEnabled && ( + sendLinodeCreateDocsEvent('Speedtest')} + /> + )} {showCrossDataCenterCloneWarning ? ( { ) : null} - + {isGeckoGAEnabled && + isDistributedRegionSupported(params.type as LinodeCreateType) ? ( + + ) : ( + handleSelection(region.id)} + regions={regions ?? []} + value={selectedId} + {...RegionSelectProps} + /> + )} {showClonePriceWarning && ( ({ '&.notistack-MuiContent-error': { - borderLeft: `6px solid ${theme.palette.error.dark}`, + backgroundColor: theme.notificationToast.error.backgroundColor, + borderLeft: theme.notificationToast.error.borderLeft, }, '&.notistack-MuiContent-info': { - borderLeft: `6px solid ${theme.palette.primary.main}`, + backgroundColor: theme.notificationToast.info.backgroundColor, + borderLeft: theme.notificationToast.info.borderLeft, }, '&.notistack-MuiContent-success': { - borderLeft: `6px solid ${theme.palette.success.main}`, // corrected to palette.success + backgroundColor: theme.notificationToast.success.backgroundColor, + borderLeft: theme.notificationToast.success.borderLeft, }, '&.notistack-MuiContent-warning': { - borderLeft: `6px solid ${theme.palette.warning.dark}`, + backgroundColor: theme.notificationToast.warning.backgroundColor, + borderLeft: theme.notificationToast.warning.borderLeft, }, }) ); const useStyles = makeStyles()((theme: Theme) => ({ root: { - '& div': { - backgroundColor: `${theme.bg.white} !important`, - color: theme.palette.text.primary, + '& .notistack-MuiContent': { + color: theme.notificationToast.default.color, fontSize: '0.875rem', }, + '& .notistack-MuiContent-default': { + backgroundColor: theme.notificationToast.default.backgroundColor, + borderLeft: theme.notificationToast.default.borderLeft, + }, [theme.breakpoints.down('md')]: { '& .SnackbarItem-contentRoot': { flexWrap: 'nowrap', diff --git a/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx b/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx index d12d5a8539f..ac8d4b13f96 100644 --- a/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx +++ b/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Button } from 'src/components/Button/Button'; import { Snackbar } from 'src/components/Snackbar/Snackbar'; +import { getEventMessage } from 'src/features/Events/utils'; import type { Meta, StoryObj } from '@storybook/react'; @@ -96,3 +97,48 @@ function Example() { ); } + +export const WithEventMessage: Story = { + args: { + anchorOrigin: { horizontal: 'right', vertical: 'bottom' }, + hideIconVariant: true, + maxSnack: 5, + }, + render: (args) => { + const WithEventMessage = () => { + const { enqueueSnackbar } = useSnackbar(); + const message = getEventMessage({ + action: 'placement_group_assign', + entity: { + label: 'Entity', + url: 'https://google.com', + }, + secondary_entity: { + label: 'Secondary Entity', + url: 'https://google.com', + }, + status: 'notification', + }); + + const showToast = (variant: any) => + enqueueSnackbar(message, { + variant, + }); + return ( + + ); + }; + + return ( + + + + ); + }, +}; diff --git a/packages/manager/src/components/StackScript/StackScript.test.tsx b/packages/manager/src/components/StackScript/StackScript.test.tsx index e6ccb1e4e11..f8b642cd540 100644 --- a/packages/manager/src/components/StackScript/StackScript.test.tsx +++ b/packages/manager/src/components/StackScript/StackScript.test.tsx @@ -8,7 +8,7 @@ import { StackScript } from './StackScript'; describe('StackScript', () => { it('should render the StackScript label, id, and username', () => { - const stackScript = stackScriptFactory.build(); + const stackScript = stackScriptFactory.build({ id: 1234 }); renderWithTheme(); expect(screen.getByText(stackScript.label)).toBeInTheDocument(); diff --git a/packages/manager/src/components/StatusIcon/StatusIcon.tsx b/packages/manager/src/components/StatusIcon/StatusIcon.tsx index fab623fe350..54d84bfee4f 100644 --- a/packages/manager/src/components/StatusIcon/StatusIcon.tsx +++ b/packages/manager/src/components/StatusIcon/StatusIcon.tsx @@ -53,16 +53,16 @@ const StyledDiv = styled(Box, { transition: theme.transitions.create(['color']), width: '16px', ...(props.status === 'active' && { - backgroundColor: theme.color.teal, + backgroundColor: theme.palette.success.dark, }), ...(props.status === 'inactive' && { backgroundColor: theme.color.grey8, }), ...(props.status === 'error' && { - backgroundColor: theme.color.red, + backgroundColor: theme.palette.error.dark, }), ...(!['active', 'error', 'inactive'].includes(props.status) && { - backgroundColor: theme.color.orange, + backgroundColor: theme.palette.warning.dark, }), ...(props.pulse && { animation: 'pulse 1.5s ease-in-out infinite', diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index 23bc8782e0a..c748d7958ee 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -22,8 +22,8 @@ export interface Tab { } interface TabbedPanelProps { - [index: string]: any; bodyClass?: string; + children?: React.ReactNode; copy?: string; docsLink?: JSX.Element; error?: JSX.Element | string; @@ -117,7 +117,10 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { {tabs.map((tab, idx) => ( - + {tab.render(rest.children)} ))} diff --git a/packages/manager/src/components/Table/Table.styles.ts b/packages/manager/src/components/Table/Table.styles.ts index f6b70032f91..87318bf2aaf 100644 --- a/packages/manager/src/components/Table/Table.styles.ts +++ b/packages/manager/src/components/Table/Table.styles.ts @@ -26,11 +26,9 @@ export const StyledTableWrapper = styled('div', { borderRight: 'none', }, backgroundColor: theme.bg.tableHeader, - borderBottom: `2px solid ${theme.borderColors.borderTable}`, - borderLeft: `1px solid ${theme.borderColors.borderTable}`, + borderBottom: `1px solid ${theme.borderColors.borderTable}`, borderRight: `1px solid ${theme.borderColors.borderTable}`, - borderTop: `2px solid ${theme.borderColors.borderTable}`, - color: theme.textColors.tableHeader, + borderTop: `1px solid ${theme.borderColors.borderTable}`, fontFamily: theme.font.bold, padding: '10px 15px', }, @@ -43,11 +41,4 @@ export const StyledTableWrapper = styled('div', { border: 0, }, }), - ...(props.rowHoverState && { - '& tbody tr': { - '&:hover': { - backgroundColor: theme.bg.lightBlue1, - }, - }, - }), })); diff --git a/packages/manager/src/components/TableRow/TableRow.styles.ts b/packages/manager/src/components/TableRow/TableRow.styles.ts index 78fc55f09a4..745ff361b3c 100644 --- a/packages/manager/src/components/TableRow/TableRow.styles.ts +++ b/packages/manager/src/components/TableRow/TableRow.styles.ts @@ -9,9 +9,6 @@ export const StyledTableRow = styled(_TableRow, { label: 'StyledTableRow', shouldForwardProp: omittedProps(['forceIndex']), })(({ theme, ...props }) => ({ - backgroundColor: theme.bg.bgPaper, - borderLeft: `1px solid ${theme.borderColors.borderTable}`, - borderRight: `1px solid ${theme.borderColors.borderTable}`, [theme.breakpoints.up('md')]: { boxShadow: `inset 3px 0 0 transparent`, }, @@ -38,14 +35,14 @@ export const StyledTableRow = styled(_TableRow, { ...(props.selected && { '& td': { '&:first-of-type': { - borderLeft: `1px solid ${theme.palette.primary.light}`, + borderLeft: `1px solid ${theme.borderColors.borderTable}`, }, - borderBottomColor: theme.palette.primary.light, - borderTop: `1px solid ${theme.palette.primary.light}`, + borderBottomColor: theme.borderColors.borderTable, + borderTop: `1px solid ${theme.borderColors.borderTable}`, position: 'relative', [theme.breakpoints.down('lg')]: { '&:last-child': { - borderRight: `1px solid ${theme.palette.primary.light}`, + borderRight: `1px solid ${theme.borderColors.borderTable}`, }, }, }, diff --git a/packages/manager/src/components/TableRow/TableRow.tsx b/packages/manager/src/components/TableRow/TableRow.tsx index 4526f615662..d2ac1b07344 100644 --- a/packages/manager/src/components/TableRow/TableRow.tsx +++ b/packages/manager/src/components/TableRow/TableRow.tsx @@ -6,7 +6,6 @@ import { Hidden } from 'src/components/Hidden'; import { StyledTableDataCell, StyledTableRow } from './TableRow.styles'; export interface TableRowProps extends _TableRowProps { - ariaLabel?: string; className?: string; disabled?: boolean; domRef?: any; @@ -18,15 +17,10 @@ export interface TableRowProps extends _TableRowProps { } export const TableRow = React.memo((props: TableRowProps) => { - const { ariaLabel, domRef, selected, ...rest } = props; + const { domRef, selected, ...rest } = props; return ( - + {props.children} {selected && ( diff --git a/packages/manager/src/components/TableSortCell/TableSortCell.tsx b/packages/manager/src/components/TableSortCell/TableSortCell.tsx index fd7d860288b..0928cc1787b 100644 --- a/packages/manager/src/components/TableSortCell/TableSortCell.tsx +++ b/packages/manager/src/components/TableSortCell/TableSortCell.tsx @@ -18,7 +18,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ marginRight: 4, }, label: { - color: theme.textColors.tableHeader, fontSize: '.875rem', minHeight: 20, transition: 'none', @@ -98,7 +97,7 @@ export const TableSortCell = (props: TableSortCellProps) => { {children} {!active && } - {isLoading && } + {isLoading && } ); }; diff --git a/packages/manager/src/components/Tabs/Tab.test.tsx b/packages/manager/src/components/Tabs/Tab.test.tsx index 38736410cdb..6463053b864 100644 --- a/packages/manager/src/components/Tabs/Tab.test.tsx +++ b/packages/manager/src/components/Tabs/Tab.test.tsx @@ -20,7 +20,7 @@ describe('Tab Component', () => { expect(tabElement).toHaveStyle(` display: inline-flex; - color: rgb(54, 131, 220); + color: rgb(0, 156, 222); `); }); diff --git a/packages/manager/src/components/Tabs/Tab.tsx b/packages/manager/src/components/Tabs/Tab.tsx index c940218ba07..ea65565187c 100644 --- a/packages/manager/src/components/Tabs/Tab.tsx +++ b/packages/manager/src/components/Tabs/Tab.tsx @@ -11,7 +11,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&:hover': { backgroundColor: theme.color.grey7, - color: theme.palette.primary.main, + color: theme.textColors.linkHover, }, alignItems: 'center', borderBottom: '2px solid transparent', @@ -29,7 +29,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&[data-reach-tab][data-selected]': { '&:hover': { - color: theme.palette.primary.main, + color: theme.textColors.linkHover, }, borderBottom: `3px solid ${theme.textColors.linkActiveLight}`, color: theme.textColors.headlineStatic, diff --git a/packages/manager/src/components/Tabs/TabList.tsx b/packages/manager/src/components/Tabs/TabList.tsx index 16a12a41296..0ae8e8a8714 100644 --- a/packages/manager/src/components/Tabs/TabList.tsx +++ b/packages/manager/src/components/Tabs/TabList.tsx @@ -23,9 +23,7 @@ export { TabList }; const StyledReachTabList = styled(ReachTabList)(({ theme }) => ({ '&[data-reach-tab-list]': { background: 'none !important', - boxShadow: `inset 0 -1px 0 ${ - theme.name === 'light' ? '#e3e5e8' : '#2e3238' - }`, + boxShadow: `inset 0 -1px 0 ${theme.borderColors.divider}`, marginBottom: theme.spacing(), [theme.breakpoints.down('lg')]: { overflowX: 'auto', diff --git a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap index 947542f4e9b..7c5d66fe7d1 100644 --- a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap +++ b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap @@ -8,7 +8,7 @@ exports[`TabList component > renders TabList correctly 1`] = ` >
    diff --git a/packages/manager/src/components/Tag/Tag.styles.ts b/packages/manager/src/components/Tag/Tag.styles.ts index a54f9b67755..74ab54e1dd9 100644 --- a/packages/manager/src/components/Tag/Tag.styles.ts +++ b/packages/manager/src/components/Tag/Tag.styles.ts @@ -16,7 +16,6 @@ export const StyledChip = styled(Chip, { borderTopRightRadius: props.onDelete && 0, }, borderRadius: 4, - color: theme.name === 'light' ? '#3a3f46' : '#fff', fontFamily: theme.font.normal, maxWidth: 350, padding: '7px 10px', @@ -32,18 +31,19 @@ export const StyledChip = styled(Chip, { ['& .StyledDeleteButton']: { color: theme.color.tagIcon, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, }, // Overrides MUI chip default styles so these appear as separate elements. '&:hover': { ['& .StyledDeleteButton']: { color: theme.color.tagIcon, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, }, fontSize: '0.875rem', height: 30, padding: 0, + transition: 'none', ...(props.colorVariant === 'blue' && { '& > span': { '&:hover, &:focus': { @@ -58,15 +58,16 @@ export const StyledChip = styled(Chip, { ...(props.colorVariant === 'lightBlue' && { '& > span': { '&:focus': { - backgroundColor: theme.color.tagButton, - color: theme.color.black, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.white, }, '&:hover': { - backgroundColor: theme.palette.primary.main, - color: 'white', + backgroundColor: theme.color.tagButtonBgHover, + color: theme.color.tagButtonTextHover, }, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, }), })); @@ -85,10 +86,9 @@ export const StyledDeleteButton = styled(StyledLinkButton, { }, '&:hover': { '& svg': { - color: 'white', + color: theme.color.tagIconHover, }, - backgroundColor: theme.palette.primary.main, - color: 'white', + backgroundColor: theme.color.buttonPrimaryHover, }, borderBottomRightRadius: 3, borderLeft: `1px solid ${theme.name === 'light' ? '#fff' : '#2e3238'}`, diff --git a/packages/manager/src/components/TagCell/AddTag.tsx b/packages/manager/src/components/TagCell/AddTag.tsx index a03008278e2..94ded840506 100644 --- a/packages/manager/src/components/TagCell/AddTag.tsx +++ b/packages/manager/src/components/TagCell/AddTag.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; import { Autocomplete } from '../Autocomplete/Autocomplete'; 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 a79ea11bd84..c1433e9bd9e 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 }) => ( } @@ -132,7 +138,7 @@ export const TagCell = (props: TagCellProps) => { > {loading ? ( - + ) : null} {tags.map((thisTag) => ( @@ -219,7 +225,7 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ backgroundColor: theme.palette.primary.main, color: '#ffff', }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, borderRadius: 0, color: theme.color.tagIcon, height: 30, diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 576a11d8e2d..0d82a178a4e 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -1,13 +1,13 @@ import { APIError } from '@linode/api-v4/lib/types'; +import { useQueryClient } from '@tanstack/react-query'; import { concat } from 'ramda'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import Select, { Item, NoOptionsMessageProps, } from 'src/components/EnhancedSelect/Select'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -46,7 +46,7 @@ export interface TagsInputProps { /** * Callback fired when the value changes. */ - onChange: (selected: Item[]) => void; + onChange: (selected: Item[]) => void; /** * An error to display beneath the input. */ @@ -54,7 +54,7 @@ export interface TagsInputProps { /** * The value of the input. */ - value: Item[]; + value: Item[]; } export const TagsInput = (props: TagsInputProps) => { diff --git a/packages/manager/src/components/TextField.tsx b/packages/manager/src/components/TextField.tsx index bac96accd5d..cdf0a429d6a 100644 --- a/packages/manager/src/components/TextField.tsx +++ b/packages/manager/src/components/TextField.tsx @@ -157,7 +157,6 @@ interface LabelToolTipProps { interface InputToolTipProps { tooltipClasses?: string; - tooltipInteractive?: boolean; tooltipOnMouseEnter?: React.MouseEventHandler; tooltipPosition?: TooltipProps['placement']; tooltipText?: JSX.Element | string; @@ -251,7 +250,6 @@ export const TextField = (props: TextFieldProps) => { optional, required, tooltipClasses, - tooltipInteractive, tooltipOnMouseEnter, tooltipPosition, tooltipText, @@ -429,7 +427,7 @@ export const TextField = (props: TextFieldProps) => { disableUnderline: true, endAdornment: loading && ( - + ), ...InputProps, @@ -484,7 +482,6 @@ export const TextField = (props: TextFieldProps) => { padding: '6px', }} classes={{ popper: tooltipClasses }} - interactive={tooltipInteractive} onMouseEnter={tooltipOnMouseEnter} status="help" text={tooltipText} diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx index 7a44e14538e..301c5787ca5 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -56,7 +56,28 @@ describe('TextTooltip', () => { const displayText = getByText(props.displayText); - expect(displayText).toHaveStyle('color: rgb(54, 131, 220)'); + expect(displayText).toHaveStyle('color: rgb(0, 156, 222)'); expect(displayText).toHaveStyle('font-size: 18px'); }); + + it('the tooltip should disappear on mouseout', async () => { + const props = { + displayText: 'Hover me', + tooltipText: 'This is a tooltip', + }; + + const { findByRole, getByText, queryByRole } = renderWithTheme( + + ); + + fireEvent.mouseEnter(getByText(props.displayText)); + + const tooltip = await findByRole('tooltip'); + + expect(tooltip).toBeInTheDocument(); + + fireEvent.mouseLeave(getByText(props.displayText)); + + await waitFor(() => expect(queryByRole('tooltip')).not.toBeInTheDocument()); + }); }); diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index d9cb98a03e2..25e43179479 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -13,6 +13,11 @@ export interface TextTooltipProps { * Props to pass to the Popper component */ PopperProps?: TooltipProps['PopperProps']; + /** + * The data-qa-tooltip attribute for the tooltip. + * Defaults to the tooltip title, but will be undefined if the title is a JSX element. + */ + dataQaTooltip?: string; /** The text to hover on to display the tooltip */ displayText: string; /** If true, the tooltip will not have a min-width of 375px @@ -41,6 +46,7 @@ export interface TextTooltipProps { export const TextTooltip = (props: TextTooltipProps) => { const { PopperProps, + dataQaTooltip, displayText, minWidth, placement, @@ -60,8 +66,10 @@ export const TextTooltip = (props: TextTooltipProps) => { }, }, }} + data-qa-tooltip={dataQaTooltip} enterTouchDelay={0} placement={placement ? placement : 'bottom'} + tabIndex={0} title={tooltipText} > @@ -74,10 +82,13 @@ export const TextTooltip = (props: TextTooltipProps) => { const StyledRootTooltip = styled(Tooltip, { label: 'StyledRootTooltip', })(({ theme }) => ({ + '&:hover': { + color: theme.textColors.linkHover, + }, borderRadius: 4, - color: theme.palette.primary.main, + color: theme.textColors.linkActiveLight, cursor: 'pointer', position: 'relative', - textDecoration: `underline dotted ${theme.palette.primary.main}`, + textDecoration: `underline dotted ${theme.textColors.linkActiveLight}`, textUnderlineOffset: 4, })); diff --git a/packages/manager/src/components/Tile/Tile.styles.ts b/packages/manager/src/components/Tile/Tile.styles.ts index ddeb5994f3f..a1a26d525ea 100644 --- a/packages/manager/src/components/Tile/Tile.styles.ts +++ b/packages/manager/src/components/Tile/Tile.styles.ts @@ -15,8 +15,8 @@ export const useStyles = makeStyles()( }, card: { alignItems: 'center', - backgroundColor: theme.color.white, - border: `1px solid ${theme.color.grey2}`, + backgroundColor: theme.bg.bgPaper, + border: `1px solid ${theme.borderColors.divider}`, display: 'flex', flexDirection: 'column', height: '100%', @@ -51,7 +51,7 @@ export const useStyles = makeStyles()( icon: { '& .insidePath': { fill: 'none', - stroke: '#3683DC', + stroke: theme.palette.primary.main, strokeLinejoin: 'round', strokeWidth: 1.25, }, diff --git a/packages/manager/src/components/Toggle/Toggle.stories.tsx b/packages/manager/src/components/Toggle/Toggle.stories.tsx index 2a4d5c4d3f7..62c7ced65b1 100644 --- a/packages/manager/src/components/Toggle/Toggle.stories.tsx +++ b/packages/manager/src/components/Toggle/Toggle.stories.tsx @@ -11,12 +11,6 @@ export const Default: StoryObj = { render: (args) => , }; -export const WithInteractiveTooltip: StoryObj = { - render: (args) => ( - - ), -}; - const meta: Meta = { args: { disabled: false, diff --git a/packages/manager/src/components/Toggle/Toggle.test.tsx b/packages/manager/src/components/Toggle/Toggle.test.tsx index d32ba752ec2..c6f8d9e805e 100644 --- a/packages/manager/src/components/Toggle/Toggle.test.tsx +++ b/packages/manager/src/components/Toggle/Toggle.test.tsx @@ -13,7 +13,7 @@ describe('Toggle component', () => { }); it('should render a tooltip button', async () => { const screen = renderWithTheme( - + ); const tooltipButton = screen.getByRole('button'); expect(tooltipButton).toBeInTheDocument(); diff --git a/packages/manager/src/components/Toggle/Toggle.tsx b/packages/manager/src/components/Toggle/Toggle.tsx index 3c5cc0e5fa0..e1a478cbab6 100644 --- a/packages/manager/src/components/Toggle/Toggle.tsx +++ b/packages/manager/src/components/Toggle/Toggle.tsx @@ -6,10 +6,6 @@ import ToggleOn from 'src/assets/icons/toggleOn.svg'; import { TooltipIcon } from 'src/components/TooltipIcon'; export interface ToggleProps extends SwitchProps { - /** - * Makes a tooltip interactive (meaning the tooltip will not close when the user hovers over the tooltip). Note that in order for the tooltip to show up, tooltipText must be passed in as a prop. - */ - interactive?: boolean; /** * Content to display inside an optional tooltip. */ @@ -28,7 +24,7 @@ export interface ToggleProps extends SwitchProps { * > **Note:** Do not use toggles in long forms where other types of form fields are present, and users will need to click a Submit button for other changes to take effect. This scenario confuses users because they canā€™t be sure whether their toggle choice will take immediate effect. */ export const Toggle = (props: ToggleProps) => { - const { interactive, tooltipText, ...rest } = props; + const { tooltipText, ...rest } = props; return ( @@ -39,13 +35,7 @@ export const Toggle = (props: ToggleProps) => { icon={} {...rest} /> - {tooltipText && ( - - )} + {tooltipText && } ); }; diff --git a/packages/manager/src/components/Tooltip.tsx b/packages/manager/src/components/Tooltip.tsx index c325f07cc4d..fc0fcaa273d 100644 --- a/packages/manager/src/components/Tooltip.tsx +++ b/packages/manager/src/components/Tooltip.tsx @@ -7,7 +7,12 @@ import type { TooltipProps } from '@mui/material/Tooltip'; * Tooltips display informative text when users hover over, focus on, or tap an element. */ export const Tooltip = (props: TooltipProps) => { - return <_Tooltip data-qa-tooltip={props.title} {...props} />; + // Avoiding displaying [object Object] in the data-qa-tooltip attribute when the title is an JSX element. + // Can be overridden by passing data-qa-tooltip directly to the Tooltip component. + const dataQaTooltip: string | undefined = + typeof props.title === 'string' ? props.title : undefined; + + return <_Tooltip data-qa-tooltip={dataQaTooltip} {...props} />; }; export { tooltipClasses }; export type { TooltipProps }; diff --git a/packages/manager/src/components/TooltipIcon.tsx b/packages/manager/src/components/TooltipIcon.tsx index ef6f08c53b7..fc982c1a1b6 100644 --- a/packages/manager/src/components/TooltipIcon.tsx +++ b/packages/manager/src/components/TooltipIcon.tsx @@ -25,7 +25,10 @@ interface EnhancedTooltipProps extends TooltipProps { } export interface TooltipIconProps - extends Omit { + extends Omit< + TooltipProps, + 'children' | 'disableInteractive' | 'leaveDelay' | 'title' + > { /** * An optional className that does absolutely nothing */ @@ -35,11 +38,6 @@ export interface TooltipIconProps * @todo this seems like a flaw... passing an icon should not require `status` to be `other` */ icon?: JSX.Element; - /** - * Makes the tooltip interactive (stays open when cursor is over tooltip) - * @default false - */ - interactive?: boolean; /** * Enables a leaveDelay of 3000ms * @default false @@ -92,7 +90,6 @@ export const TooltipIcon = (props: TooltipIconProps) => { const { classes, icon, - interactive, leaveDelay, status, sx, @@ -113,16 +110,16 @@ export const TooltipIcon = (props: TooltipIconProps) => { const sxRootStyle = { '&&': { - fill: '#888f91', - stroke: '#888f91', + fill: theme.color.grey4, + stroke: theme.color.grey4, strokeWidth: 0, }, '&:hover': { - color: '#3683dc', - fill: '#3683dc', - stroke: '#3683dc', + color: theme.palette.primary.main, + fill: theme.palette.primary.main, + stroke: theme.palette.primary.main, }, - color: '#888f91', + color: theme.color.grey4, height: 20, width: 20, }; @@ -155,7 +152,6 @@ export const TooltipIcon = (props: TooltipIconProps) => { classes={classes} componentsProps={props.componentsProps} data-qa-help-tooltip - disableInteractive={!interactive} enterTouchDelay={0} leaveDelay={leaveDelay ? 3000 : undefined} leaveTouchDelay={5000} diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx index e05c8ce2448..971ec80d6b7 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx @@ -43,7 +43,7 @@ export const TransferDisplay = React.memo(({ spacingTop }: Props) => { {isLoading ? ( <> Loading transfer data... - + ) : ( <> diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index 0a4c937fac3..55e555abe50 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -10,7 +10,7 @@ import { TypeToConfirm, TypeToConfirmProps, } from 'src/components/TypeToConfirm/TypeToConfirm'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; interface EntityInfo { action?: diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx index dd01b6cae8b..35b7a42e3c2 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx @@ -9,10 +9,8 @@ import type { Meta, StoryObj } from '@storybook/react'; */ export const _ImageUploader: StoryObj = { args: { - description: 'My Ubuntu Image for Production', - dropzoneDisabled: false, - label: 'file upload', - region: 'us-east-1', + isUploading: false, + progress: undefined, }, render: (args) => { return ; diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx index 0d41c330e2d..ae64d58a2db 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx @@ -5,22 +5,17 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ImageUploader } from './ImageUploader'; const props = { - apiError: undefined, - dropzoneDisabled: false, - label: 'Upload files here', - onSuccess: vi.fn(), - region: 'us-east-1', - setCancelFn: vi.fn(), - setErrors: vi.fn(), + isUploading: false, + progress: undefined, }; describe('File Uploader', () => { it('properly renders the File Uploader', () => { const screen = renderWithTheme(); - const browseFiles = screen.getByTestId('upload-button'); + const browseFiles = screen.getByText('Browse Files').closest('button'); expect(browseFiles).toBeVisible(); - expect(browseFiles).toHaveAttribute('aria-disabled', 'false'); + expect(browseFiles).toBeEnabled(); const text = screen.getByText( 'You can browse your device to upload an image file or drop it here.' ); @@ -28,16 +23,15 @@ describe('File Uploader', () => { }); it('disables the dropzone', () => { - const screen = renderWithTheme( - - ); + const screen = renderWithTheme(); - const browseFiles = screen.getByTestId('upload-button'); + const browseFiles = screen.getByText('Browse Files').closest('button'); expect(browseFiles).toBeVisible(); + expect(browseFiles).toBeDisabled(); expect(browseFiles).toHaveAttribute('aria-disabled', 'true'); const text = screen.getByText( - 'To upload an image, complete the required fields.' + 'You can browse your device to upload an image file or drop it here.' ); expect(text).toBeVisible(); }); diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 7e1a5f23f6f..0392f8e5145 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -1,397 +1,107 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material'; +import { Duration } from 'luxon'; import * as React from 'react'; -import { flushSync } from 'react-dom'; -import { FileRejection, useDropzone } from 'react-dropzone'; -import { useQueryClient } from '@tanstack/react-query'; -import { useDispatch } from 'react-redux'; -import { useHistory } from 'react-router-dom'; - -import { FileUpload } from 'src/components/Uploaders/FileUpload'; -import { - StyledCopy, - StyledDropZoneContentDiv, - StyledDropZoneDiv, - StyledFileUploadsDiv, - StyledUploadButton, -} from 'src/components/Uploaders/ImageUploader/ImageUploader.styles'; -import { onUploadProgressFactory } from 'src/components/Uploaders/ObjectUploader/ObjectUploader'; -import { - MAX_FILE_SIZE_IN_BYTES, - MAX_PARALLEL_UPLOADS, - curriedObjectUploaderReducer, - defaultState, - pathOrFileName, -} from 'src/components/Uploaders/reducer'; -import { uploadImageFile } from 'src/features/Images/requests'; -import { Dispatch } from 'src/hooks/types'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; -import { imageQueries, useUploadImageMutation } from 'src/queries/images'; -import { redirectToLogin } from 'src/session'; -import { setPendingUpload } from 'src/store/pendingUpload'; -import { sendImageUploadEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { DropzoneProps, useDropzone } from 'react-dropzone'; + +import { BarPercent } from 'src/components/BarPercent'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; import { readableBytes } from 'src/utilities/unitConversions'; -interface ImageUploaderProps { - /** - * An error to display if an upload error occurred. - */ - apiError: string | undefined; - /** - * The description of the upload that will be sent to the Linode API (used for Image uploads) - */ - description?: string; - /** - * Disables the ability to select image(s) to upload. - */ - dropzoneDisabled: boolean; - isCloudInit?: boolean; - /** - * The label of the upload that will be sent to the Linode API (used for Image uploads). - */ - label: string; - /** - * A function that is called when an upload is successful. - */ - onSuccess?: () => void; - /** - * The region ID to upload the image to. - */ - region: string; +import type { AxiosProgressEvent } from 'axios'; + +interface Props extends Partial { /** - * Allows you to set a cancel upload function in the parent component. + * Whether or not the upload is in progress. */ - setCancelFn: React.Dispatch void) | null>>; + isUploading: boolean; /** - * A function that allows you to set an error value in the parent component. + * The progress of the image upload. */ - setErrors: React.Dispatch>; + progress: AxiosProgressEvent | undefined; } /** * This component enables users to attach and upload images from a device. */ -export const ImageUploader = React.memo((props: ImageUploaderProps) => { - const { - apiError, - description, - dropzoneDisabled, - isCloudInit, - label, - onSuccess, - region, - setErrors, - } = props; - - const { enqueueSnackbar } = useSnackbar(); - const [uploadToURL, setUploadToURL] = React.useState(''); - const queryClient = useQueryClient(); - const { mutateAsync: uploadImage } = useUploadImageMutation({ - cloud_init: isCloudInit ? isCloudInit : undefined, - description: description ? description : undefined, - label, - region, - }); - - const history = useHistory(); - - // Keep track of the session token since we may need to grab the user a new - // one after a long upload (if their session has expired). - const currentToken = useCurrentToken(); - - const [state, dispatch] = React.useReducer( - curriedObjectUploaderReducer, - defaultState - ); - - const dispatchAction: Dispatch = useDispatch(); - - React.useEffect(() => { - const preventDefault = (e: any) => { - e.preventDefault(); - }; - - // This event listeners prevent the browser from opening files dropped on - // the screen, which was happening when the dropzone was disabled. - - // eslint-disable-next-line scanjs-rules/call_addEventListener - window.addEventListener('dragover', preventDefault); - // eslint-disable-next-line scanjs-rules/call_addEventListener - window.addEventListener('drop', preventDefault); - - return () => { - window.removeEventListener('dragover', preventDefault); - window.removeEventListener('drop', preventDefault); - }; - }, []); - - // This function is fired when files are dropped in the upload area. - const onDrop = (files: File[]) => { - const prefix = ''; - - // If an upload attempt failed previously, clear the dropzone. - if (state.numErrors > 0) { - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - } - - dispatch({ files, prefix, type: 'ENQUEUE' }); - }; - - // This function will be called when the user drops non-.gz files, more than one file at a time, or files that are over the max size. - const onDropRejected = (files: FileRejection[]) => { - const wrongFileType = !files[0].file.type.match(/gzip/gi); - const fileTypeErrorMessage = - 'Only raw disk images (.img) compressed using gzip (.gz) can be uploaded.'; - - const moreThanOneFile = files.length > 1; - const fileNumberErrorMessage = 'Only one file may be uploaded at a time.'; - - const fileSizeErrorMessage = `Max file size (${ - readableBytes(MAX_FILE_SIZE_IN_BYTES).formatted - }) exceeded`; - - if (wrongFileType) { - enqueueSnackbar(fileTypeErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } else if (moreThanOneFile) { - enqueueSnackbar(fileNumberErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } else { - enqueueSnackbar(fileSizeErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } - }; - - const nextBatch = React.useMemo(() => { - if (state.numQueued === 0 || state.numInProgress > 0) { - return []; - } - - const queuedUploads = state.files.filter( - (upload) => upload.status === 'QUEUED' - ); - - return queuedUploads.slice(0, MAX_PARALLEL_UPLOADS - state.numInProgress); - }, [state.numQueued, state.numInProgress, state.files]); - - const uploadInProgressOrFinished = - state.numInProgress > 0 || state.numFinished > 0; - - // When `nextBatch` changes, upload the files. - React.useEffect(() => { - if (nextBatch.length === 0) { - return; - } - - nextBatch.forEach((fileUpload) => { - const { file } = fileUpload; - - const path = pathOrFileName(fileUpload.file); - - const onUploadProgress = onUploadProgressFactory(dispatch, path); - - const handleSuccess = () => { - if (onSuccess) { - onSuccess(); - } - - dispatch({ - data: { - percentComplete: 100, - status: 'FINISHED', - }, - filesToUpdate: [path], - type: 'UPDATE_FILES', - }); - - const successfulUploadMessage = `Image ${label} uploaded successfully. It is being processed and will be available shortly.`; - - enqueueSnackbar(successfulUploadMessage, { - autoHideDuration: 6000, - variant: 'success', - }); - - // React force a render so that `pendingUpload` is false when navigating away - // from the upload page. - flushSync(() => { - dispatchAction(setPendingUpload(false)); - }); - - recordImageAnalytics('success', file); - - // EDGE CASE: - // The upload has finished, but the user's token has expired. - // Show the toast, then redirect them to /images, passing them through - // Login to get a new token. - if (!currentToken) { - setTimeout(() => { - redirectToLogin('/images'); - }, 3000); - } else { - queryClient.invalidateQueries(imageQueries.paginated._def); - queryClient.invalidateQueries(imageQueries.all._def); - history.push('/images'); - } - }; - - const handleError = () => { - dispatch({ - data: { - status: 'ERROR', - }, - filesToUpdate: [path], - type: 'UPDATE_FILES', - }); - - dispatchAction(setPendingUpload(false)); - }; - - if (!uploadToURL) { - uploadImage() - .then((response) => { - setUploadToURL(response.upload_to); - - // Let the entire app know that there's a pending upload via Redux. - // High-level components like AuthenticationWrapper need to know - // this, so the user isn't redirected to Login if the token expires. - dispatchAction(setPendingUpload(true)); - - dispatch({ - data: { status: 'IN_PROGRESS' }, - filesToUpdate: [pathOrFileName(fileUpload.file)], - type: 'UPDATE_FILES', - }); - - recordImageAnalytics('start', file); - - const { cancel, request } = uploadImageFile( - response.upload_to, - file, - onUploadProgress - ); - - // The parent might need to cancel this upload (e.g. if the user - // navigates away from the page). - props.setCancelFn(() => () => cancel()); - - request() - .then(() => handleSuccess()) - .catch(() => handleError()); - }) - .catch((e) => { - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - setErrors(e); - }); - } else { - recordImageAnalytics('start', file); - - // Overwrite any file that was previously uploaded to the upload_to URL. - const { cancel, request } = uploadImageFile( - uploadToURL, - file, - onUploadProgress - ); - - props.setCancelFn(cancel); - - request() - .then(() => handleSuccess()) - .catch(() => { - handleError(); - recordImageAnalytics('fail', file); - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - }); - } - }); - }, [nextBatch]); - +export const ImageUploader = React.memo((props: Props) => { + const { isUploading, progress, ...dropzoneProps } = props; const { acceptedFiles, getInputProps, getRootProps, - isDragAccept, isDragActive, - isDragReject, - open, } = useDropzone({ accept: ['application/x-gzip', 'application/gzip'], // Uploaded files must be compressed using gzip. - disabled: dropzoneDisabled || uploadInProgressOrFinished, // disabled when dropzoneDisabled === true, an upload is in progress, or if an upload finished. maxFiles: 1, maxSize: MAX_FILE_SIZE_IN_BYTES, - noClick: true, - noKeyboard: true, - onDrop, - onDropRejected, + ...dropzoneProps, + disabled: dropzoneProps.disabled || isUploading, }); - const hideDropzoneBrowseBtn = - (isDragAccept || acceptedFiles.length > 0) && !apiError; // Checking that there isn't an apiError set to prevent disappearance of button if image creation isn't available in a region at that moment, etc. - - // const UploadZoneActive = - // state.files.filter((upload) => upload.status !== 'QUEUED').length !== 0; - - const uploadZoneActive = state.files.length !== 0; - - const placeholder = dropzoneDisabled - ? 'To upload an image, complete the required fields.' - : 'You can browse your device to upload an image file or drop it here.'; - return ( - - - - {state.files.map((upload, idx) => { - const fileName = upload.file.name; - return ( - - ); - })} - - - {!uploadZoneActive && ( - {placeholder} + + + + {acceptedFiles.length === 0 && ( + + You can browse your device to upload an image file or drop it here. + )} - {!hideDropzoneBrowseBtn ? ( - ( + + {file.name} ({readableBytes(file.size, { base10: true }).formatted}) + + ))} + + {isUploading && ( + + + + + + + {readableBytes(progress?.rate ?? 0, { base10: true }).formatted}/s{' '} + + + {Duration.fromObject({ seconds: progress?.estimated }).toHuman({ + maximumFractionDigits: 0, + })}{' '} + remaining + + + + )} + {!isUploading && ( + + + + )} + ); }); -const recordImageAnalytics = ( - action: 'fail' | 'start' | 'success', - file: File -) => { - const readableFileSize = readableBytes(file.size).formatted; - sendImageUploadEvent(action, readableFileSize); -}; +const Dropzone = styled('div')<{ active: boolean }>(({ active, theme }) => ({ + borderColor: 'gray', + borderStyle: 'dashed', + borderWidth: 1, + display: 'flex', + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + minHeight: 150, + padding: 16, + ...(active && { + backgroundColor: theme.palette.background.default, + borderColor: theme.palette.primary.main, + }), +})); diff --git a/packages/manager/src/components/VLANSelect.tsx b/packages/manager/src/components/VLANSelect.tsx index 99d5fe82996..560b744ee19 100644 --- a/packages/manager/src/components/VLANSelect.tsx +++ b/packages/manager/src/components/VLANSelect.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useDebouncedValue } from 'src/hooks/useDebouncedValue'; import { useVLANsInfiniteQuery } from 'src/queries/vlans'; import { Autocomplete } from './Autocomplete/Autocomplete'; @@ -51,9 +52,19 @@ 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 debouncedInputValue = useDebouncedValue(inputValue); + const apiFilter = getVLANSelectFilter({ defaultFilter: filter, - inputValue, + inputValue: debouncedInputValue, }); const { diff --git a/packages/manager/src/containers/localStorage.container.ts b/packages/manager/src/containers/localStorage.container.ts deleted file mode 100644 index 09db5de0060..00000000000 --- a/packages/manager/src/containers/localStorage.container.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { StateHandlerMap, StateUpdaters, withStateHandlers } from 'recompose'; - -import { Storage, storage } from 'src/utilities/storage'; - -const localStorageContainer = ( - mapState: (s: Storage) => TState, - mapHandlers: ( - s: Storage - ) => StateUpdaters & TUpdaters> -) => { - const handlers = mapHandlers(storage); - return withStateHandlers & TUpdaters, TOuter>( - () => mapState(storage), - handlers - ); -}; - -export default localStorageContainer; diff --git a/packages/manager/src/containers/preferences.container.ts b/packages/manager/src/containers/preferences.container.ts index dbc04852d30..48736a01f65 100644 --- a/packages/manager/src/containers/preferences.container.ts +++ b/packages/manager/src/containers/preferences.container.ts @@ -1,6 +1,9 @@ import React from 'react'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { ManagerPreferences } from 'src/types/ManagerPreferences'; export interface PreferencesStateProps { preferences?: ManagerPreferences; diff --git a/packages/manager/src/containers/profile.container.ts b/packages/manager/src/containers/profile.container.ts index 0e8a0d986cb..0ba0c0d0b29 100644 --- a/packages/manager/src/containers/profile.container.ts +++ b/packages/manager/src/containers/profile.container.ts @@ -3,7 +3,7 @@ import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; import { UseQueryResult } from '@tanstack/react-query'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; export interface WithProfileProps { grants: UseQueryResult; diff --git a/packages/manager/src/containers/withMarketplaceApps.ts b/packages/manager/src/containers/withMarketplaceApps.ts index f5040df7241..17a0a56073e 100644 --- a/packages/manager/src/containers/withMarketplaceApps.ts +++ b/packages/manager/src/containers/withMarketplaceApps.ts @@ -2,7 +2,7 @@ import { StackScript } from '@linode/api-v4'; import React from 'react'; import { useLocation } from 'react-router-dom'; -import { baseApps } from 'src/features/StackScripts/stackScriptUtils'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { useFlags } from 'src/hooks/useFlags'; import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; @@ -34,7 +34,7 @@ export const withMarketplaceApps = ( const { data, error, isLoading } = useMarketplaceAppsQuery(enabled); const newApps = flags.oneClickApps || []; - const allowedApps = Object.keys({ ...baseApps, ...newApps }); + const allowedApps = Object.keys({ ...oneClickApps, ...newApps }); const filteredApps = (data ?? []).filter((script) => { return ( diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 98daf91f982..5e3d744f720 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'; /** @@ -20,12 +21,15 @@ const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclb', label: 'ACLB' }, { flag: 'aclbFullCreateFlow', label: 'ACLB Full Create Flow' }, + { flag: 'aclp', label: 'CloudPulse' }, { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, + { flag: 'eventMessagesV2', label: 'Event Messages V2' }, { flag: 'gecko2', label: 'Gecko' }, + { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, - { flag: 'parentChildAccountAccess', label: 'Parent/Child Account' }, + { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, { flag: 'placementGroups', label: 'Placement Groups' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, diff --git a/packages/manager/src/env.d.ts b/packages/manager/src/env.d.ts index 0956ed09f6e..8d705542528 100644 --- a/packages/manager/src/env.d.ts +++ b/packages/manager/src/env.d.ts @@ -43,7 +43,7 @@ declare module '*.svg' { export default src; } -declare module '*.css?raw' { +declare module '*?raw' { const src: string; export default src; } diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index 79f8866b75d..eace884c756 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -3,7 +3,7 @@ import { ActivePromotion, RegionalNetworkUtilization, } from '@linode/api-v4/lib/account/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const promoFactory = Factory.Sync.makeFactory({ credit_monthly_cap: '20.00', @@ -36,17 +36,18 @@ export const accountFactory = Factory.Sync.makeFactory({ balance_uninvoiced: 0.0, billing_source: 'linode', capabilities: [ - 'Linodes', - 'NodeBalancers', 'Block Storage', - 'Object Storage', - 'Kubernetes', 'Cloud Firewall', - 'Vlans', + 'Disk Encryption', + 'Kubernetes', + 'Linodes', 'LKE HA Control Planes', 'Machine Images', 'Managed Databases', + 'NodeBalancers', + 'Object Storage', 'Placement Group', + 'Vlans', ], city: 'Colorado', company: Factory.each((i) => `company-${i}`), diff --git a/packages/manager/src/factories/accountAgreements.ts b/packages/manager/src/factories/accountAgreements.ts index abc9eabeb94..e14f1db920f 100644 --- a/packages/manager/src/factories/accountAgreements.ts +++ b/packages/manager/src/factories/accountAgreements.ts @@ -1,5 +1,5 @@ import { Agreements } from '@linode/api-v4/lib/account'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const accountAgreementsFactory = Factory.Sync.makeFactory({ eu_model: false, diff --git a/packages/manager/src/factories/accountAvailability.ts b/packages/manager/src/factories/accountAvailability.ts index 7f1cff796a0..3196a439674 100644 --- a/packages/manager/src/factories/accountAvailability.ts +++ b/packages/manager/src/factories/accountAvailability.ts @@ -1,5 +1,5 @@ import { AccountAvailability } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { pickRandom } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/accountLogin.ts b/packages/manager/src/factories/accountLogin.ts index 878519a2c43..e4f36ab9cce 100644 --- a/packages/manager/src/factories/accountLogin.ts +++ b/packages/manager/src/factories/accountLogin.ts @@ -1,5 +1,5 @@ import { AccountLogin } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const accountLoginFactory = Factory.Sync.makeFactory({ datetime: '2021-05-21T14:27:51', diff --git a/packages/manager/src/factories/accountMaintenance.ts b/packages/manager/src/factories/accountMaintenance.ts index 11223cf6c16..987769b936a 100644 --- a/packages/manager/src/factories/accountMaintenance.ts +++ b/packages/manager/src/factories/accountMaintenance.ts @@ -1,5 +1,5 @@ import { AccountMaintenance } from '@linode/api-v4/lib/account/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { pickRandom, randomDate } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/accountOAuth.ts b/packages/manager/src/factories/accountOAuth.ts index 277632864ba..37c380efea6 100644 --- a/packages/manager/src/factories/accountOAuth.ts +++ b/packages/manager/src/factories/accountOAuth.ts @@ -1,5 +1,5 @@ import { OAuthClient } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const oauthClientFactory = Factory.Sync.makeFactory({ id: Factory.each((id) => String(id)), diff --git a/packages/manager/src/factories/accountPayment.ts b/packages/manager/src/factories/accountPayment.ts index 392d3cc94c2..844613a1367 100644 --- a/packages/manager/src/factories/accountPayment.ts +++ b/packages/manager/src/factories/accountPayment.ts @@ -1,5 +1,5 @@ import { PaymentMethod } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const paymentMethodFactory = Factory.Sync.makeFactory({ created: '2021-05-21T14:27:51', diff --git a/packages/manager/src/factories/accountSettings.ts b/packages/manager/src/factories/accountSettings.ts index 4c7f9a073af..5b9d320bf66 100644 --- a/packages/manager/src/factories/accountSettings.ts +++ b/packages/manager/src/factories/accountSettings.ts @@ -1,5 +1,5 @@ import { AccountSettings } from '@linode/api-v4/lib/account/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const accountSettingsFactory = Factory.Sync.makeFactory( { diff --git a/packages/manager/src/factories/accountUsers.ts b/packages/manager/src/factories/accountUsers.ts index 664cf75bd8b..49e8e968db1 100644 --- a/packages/manager/src/factories/accountUsers.ts +++ b/packages/manager/src/factories/accountUsers.ts @@ -1,5 +1,5 @@ import { User } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const accountUserFactory = Factory.Sync.makeFactory({ email: 'support@linode.com', diff --git a/packages/manager/src/factories/aclb.ts b/packages/manager/src/factories/aclb.ts index 7a3779e4ebc..1ca143ec038 100644 --- a/packages/manager/src/factories/aclb.ts +++ b/packages/manager/src/factories/aclb.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { pickRandom } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/betas.ts b/packages/manager/src/factories/betas.ts index c50e76f2a28..80041895527 100644 --- a/packages/manager/src/factories/betas.ts +++ b/packages/manager/src/factories/betas.ts @@ -1,5 +1,5 @@ import { Beta, AccountBeta } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; export const betaFactory = Factory.Sync.makeFactory({ diff --git a/packages/manager/src/factories/billing.ts b/packages/manager/src/factories/billing.ts index dbbf732a8cf..f14b7e80c97 100644 --- a/packages/manager/src/factories/billing.ts +++ b/packages/manager/src/factories/billing.ts @@ -5,7 +5,7 @@ import { PaymentResponse, } from '@linode/api-v4/lib/account'; import { APIWarning } from '@linode/api-v4/lib/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const invoiceItemFactory = Factory.Sync.makeFactory({ amount: 5, diff --git a/packages/manager/src/factories/config.ts b/packages/manager/src/factories/config.ts index a98b0e798d5..72199f914f1 100644 --- a/packages/manager/src/factories/config.ts +++ b/packages/manager/src/factories/config.ts @@ -1,5 +1,5 @@ import { Config } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const configFactory = Factory.Sync.makeFactory({ comments: '', diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 856d20ff021..b9188b54d62 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -8,17 +8,19 @@ import { MySQLReplicationType, PostgresReplicationType, } from '@linode/api-v4/lib/databases/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { v4 } from 'uuid'; import { pickRandom, randomDate } from 'src/utilities/random'; // These are not all of the possible statuses, but these are some common ones. -const possibleStatuses: DatabaseStatus[] = [ +export const possibleStatuses: DatabaseStatus[] = [ 'provisioning', 'active', 'failed', 'degraded', + 'restoring', + 'resizing', ]; export const possibleMySQLReplicationTypes: MySQLReplicationType[] = [ @@ -37,7 +39,6 @@ export const IPv4List = ['192.0.2.1', '196.0.0.0', '198.0.0.2']; export const databaseTypeFactory = Factory.Sync.makeFactory({ class: 'standard', - disk: 20480, engines: { mongodb: [ { @@ -133,9 +134,10 @@ export const databaseTypeFactory = Factory.Sync.makeFactory({ ], }, id: Factory.each((i) => `g6-standard-${i}`), + disk: Factory.each((i) => i * 20480), label: Factory.each((i) => `Linode ${i} GB`), - memory: 2048, - vcpus: 2, + memory: Factory.each((i) => i * 2048), + vcpus: Factory.each((i) => i * 2), }); export const databaseInstanceFactory = Factory.Sync.makeFactory( @@ -150,6 +152,9 @@ export const databaseInstanceFactory = Factory.Sync.makeFactory i), instance_uri: '', label: Factory.each((i) => `database-${i}`), + members: { + '2.2.2.2': 'primary', + }, region: 'us-east', status: Factory.each(() => pickRandom(possibleStatuses)), type: databaseTypeFactory.build().id, @@ -176,6 +181,9 @@ export const databaseFactory = Factory.Sync.makeFactory({ }, id: Factory.each((i) => i), label: Factory.each((i) => `database-${i}`), + members: { + '2.2.2.2': 'primary', + }, port: 3306, region: 'us-east', ssl_connection: false, diff --git a/packages/manager/src/factories/disk.ts b/packages/manager/src/factories/disk.ts index 9aef8fe3c1c..0ee25de8cea 100644 --- a/packages/manager/src/factories/disk.ts +++ b/packages/manager/src/factories/disk.ts @@ -1,5 +1,5 @@ import { Disk } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const linodeDiskFactory = Factory.Sync.makeFactory({ created: '2018-01-01', diff --git a/packages/manager/src/factories/domain.ts b/packages/manager/src/factories/domain.ts index f0b2bad1c13..3244d825b7f 100644 --- a/packages/manager/src/factories/domain.ts +++ b/packages/manager/src/factories/domain.ts @@ -4,7 +4,7 @@ import { DomainRecord, ZoneFile, } from '@linode/api-v4/lib/domains/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const domainFactory = Factory.Sync.makeFactory({ axfr_ips: [], diff --git a/packages/manager/src/factories/entityTransfers.ts b/packages/manager/src/factories/entityTransfers.ts index 826f2889400..a785fbf0c45 100644 --- a/packages/manager/src/factories/entityTransfers.ts +++ b/packages/manager/src/factories/entityTransfers.ts @@ -2,7 +2,7 @@ import { EntityTransfer, TransferEntities, } from '@linode/api-v4/lib/entity-transfers/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; import { v4 } from 'uuid'; diff --git a/packages/manager/src/factories/events.ts b/packages/manager/src/factories/events.ts index 3b74a662149..f0dd62292bb 100644 --- a/packages/manager/src/factories/events.ts +++ b/packages/manager/src/factories/events.ts @@ -1,5 +1,5 @@ import { Entity, Event } from '@linode/api-v4/lib/account/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; export const entityFactory = Factory.Sync.makeFactory({ diff --git a/packages/manager/src/factories/factoryProxy.ts b/packages/manager/src/factories/factoryProxy.ts new file mode 100644 index 00000000000..437f443b27a --- /dev/null +++ b/packages/manager/src/factories/factoryProxy.ts @@ -0,0 +1,32 @@ +import * as Factory from 'factory.ts'; + +const originalEach = Factory.each; + +/** + * This file is a proxy for the factory.ts library. + * + * We Override the `each` method of the factory.ts library to start the index from 1 + * This prevents a a variety of issues with entity IDs being falsy when starting from 0. + * + * As a result, `Factory` must be imported from the `factoryProxy` file. ex: + * `import Factory from 'src/factories/factoryProxy';` + */ +const factoryProxyHandler = { + get( + target: typeof Factory, + prop: keyof typeof Factory, + receiver: typeof Factory + ) { + if (prop === 'each') { + return (fn: (index: number) => number | string) => { + return originalEach((i) => { + return fn(i + 1); + }); + }; + } + + return Reflect.get(target, prop, receiver); + }, +}; + +export default new Proxy(Factory, factoryProxyHandler); diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index 0c7589388e6..2a7e64d93db 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { ProductInformationBannerFlag } from 'src/featureFlags'; diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index 5d70b52d97c..5c72caa6d61 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -5,7 +5,7 @@ import { FirewallRuleType, FirewallRules, } from '@linode/api-v4/lib/firewalls/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const firewallRuleFactory = Factory.Sync.makeFactory({ action: 'DROP', diff --git a/packages/manager/src/factories/grants.ts b/packages/manager/src/factories/grants.ts index d1cd55caba0..bfa53a7de8b 100644 --- a/packages/manager/src/factories/grants.ts +++ b/packages/manager/src/factories/grants.ts @@ -1,5 +1,5 @@ import { Grant, Grants } from '@linode/api-v4/lib/account'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const grantFactory = Factory.Sync.makeFactory({ id: Factory.each((i) => i), diff --git a/packages/manager/src/factories/images.ts b/packages/manager/src/factories/images.ts index 29094c54369..85255aacb17 100644 --- a/packages/manager/src/factories/images.ts +++ b/packages/manager/src/factories/images.ts @@ -1,5 +1,5 @@ import { Image } from '@linode/api-v4/lib/images/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const imageFactory = Factory.Sync.makeFactory({ capabilities: [], @@ -12,8 +12,11 @@ export const imageFactory = Factory.Sync.makeFactory({ id: Factory.each((id) => `private/${id}`), is_public: false, label: Factory.each((i) => `image-${i}`), + regions: [], size: 1500, status: 'available', + tags: [], + total_size: 1500, type: 'manual', updated: new Date().toISOString(), vendor: null, diff --git a/packages/manager/src/factories/kernels.ts b/packages/manager/src/factories/kernels.ts index 316704080ee..ad1f0418993 100644 --- a/packages/manager/src/factories/kernels.ts +++ b/packages/manager/src/factories/kernels.ts @@ -1,5 +1,5 @@ import { Kernel } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const kernelFactory = Factory.Sync.makeFactory({ id: Factory.each((i) => `kernel-${i}`), diff --git a/packages/manager/src/factories/kubernetesCluster.ts b/packages/manager/src/factories/kubernetesCluster.ts index c2ca312899a..5bf468c00fe 100644 --- a/packages/manager/src/factories/kubernetesCluster.ts +++ b/packages/manager/src/factories/kubernetesCluster.ts @@ -6,7 +6,7 @@ import { KubernetesVersion, PoolNodeResponse, } from '@linode/api-v4/lib/kubernetes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { v4 } from 'uuid'; export const kubeLinodeFactory = Factory.Sync.makeFactory({ diff --git a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts index 914551a8fa6..4f041afec55 100644 --- a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts +++ b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts @@ -1,5 +1,5 @@ import { Interface } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const LinodeConfigInterfaceFactory = Factory.Sync.makeFactory( { diff --git a/packages/manager/src/factories/linodeConfigs.ts b/packages/manager/src/factories/linodeConfigs.ts index 66c2ec0de7a..08c64f8f2d9 100644 --- a/packages/manager/src/factories/linodeConfigs.ts +++ b/packages/manager/src/factories/linodeConfigs.ts @@ -1,5 +1,5 @@ import { Config } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LinodeConfigInterfaceFactory, diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index 63f5b0e8ad0..41adb044a39 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -12,7 +12,7 @@ import { Stats, StatsData, } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { placementGroupFactory } from './placementGroups'; @@ -263,6 +263,7 @@ export const linodeFactory = Factory.Sync.makeFactory({ ipv4: ['50.116.6.212', '192.168.203.1'], ipv6: '2600:3c00::f03c:92ff:fee2:6c40/64', label: Factory.each((i) => `linode-${i}`), + lke_cluster_id: null, placement_group: placementGroupFactory.build({ affinity_type: 'anti_affinity:local', id: 1, diff --git a/packages/manager/src/factories/longviewClient.ts b/packages/manager/src/factories/longviewClient.ts index 9de4845d034..f146da0e0c5 100644 --- a/packages/manager/src/factories/longviewClient.ts +++ b/packages/manager/src/factories/longviewClient.ts @@ -1,5 +1,5 @@ import { Apps, LongviewClient } from '@linode/api-v4/lib/longview'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const longviewAppsFactory = Factory.Sync.makeFactory({ apache: false, diff --git a/packages/manager/src/factories/longviewDisks.ts b/packages/manager/src/factories/longviewDisks.ts index 35d77269cb6..ea43a79a5ac 100644 --- a/packages/manager/src/factories/longviewDisks.ts +++ b/packages/manager/src/factories/longviewDisks.ts @@ -1,11 +1,37 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; -import { Disk, LongviewDisk } from 'src/features/Longview/request.types'; +import { + Disk, + LongviewDisk, + LongviewCPU, + CPU, + LongviewSystemInfo, + LongviewNetworkInterface, + InboundOutboundNetwork, + LongviewNetwork, + LongviewMemory, + LongviewLoad, + Uptime, +} from 'src/features/Longview/request.types'; const mockStats = [ - { x: 0, y: 1 }, - { x: 0, y: 2 }, - { x: 0, y: 3 }, + { x: 1717770900, y: 0 }, + { x: 1717770900, y: 20877.4637037037 }, + { x: 1717770900, y: 4.09420479302832 }, + { x: 1717770900, y: 83937959936 }, + { x: 1717770900, y: 5173267 }, + { x: 1717770900, y: 5210112 }, + { x: 1717770900, y: 82699642934.6133 }, + { x: 1717770900, y: 0.0372984749455338 }, + { x: 1717770900, y: 0.00723311546840959 }, + { x: 1717770900, y: 0.0918300653594771 }, + { x: 1717770900, y: 466.120718954248 }, + { x: 1717770900, y: 451.9651416122 }, + { x: 1717770900, y: 524284 }, + { x: 1717770900, y: 547242.706666667 }, + { x: 1717770900, y: 3466265.29333333 }, + { x: 1717770900, y: 57237.6133333333 }, + { x: 1717770900, y: 365385.893333333 }, ]; export const diskFactory = Factory.Sync.makeFactory({ @@ -14,8 +40,23 @@ export const diskFactory = Factory.Sync.makeFactory({ dm: 0, isswap: 0, mounted: 1, - reads: mockStats, - writes: mockStats, + reads: [mockStats[0]], + write_bytes: [mockStats[1]], + writes: [mockStats[2]], + fs: { + total: [mockStats[3]], + ifree: [mockStats[4]], + itotal: [mockStats[5]], + path: '/', + free: [mockStats[6]], + }, + read_bytes: [mockStats[0]], +}); + +export const cpuFactory = Factory.Sync.makeFactory({ + system: [mockStats[7]], + wait: [mockStats[8]], + user: [mockStats[9]], }); export const longviewDiskFactory = Factory.Sync.makeFactory({ @@ -24,3 +65,75 @@ export const longviewDiskFactory = Factory.Sync.makeFactory({ '/dev/sdb': diskFactory.build({ isswap: 1 }), }, }); + +export const longviewCPUFactory = Factory.Sync.makeFactory({ + CPU: { + cpu0: cpuFactory.build(), + cpu1: cpuFactory.build(), + }, +}); + +export const longviewSysInfoFactory = Factory.Sync.makeFactory( + { + SysInfo: { + arch: 'x86_64', + client: '1.1.5', + cpu: { + cores: 2, + type: 'AMD EPYC 7713 64-Core Processor', + }, + hostname: 'localhost', + kernel: 'Linux 5.10.0-28-amd64', + os: { + dist: 'Debian', + distversion: '11.9', + }, + type: 'kvm', + }, + } +); + +export const InboundOutboundNetworkFactory = Factory.Sync.makeFactory( + { + rx_bytes: [mockStats[10]], + tx_bytes: [mockStats[11]], + } +); + +export const LongviewNetworkInterfaceFactory = Factory.Sync.makeFactory( + { + eth0: InboundOutboundNetworkFactory.build(), + } +); + +export const longviewNetworkFactory = Factory.Sync.makeFactory( + { + Network: { + Interface: LongviewNetworkInterfaceFactory.build(), + mac_addr: 'f2:3c:94:e6:81:e2', + }, + } +); + +export const LongviewMemoryFactory = Factory.Sync.makeFactory({ + Memory: { + swap: { + free: [mockStats[12]], + used: [mockStats[0]], + }, + real: { + used: [mockStats[13]], + free: [mockStats[14]], + buffers: [mockStats[15]], + cache: [mockStats[16]], + }, + }, +}); + +export const LongviewLoadFactory = Factory.Sync.makeFactory({ + Load: [mockStats[0]], +}); + +export const UptimeFactory = Factory.Sync.makeFactory({ + uptime: 84516.53, +}); diff --git a/packages/manager/src/factories/longviewProcess.ts b/packages/manager/src/factories/longviewProcess.ts index 0da3ab24193..cc465b690f1 100644 --- a/packages/manager/src/factories/longviewProcess.ts +++ b/packages/manager/src/factories/longviewProcess.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LongviewProcesses, diff --git a/packages/manager/src/factories/longviewResponse.ts b/packages/manager/src/factories/longviewResponse.ts index 315fad71bff..427b3809c5d 100644 --- a/packages/manager/src/factories/longviewResponse.ts +++ b/packages/manager/src/factories/longviewResponse.ts @@ -1,14 +1,58 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LongviewResponse } from 'src/features/Longview/request.types'; +import { AllData, LongviewPackage } from 'src/features/Longview/request.types'; -import { longviewDiskFactory } from './longviewDisks'; +import { + longviewDiskFactory, + longviewCPUFactory, + longviewSysInfoFactory, + longviewNetworkFactory, + LongviewMemoryFactory, + LongviewLoadFactory, + UptimeFactory, +} from './longviewDisks'; + +const longviewResponseData = () => { + const diskData = longviewDiskFactory.build(); + const cpuData = longviewCPUFactory.build(); + const sysinfoData = longviewSysInfoFactory.build(); + const networkData = longviewNetworkFactory.build(); + const memoryData = LongviewMemoryFactory.build(); + const loadData = LongviewLoadFactory.build(); + const uptimeData = UptimeFactory.build(); + + return { + ...diskData, + ...cpuData, + ...sysinfoData, + ...networkData, + ...memoryData, + ...loadData, + ...uptimeData, + }; +}; export const longviewResponseFactory = Factory.Sync.makeFactory( { - ACTION: 'getValues', - DATA: longviewDiskFactory.build(), + ACTION: 'getLatestValue', + DATA: {}, NOTIFICATIONS: [], VERSION: 0.4, } ); + +export const longviewLatestStatsFactory = Factory.Sync.makeFactory< + Partial +>({ + ...longviewResponseData(), +}); + +export const longviewPackageFactory = Factory.Sync.makeFactory( + { + current: Factory.each((i) => `${i + 1}.${i + 2}.${i + 3}`), + held: 0, + name: Factory.each((i) => `mock-package-${i}`), + new: Factory.each((i) => `${i + 1}.${i + 2}.${i + 3}`), + } +); diff --git a/packages/manager/src/factories/longviewService.ts b/packages/manager/src/factories/longviewService.ts index ff3462c7fd5..fbc8978a36e 100644 --- a/packages/manager/src/factories/longviewService.ts +++ b/packages/manager/src/factories/longviewService.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LongviewPort, diff --git a/packages/manager/src/factories/longviewSubscription.ts b/packages/manager/src/factories/longviewSubscription.ts index 4fe2d140d9c..6e63fe5bf35 100644 --- a/packages/manager/src/factories/longviewSubscription.ts +++ b/packages/manager/src/factories/longviewSubscription.ts @@ -2,7 +2,7 @@ import { ActiveLongviewPlan, LongviewSubscription, } from '@linode/api-v4/lib/longview/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const longviewSubscriptionFactory = Factory.Sync.makeFactory( { diff --git a/packages/manager/src/factories/longviewTopProcesses.ts b/packages/manager/src/factories/longviewTopProcesses.ts index ee51a280775..22cd669ce31 100644 --- a/packages/manager/src/factories/longviewTopProcesses.ts +++ b/packages/manager/src/factories/longviewTopProcesses.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LongviewTopProcesses, diff --git a/packages/manager/src/factories/managed.ts b/packages/manager/src/factories/managed.ts index 9652ce62c11..daceda4dabf 100644 --- a/packages/manager/src/factories/managed.ts +++ b/packages/manager/src/factories/managed.ts @@ -9,7 +9,7 @@ import { ManagedServiceMonitor, ManagedStats, } from '@linode/api-v4/lib/managed/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const contactFactory = Factory.Sync.makeFactory({ email: Factory.each((i) => `john.doe.${i}@example.com`), diff --git a/packages/manager/src/factories/networking.ts b/packages/manager/src/factories/networking.ts index 7580e12be81..74a29840383 100644 --- a/packages/manager/src/factories/networking.ts +++ b/packages/manager/src/factories/networking.ts @@ -1,5 +1,5 @@ import { IPAddress } from '@linode/api-v4/lib/networking'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const ipAddressFactory = Factory.Sync.makeFactory({ address: Factory.each((id) => `192.168.1.${id}`), diff --git a/packages/manager/src/factories/nodebalancer.ts b/packages/manager/src/factories/nodebalancer.ts index 0be6b30b428..711289d7abf 100644 --- a/packages/manager/src/factories/nodebalancer.ts +++ b/packages/manager/src/factories/nodebalancer.ts @@ -3,7 +3,7 @@ import { NodeBalancerConfig, NodeBalancerConfigNode, } from '@linode/api-v4/lib/nodebalancers/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const nodeBalancerFactory = Factory.Sync.makeFactory({ client_conn_throttle: 0, diff --git a/packages/manager/src/factories/notification.ts b/packages/manager/src/factories/notification.ts index 45d146243ef..16f59842c5c 100644 --- a/packages/manager/src/factories/notification.ts +++ b/packages/manager/src/factories/notification.ts @@ -1,5 +1,5 @@ import { Entity, Notification } from '@linode/api-v4/lib/account'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; const generateEntity = ( diff --git a/packages/manager/src/factories/oauth.ts b/packages/manager/src/factories/oauth.ts index be6b64dd528..e2151eb62d5 100644 --- a/packages/manager/src/factories/oauth.ts +++ b/packages/manager/src/factories/oauth.ts @@ -1,5 +1,5 @@ import { Token } from '@linode/api-v4/lib/profile/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const appTokenFactory = Factory.Sync.makeFactory({ created: '2020-01-01T12:00:00', diff --git a/packages/manager/src/factories/objectStorage.ts b/packages/manager/src/factories/objectStorage.ts index a45038cc9ee..6d5d7411f34 100644 --- a/packages/manager/src/factories/objectStorage.ts +++ b/packages/manager/src/factories/objectStorage.ts @@ -4,7 +4,7 @@ import { ObjectStorageKey, ObjectStorageObject, } from '@linode/api-v4/lib/object-storage/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const objectStorageBucketFactory = Factory.Sync.makeFactory( { diff --git a/packages/manager/src/factories/placementGroups.ts b/packages/manager/src/factories/placementGroups.ts index 03d89ba1009..c3fa3b8dfea 100644 --- a/packages/manager/src/factories/placementGroups.ts +++ b/packages/manager/src/factories/placementGroups.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { pickRandom } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/preferences.ts b/packages/manager/src/factories/preferences.ts index 06deb6125b6..3072b842b4b 100644 --- a/packages/manager/src/factories/preferences.ts +++ b/packages/manager/src/factories/preferences.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { ManagerPreferences } from 'src/types/ManagerPreferences'; diff --git a/packages/manager/src/factories/profile.ts b/packages/manager/src/factories/profile.ts index 8bbb442f188..b16a34dc49e 100644 --- a/packages/manager/src/factories/profile.ts +++ b/packages/manager/src/factories/profile.ts @@ -4,7 +4,7 @@ import { SecurityQuestionsData, UserPreferences, } from '@linode/api-v4/lib/profile'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const profileFactory = Factory.Sync.makeFactory({ authentication_type: 'password', diff --git a/packages/manager/src/factories/promotionalOffer.ts b/packages/manager/src/factories/promotionalOffer.ts index b68eaa0f1d4..e401266b511 100644 --- a/packages/manager/src/factories/promotionalOffer.ts +++ b/packages/manager/src/factories/promotionalOffer.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { PromotionalOffer } from 'src/featureFlags'; diff --git a/packages/manager/src/factories/regions.ts b/packages/manager/src/factories/regions.ts index 16387addac8..369e3e1f09f 100644 --- a/packages/manager/src/factories/regions.ts +++ b/packages/manager/src/factories/regions.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import type { Country, diff --git a/packages/manager/src/factories/stackscripts.ts b/packages/manager/src/factories/stackscripts.ts index 398ac04a5ce..090db396c83 100644 --- a/packages/manager/src/factories/stackscripts.ts +++ b/packages/manager/src/factories/stackscripts.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import type { StackScript, @@ -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/statusPage.ts b/packages/manager/src/factories/statusPage.ts index 6781cbb2e87..f485eee7069 100644 --- a/packages/manager/src/factories/statusPage.ts +++ b/packages/manager/src/factories/statusPage.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { v4 } from 'uuid'; import { diff --git a/packages/manager/src/factories/subnets.ts b/packages/manager/src/factories/subnets.ts index 4e8bc5c5796..3b0f4e7f145 100644 --- a/packages/manager/src/factories/subnets.ts +++ b/packages/manager/src/factories/subnets.ts @@ -2,7 +2,7 @@ import { Subnet, SubnetAssignedLinodeData, } from '@linode/api-v4/lib/vpcs/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; // NOTE: Changing to fixed array length for the interfaces and linodes fields of the // subnetAssignedLinodeDataFactory and subnetFactory respectively -- see [M3-7227] for more details diff --git a/packages/manager/src/factories/support.ts b/packages/manager/src/factories/support.ts index 524ed6c0c1f..7fddfe1f36e 100644 --- a/packages/manager/src/factories/support.ts +++ b/packages/manager/src/factories/support.ts @@ -1,5 +1,5 @@ import { SupportReply, SupportTicket } from '@linode/api-v4/lib/support/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const supportTicketFactory = Factory.Sync.makeFactory({ attachments: [], diff --git a/packages/manager/src/factories/tags.ts b/packages/manager/src/factories/tags.ts index 6c1699952bc..07fc150d870 100644 --- a/packages/manager/src/factories/tags.ts +++ b/packages/manager/src/factories/tags.ts @@ -1,5 +1,5 @@ import { Tag } from '@linode/api-v4/lib/tags/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const tagFactory = Factory.Sync.makeFactory({ label: Factory.each((id) => `tag-${id + 1}`), diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index 830f7efdbcd..4b7cb8f755e 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import type { LinodeType } from '@linode/api-v4/lib/linodes/types'; import type { PriceType } from '@linode/api-v4/src/types'; @@ -172,3 +172,123 @@ 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', + price: { + hourly: 0.0075, + monthly: 5.0, + }, + region_prices: [ + { + hourly: 0.0075, + id: 'id-cgk', + monthly: 5.0, + }, + { + hourly: 0.0075, + id: 'br-gru', + monthly: 5.0, + }, + ], + transfer: 1000, +}); + +export const objectStorageOverageTypeFactory = Factory.Sync.makeFactory( + { + id: 'objectstorage-overage', + label: 'Object Storage Overage', + price: { + hourly: 0.02, + monthly: null, + }, + region_prices: [ + { + hourly: 0.024, + id: 'id-cgk', + monthly: null, + }, + { + hourly: 0.028, + id: 'br-gru', + monthly: null, + }, + ], + transfer: 0, + } +); + +export const distributedNetworkTransferPriceTypeFactory = 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/factories/vlans.ts b/packages/manager/src/factories/vlans.ts index ec6b5154920..0ee8708c553 100644 --- a/packages/manager/src/factories/vlans.ts +++ b/packages/manager/src/factories/vlans.ts @@ -1,5 +1,5 @@ import { VLAN } from '@linode/api-v4/lib/vlans/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const VLANFactory = Factory.Sync.makeFactory({ cidr_block: '10.0.0.0/24', diff --git a/packages/manager/src/factories/volume.ts b/packages/manager/src/factories/volume.ts index 5b7b6b52c14..5a127893af9 100644 --- a/packages/manager/src/factories/volume.ts +++ b/packages/manager/src/factories/volume.ts @@ -1,5 +1,5 @@ import { Volume, VolumeRequestPayload } from '@linode/api-v4/lib/volumes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const volumeFactory = Factory.Sync.makeFactory({ created: '2018-01-01', diff --git a/packages/manager/src/factories/vpcs.ts b/packages/manager/src/factories/vpcs.ts index f3d66072b2a..5584639f695 100644 --- a/packages/manager/src/factories/vpcs.ts +++ b/packages/manager/src/factories/vpcs.ts @@ -1,5 +1,5 @@ import { VPC } from '@linode/api-v4/lib/vpcs/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const vpcFactory = Factory.Sync.makeFactory({ created: '2023-07-12T16:08:53', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 9c0ecc17339..fd537b7e0ef 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; } @@ -38,33 +46,49 @@ interface TaxCollectionBanner { regions?: TaxCollectionRegion[]; } -interface PlacementGroupsFlag { - beta: boolean; +interface BaseFeatureFlag { enabled: boolean; } -interface GeckoFlag { - enabled: boolean; +interface BetaFeatureFlag extends BaseFeatureFlag { + beta: boolean; +} + +interface GaFeatureFlag extends BaseFeatureFlag { ga: boolean; } +interface AclpFlag { + beta: boolean; + enabled: boolean; +} + interface gpuV2 { planDivider: boolean; } type OneClickApp = Record; +interface DesignUpdatesBannerFlag extends BaseFeatureFlag { + key: string; + link: string; +} + export interface Flags { aclb: boolean; aclbFullCreateFlow: boolean; + aclp: AclpFlag; apiMaintenance: APIMaintenance; + cloudManagerDesignUpdatesBanner: DesignUpdatesBannerFlag; databaseBeta: boolean; databaseResize: boolean; databases: boolean; disableLargestGbPlans: boolean; + eventMessagesV2: boolean; gecko: boolean; // @TODO gecko: delete this after next release - gecko2: GeckoFlag; + gecko2: GaFeatureFlag; gpuv2: gpuV2; + imageServiceGen2: boolean; ipv6Sharing: boolean; linodeCreateRefactor: boolean; linodeCreateWithFirewall: boolean; @@ -72,10 +96,10 @@ export interface Flags { mainContentBanner: MainContentBanner; metadata: boolean; objMultiCluster: boolean; + objectStorageGen2: BaseFeatureFlag; oneClickApps: OneClickApp; oneClickAppsDocsOverride: Record; - parentChildAccountAccess: boolean; - placementGroups: PlacementGroupsFlag; + placementGroups: BetaFeatureFlag; productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; promotionalOffers: PromotionalOffer[]; @@ -85,6 +109,7 @@ export interface Flags { supportTicketSeverity: boolean; taxBanner: TaxBanner; taxCollectionBanner: TaxCollectionBanner; + taxId: BaseFeatureFlag; taxes: Taxes; tpaProviders: Provider[]; } diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 44880338905..df7e7fc20b4 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -14,10 +14,9 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { switchAccountSessionContext } from 'src/context/switchAccountSessionContext'; import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import AccountLogins from './AccountLogins'; @@ -52,7 +51,6 @@ const AccountLanding = () => { const { data: account } = useAccount(); const { data: profile } = useProfile(); - const flags = useFlags(); const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const sessionContext = React.useContext(switchAccountSessionContext); @@ -143,8 +141,7 @@ const AccountLanding = () => { const isBillingTabSelected = location.pathname.match(/billing/); const canSwitchBetweenParentOrProxyAccount = - flags.parentChildAccountAccess && - ((!isChildAccountAccessRestricted && isParentUser) || isProxyUser); + (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { diff --git a/packages/manager/src/features/Account/AccountLogins.tsx b/packages/manager/src/features/Account/AccountLogins.tsx index 2b07f1a1f8f..055a28e1a32 100644 --- a/packages/manager/src/features/Account/AccountLogins.tsx +++ b/packages/manager/src/features/Account/AccountLogins.tsx @@ -16,11 +16,10 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; 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 { useAccountLoginsQuery } from 'src/queries/account/logins'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import AccountLoginsTableRow from './AccountLoginsTableRow'; import { getRestrictedResourceText } from './utils'; @@ -44,7 +43,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ const AccountLogins = () => { const { classes } = useStyles(); const pagination = usePagination(1, preferenceKey); - const flags = useFlags(); const { handleOrderChange, order, orderBy } = useOrder( { @@ -69,9 +67,7 @@ const AccountLogins = () => { const { data: profile } = useProfile(); const isChildUser = profile?.user_type === 'child'; - const isRestrictedChildUser = Boolean( - flags.parentChildAccountAccess && isChildUser - ); + const isRestrictedChildUser = Boolean(isChildUser); const isAccountAccessRestricted = isRestrictedChildUser || profile?.restricted; diff --git a/packages/manager/src/features/Account/AccountLoginsTableRow.tsx b/packages/manager/src/features/Account/AccountLoginsTableRow.tsx index 5763a6c8227..1fa71109e86 100644 --- a/packages/manager/src/features/Account/AccountLoginsTableRow.tsx +++ b/packages/manager/src/features/Account/AccountLoginsTableRow.tsx @@ -9,7 +9,7 @@ import { Link } from 'src/components/Link'; import { Status, StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Account/CloseAccountDialog.tsx b/packages/manager/src/features/Account/CloseAccountDialog.tsx index e4d3cf748ae..9513c701954 100644 --- a/packages/manager/src/features/Account/CloseAccountDialog.tsx +++ b/packages/manager/src/features/Account/CloseAccountDialog.tsx @@ -9,7 +9,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; interface Props { closeDialog: () => void; diff --git a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx index 8d6e1cb39d7..03555a3e0da 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx @@ -16,8 +16,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useProfile: queryMocks.useProfile, @@ -35,6 +35,10 @@ describe('Close Account Settings', () => { }); it('should render a Close Account Button', () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: 'default' }), + }); + const { getByTestId } = renderWithTheme(); const button = getByTestId('close-account-button'); const span = button.querySelector('span'); @@ -49,10 +53,7 @@ describe('Close Account Settings', () => { }); const { getByRole, getByTestId, getByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const button = getByTestId('close-account-button'); fireEvent.mouseOver(button); @@ -73,10 +74,7 @@ describe('Close Account Settings', () => { }); const { getByRole, getByTestId, getByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const button = getByTestId('close-account-button'); fireEvent.mouseOver(button); @@ -97,10 +95,7 @@ describe('Close Account Settings', () => { }); const { getByRole, getByTestId, getByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const button = getByTestId('close-account-button'); fireEvent.mouseOver(button); diff --git a/packages/manager/src/features/Account/CloseAccountSetting.tsx b/packages/manager/src/features/Account/CloseAccountSetting.tsx index 440fe8b7993..8ef0230b21e 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.tsx @@ -3,8 +3,7 @@ import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; import { Button } from 'src/components/Button/Button'; -import { useFlags } from 'src/hooks/useFlags'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import CloseAccountDialog from './CloseAccountDialog'; import { @@ -17,12 +16,9 @@ const CloseAccountSetting = () => { const [dialogOpen, setDialogOpen] = React.useState(false); const { data: profile } = useProfile(); - const flags = useFlags(); // Disable the Close Account button for users with a Parent/Proxy/Child user type. - const isCloseAccountDisabled = Boolean( - flags.parentChildAccountAccess && profile?.user_type !== 'default' - ); + const isCloseAccountDisabled = Boolean(profile?.user_type !== 'default'); let closeAccountButtonTooltipText; const userType = profile?.user_type; diff --git a/packages/manager/src/features/Account/EnableObjectStorage.tsx b/packages/manager/src/features/Account/EnableObjectStorage.tsx index 51e74662cf0..66f4b2ca586 100644 --- a/packages/manager/src/features/Account/EnableObjectStorage.tsx +++ b/packages/manager/src/features/Account/EnableObjectStorage.tsx @@ -2,8 +2,8 @@ import { AccountSettings } from '@linode/api-v4/lib/account'; import { cancelObjectStorage } from '@linode/api-v4/lib/object-storage'; import { APIError } from '@linode/api-v4/lib/types'; import Grid from '@mui/material/Unstable_Grid2'; -import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; import { Button } from 'src/components/Button/Button'; @@ -13,7 +13,7 @@ import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToCo import { Typography } from 'src/components/Typography'; import { updateAccountSettingsData } from 'src/queries/account/settings'; import { queryKey } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; interface Props { object_storage: AccountSettings['object_storage']; } diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 8848c6ad16a..6b40b3ced15 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -8,7 +8,7 @@ import { Status, StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Tooltip } from 'src/components/Tooltip'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx index aa9e88ebe1b..aca09b3f886 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx @@ -29,6 +29,12 @@ describe('SwitchAccountDrawer', () => { ).toBeInTheDocument(); }); + it('should have a search bar', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Search')).toBeVisible(); + }); + it('should include a link to switch back to the parent account if the active user is a proxy user', async () => { server.use( http.get('*/profile', () => { diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index dbcdc3ad084..50f546ae1de 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; @@ -37,6 +38,7 @@ export const SwitchAccountDrawer = (props: Props) => { const [isParentTokenError, setIsParentTokenError] = React.useState< APIError[] >([]); + const [query, setQuery] = React.useState(''); const isProxyUser = userType === 'proxy'; const currentParentTokenWithBearer = @@ -154,6 +156,16 @@ export const SwitchAccountDrawer = (props: Props) => { )} . + { isLoading={isSubmitting} onClose={onClose} onSwitchAccount={handleSwitchToChildAccount} + searchQuery={query} userType={userType} /> diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx index 8aea8a621d4..3e3416e2426 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx @@ -11,6 +11,7 @@ const props = { currentTokenWithBearer: 'Bearer 123', onClose: vi.fn(), onSwitchAccount: vi.fn(), + searchQuery: '', userType: undefined, }; diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx index c6dfa3b3cea..0e2242a2c18 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx @@ -11,7 +11,7 @@ import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useChildAccountsInfiniteQuery } from 'src/queries/account/account'; -import type { UserType } from '@linode/api-v4'; +import type { Filter, UserType } from '@linode/api-v4'; interface ChildAccountListProps { currentTokenWithBearer: string; @@ -24,6 +24,7 @@ interface ChildAccountListProps { onClose: () => void; userType: UserType | undefined; }) => void; + searchQuery: string; userType: UserType | undefined; } @@ -33,8 +34,17 @@ export const ChildAccountList = React.memo( isLoading, onClose, onSwitchAccount, + searchQuery, userType, }: ChildAccountListProps) => { + const filter: Filter = { + ['+order']: 'asc', + ['+order_by']: 'company', + }; + if (searchQuery) { + filter['company'] = { '+contains': searchQuery }; + } + const [ isSwitchingChildAccounts, setIsSwitchingChildAccounts, @@ -46,8 +56,10 @@ export const ChildAccountList = React.memo( isError, isFetchingNextPage, isInitialLoading, + isRefetching, refetch: refetchChildAccounts, } = useChildAccountsInfiniteQuery({ + filter, headers: userType === 'proxy' ? { @@ -57,17 +69,28 @@ export const ChildAccountList = React.memo( }); const childAccounts = data?.pages.flatMap((page) => page.data); - if (isInitialLoading) { + if ( + isInitialLoading || + isLoading || + isSwitchingChildAccounts || + isRefetching + ) { return ( - + ); } if (childAccounts?.length === 0) { return ( - There are no indirect customer accounts. + + There are no child accounts + {filter.hasOwnProperty('company') + ? ' that match this query' + : undefined} + . + ); } @@ -119,14 +142,9 @@ export const ChildAccountList = React.memo( return ( - {(isSwitchingChildAccounts || isLoading) && ( - - - - )} {!isSwitchingChildAccounts && !isLoading && renderChildAccounts} {hasNextPage && fetchNextPage()} />} - {isFetchingNextPage && } + {isFetchingNextPage && } ); } diff --git a/packages/manager/src/features/Account/utils.ts b/packages/manager/src/features/Account/utils.ts index b181a1f88c0..4fabcaa6edf 100644 --- a/packages/manager/src/features/Account/utils.ts +++ b/packages/manager/src/features/Account/utils.ts @@ -1,12 +1,16 @@ +import { useFlags } from 'src/hooks/useFlags'; + import { ADMINISTRATOR, PARENT_USER } from './constants'; import type { GlobalGrantTypes, GrantLevel } from '@linode/api-v4'; import type { GrantTypeMap } from 'src/features/Account/types'; export type ActionType = + | 'attach' | 'clone' | 'create' | 'delete' + | 'detach' | 'edit' | 'migrate' | 'modify' @@ -63,3 +67,23 @@ export const getRestrictedResourceText = ({ return message; }; + +/** + * Hook to determine if the Tax Id feature should be visible to the user. + * Based on the user's account capability and the feature flag. + * + * @returns {boolean} - Whether the TaxId feature is enabled for the current user. + */ +export const useIsTaxIdEnabled = (): { + isTaxIdEnabled: boolean; +} => { + const flags = useFlags(); + + if (!flags) { + return { isTaxIdEnabled: false }; + } + + const isTaxIdEnabled = Boolean(flags.taxId?.enabled); + + return { isTaxIdEnabled }; +}; diff --git a/packages/manager/src/features/Backups/BackupsCTA.tsx b/packages/manager/src/features/Backups/BackupsCTA.tsx index 62a833ff4fb..6920e171d3e 100644 --- a/packages/manager/src/features/Backups/BackupsCTA.tsx +++ b/packages/manager/src/features/Backups/BackupsCTA.tsx @@ -6,8 +6,11 @@ import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { Typography } from 'src/components/Typography'; import { useAccountSettings } from 'src/queries/account/settings'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; -import { useProfile } from 'src/queries/profile'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; +import { useProfile } from 'src/queries/profile/profile'; import { BackupDrawer } from './BackupDrawer'; import { StyledPaper } from './BackupsCTA.styles'; diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index dd2e7dd4807..6102f09b7c8 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -11,7 +11,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { PAYPAL_CLIENT_ID } from 'src/constants'; import { useAccount } from 'src/queries/account/account'; import { useAllPaymentMethodsQuery } from 'src/queries/account/payment'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import BillingActivityPanel from './BillingPanels/BillingActivityPanel/BillingActivityPanel'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx index 8f17374c8b2..ba8d988fd30 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx @@ -63,10 +63,10 @@ describe('BillingActivityPanel', () => { ); await waitFor(() => { - getByText('Invoice #0'); getByText('Invoice #1'); - getByTestId(`payment-0`); + getByText('Invoice #2'); getByTestId(`payment-1`); + getByTestId(`payment-2`); }); }); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index 8cbb72aa281..9a7235d8729 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -4,7 +4,7 @@ import { Payment, getInvoiceItems, } from '@linode/api-v4/lib/account'; -import { Theme } from '@mui/material/styles'; +import { Theme, styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; @@ -39,7 +39,7 @@ import { useAllAccountInvoices, useAllAccountPayments, } from 'src/queries/account/billing'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; @@ -335,9 +335,11 @@ export const BillingActivityPanel = (props: Props) => { }, [selectedTransactionType, combinedData]); return ( - +
    -
    + {`${isAkamaiCustomer ? 'Usage' : 'Billing & Payment'} History`} @@ -397,7 +399,7 @@ export const BillingActivityPanel = (props: Props) => { />
    -
    + { ); }; +const StyledBillingAndPaymentHistoryHeader = styled('div', { + name: 'BillingAndPaymentHistoryHeader', +})(({ theme }) => ({ + border: theme.name === 'dark' ? `1px solid ${theme.borderColors.divider}` : 0, + borderBottom: 0, +})); + // ============================================================================= // // ============================================================================= diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx index b9605c9cbde..35688591d00 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx @@ -31,7 +31,7 @@ describe('BillingSummary', () => { ); - within(screen.getByTestId(accountBalanceText)).getByText(/no balance/gi); + within(screen.getByTestId(accountBalanceText)).getByText(/no balance/i); within(screen.getByTestId(accountBalanceValue)).getByText('$0.00'); }); @@ -45,7 +45,7 @@ describe('BillingSummary', () => { /> ); - within(screen.getByTestId(accountBalanceText)).getByText(/credit/gi); + within(screen.getByTestId(accountBalanceText)).getByText(/credit/i); within(screen.getByTestId(accountBalanceValue)).getByText('$10.00'); }); @@ -59,7 +59,7 @@ describe('BillingSummary', () => { /> ); - within(screen.getByTestId(accountBalanceText)).getByText(/Balance/gi); + within(screen.getByTestId(accountBalanceText)).getByText(/Balance/i); within(screen.getByTestId(accountBalanceValue)).getByText('$10.00'); }); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx index b18adbaa7d2..b2ceee1d0cc 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx @@ -14,7 +14,7 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { isWithinDays } from 'src/utilities/date'; import { BillingPaper } from '../../BillingDetail'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx index 99835eefb88..f9fdb454989 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx @@ -145,7 +145,7 @@ export const GooglePayButton = (props: Props) => { container justifyContent="center" > - + ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx index 6da3a6056d7..94b614ffe95 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx @@ -222,7 +222,7 @@ export const PayPalButton = (props: Props) => { container justifyContent="center" > - + ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx index 56ce8a853e5..9ee3fec3f06 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx @@ -23,7 +23,7 @@ import { Typography } from 'src/components/Typography'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { isCreditCardExpired } from 'src/utilities/creditCard'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx index 7a0c9b1f7e3..f536dda3c10 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx @@ -29,8 +29,8 @@ const props = { zip: '19106', }; -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useGrants: queryMocks.useGrants, @@ -47,10 +47,7 @@ describe('Edit Contact Information', () => { }); const { getByTestId } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); expect(getByTestId(EDIT_BUTTON_ID)).toHaveAttribute( diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx index 7fb93414ed6..a9402f6a85e 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -8,11 +8,15 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { + getRestrictedResourceText, + useIsTaxIdEnabled, +} from 'src/features/Account/utils'; +import { TAX_ID_HELPER_TEXT } from 'src/features/Billing/constants'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount, useMutateAccount } from 'src/queries/account/account'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; interface Props { @@ -29,6 +33,7 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { const { classes } = useStyles(); const emailRef = React.useRef(); const { data: profile } = useProfile(); + const { isTaxIdEnabled } = useIsTaxIdEnabled(); const isChildUser = profile?.user_type === 'child'; const isParentUser = profile?.user_type === 'parent'; const isReadOnly = @@ -166,6 +171,11 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { formik.setFieldValue('company', ''); } + const handleCountryChange = (item: Item) => { + formik.setFieldValue('country', item.value); + formik.setFieldValue('tax_id', ''); + }; + return (
    { errorText={errorMap.country} isClearable={false} label="Country" - onChange={(item) => formik.setFieldValue('country', item.value)} + onChange={(item) => handleCountryChange(item)} options={countryResults} placeholder="Select a Country" required @@ -359,6 +369,11 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { { if (isLoading) { return ( - + ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx index 7c76f26d092..b96626489e5 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx @@ -161,7 +161,7 @@ export const PayPalChip = (props: Props) => { if (isLoading || isPending || !options['data-client-token']) { return ( - + ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx index 6e66ab4a7d3..44253932d73 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx @@ -25,8 +25,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useGrants: queryMocks.useGrants, @@ -145,10 +145,7 @@ describe('Payment Info Panel', () => { {...props} profile={queryMocks.useProfile().data} /> - , - { - flags: { parentChildAccountAccess: true }, - } + ); expect(getByTestId(ADD_PAYMENT_METHOD_BUTTON_ID)).toHaveAttribute( diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx index fb16f7e602e..056fc1c7e61 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx @@ -33,7 +33,7 @@ const PaymentMethods = ({ justifyContent: 'center', }} > - + ); } diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx index cfe7c4a3cad..e633213429d 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx @@ -1,8 +1,6 @@ import { InvoiceItem } from '@linode/api-v4/lib/account'; import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { Currency } from 'src/components/Currency'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; @@ -21,18 +19,6 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { getInvoiceRegion } from '../PdfGenerator/utils'; -const useStyles = makeStyles()((theme: Theme) => ({ - table: { - '& thead th': { - '&:last-of-type': { - paddingRight: 15, - }, - borderBottom: `1px solid ${theme.borderColors.borderTable}`, - }, - border: `1px solid ${theme.borderColors.borderTable}`, - }, -})); - interface Props { errors?: APIError[]; items?: InvoiceItem[]; @@ -41,7 +27,6 @@ interface Props { } export const InvoiceTable = (props: Props) => { - const { classes } = useStyles(); const MIN_PAGE_SIZE = 25; const { @@ -157,7 +142,7 @@ export const InvoiceTable = (props: Props) => { }; return ( - +
    Description 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/Billing/constants.ts b/packages/manager/src/features/Billing/constants.ts index 4229f93f157..e77dda6fbb6 100644 --- a/packages/manager/src/features/Billing/constants.ts +++ b/packages/manager/src/features/Billing/constants.ts @@ -1,2 +1,4 @@ export const ADD_PAYMENT_METHOD = 'Add Payment Method'; export const EDIT_BILLING_CONTACT = 'Edit'; +export const TAX_ID_HELPER_TEXT = + 'Tax Identification Numbers (TIN) are set by the national authorities and they have different names in different countries. Enter a TIN valid for the country of your billing address. It will be validated.'; diff --git a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx new file mode 100644 index 00000000000..f8cfbe748fd --- /dev/null +++ b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; + +import { CloudPulseTabs } from './CloudPulseTabs'; +export const CloudPulseLanding = () => { + return ( + <> + + }> + + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx new file mode 100644 index 00000000000..a6562db5204 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx @@ -0,0 +1,55 @@ +import { styled } from '@mui/material/styles'; +import * as React from 'react'; +import { RouteComponentProps, matchPath } from 'react-router-dom'; + +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabLinkList } from 'src/components/Tabs/TabLinkList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; + +import { DashboardLanding } from './Dashboard/DashboardLanding'; +type Props = RouteComponentProps<{}>; + +export const CloudPulseTabs = React.memo((props: Props) => { + const tabs = [ + { + routeName: `${props.match.url}/dashboards`, + title: 'Dashboards', + }, + ]; + + const matches = (p: string) => { + return Boolean(matchPath(p, { path: props.location.pathname })); + }; + + const navToURL = (index: number) => { + props.history.push(tabs[index].routeName); + }; + + return ( + matches(tab.routeName)), + 0 + )} + onChange={navToURL} + > + + + }> + + + + + + + + ); +}); + +const StyledTabs = styled(Tabs, { + label: 'StyledTabs', +})(() => ({ + marginTop: 0, +})); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx new file mode 100644 index 00000000000..7cc07dc8cb9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx @@ -0,0 +1,18 @@ +import { Paper } from '@mui/material'; +import * as React from 'react'; + +import { FiltersObject, GlobalFilters } from '../Overview/GlobalFilters'; + +export const DashboardLanding = () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const onFilterChange = (_filters: FiltersObject) => {}; + return ( + +
    +
    + +
    +
    +
    + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx new file mode 100644 index 00000000000..a91ff4e6259 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -0,0 +1,147 @@ +import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; +import * as React from 'react'; + +import { CloudPulseRegionSelect } from '../shared/CloudPulseRegionSelect'; +import { CloudPulseResourcesSelect } from '../shared/CloudPulseResourcesSelect'; +import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; + +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { WithStartAndEnd } from 'src/features/Longview/request.types'; +import { Dashboard } from '@linode/api-v4'; +import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; + +export interface GlobalFilterProperties { + handleAnyFilterChange(filters: FiltersObject): undefined | void; +} + +export interface FiltersObject { + interval: string; + region: string; + resource: string[]; + serviceType?: string; + timeRange: WithStartAndEnd; +} + +export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { + const [time, setTimeBox] = React.useState({ + end: 0, + start: 0, + }); + + const [selectedDashboard, setSelectedDashboard] = React.useState< + Dashboard | undefined + >(); + 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 = { + interval: '', + region: '', + resource: [], + timeRange: time, + }; + if (selectedRegion) { + globalFilters.region = selectedRegion; + } + props.handleAnyFilterChange(globalFilters); + }; + triggerGlobalFilterChange(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [time, selectedRegion]); // if anything changes, emit an event to parent component + + const handleTimeRangeChange = React.useCallback( + (start: number, end: number) => { + setTimeBox({ end, start }); + }, + [] + ); + + const handleRegionChange = React.useCallback((region: string | undefined) => { + setRegion(region); + }, []); + + const handleResourcesSelection = React.useCallback( + (resources: CloudPulseResources[]) => { + setResources(resources); + }, + [] + ); + + const handleDashboardChange = React.useCallback( + (dashboard: Dashboard | undefined) => { + setSelectedDashboard(dashboard); + setRegion(undefined); + }, + [] + ); + + return ( + + + + + + + + + + + + + + + + + + ); +}); + +const StyledCloudPulseRegionSelect = styled(CloudPulseRegionSelect, { + label: 'StyledCloudPulseRegionSelect', +})({ + width: 150, +}); + +const StyledCloudPulseTimeRangeSelect = styled(CloudPulseTimeRangeSelect, { + label: 'StyledCloudPulseTimeRangeSelect', +})({ + width: 150, +}); + +const StyledCloudPulseResourcesSelect = styled(CloudPulseResourcesSelect, { + label: 'StyledCloudPulseResourcesSelect', +})({ + width: 250, +}); + +const StyledGrid = styled(Grid, { label: 'StyledGrid' })(({ theme }) => ({ + alignItems: 'end', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'row', + justifyContent: 'start', + marginBottom: theme.spacing(1.25), +})); + +const itemSpacing = { + boxSizing: 'border-box', + margin: '0', +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx new file mode 100644 index 00000000000..18f7880ca7f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx @@ -0,0 +1,72 @@ +import { renderWithTheme } from 'src/utilities/testHelpers'; +import { + CloudPulseDashboardSelect, + CloudPulseDashboardSelectProps, +} from './CloudPulseDashboardSelect'; +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; + +const props: CloudPulseDashboardSelectProps = { + handleDashboardChange: vi.fn(), +}; + +const queryMocks = vi.hoisted(() => ({ + useCloudViewDashboardsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/dashboards', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/dashboards'); + return { + ...actual, + useCloudViewDashboardsQuery: queryMocks.useCloudViewDashboardsQuery, + }; +}); + +queryMocks.useCloudViewDashboardsQuery.mockReturnValue({ + data: { + data: [ + { + id: 1, + type: 'standard', + service_type: 'linode', + label: 'Dashboard 1', + created: '2024-04-29T17:09:29', + updated: null, + widgets: {}, + }, + ], + }, + isLoading: false, + error: false, +}); + +describe('CloudPulse Dashboard select', () => { + it('Should render dashboard select component', () => { + const { getByTestId, getByPlaceholderText } = renderWithTheme( + + ); + + expect(getByTestId('cloudview-dashboard-select')).toBeInTheDocument(); + expect(getByPlaceholderText('Select a Dashboard')).toBeInTheDocument(); + }), + it('Should render dashboard select component with data', () => { + renderWithTheme(); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + expect( + screen.getByRole('option', { name: 'Dashboard 1' }) + ).toBeInTheDocument(); + }), + it('Should select the option on click', () => { + renderWithTheme(); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: 'Dashboard 1' })); + + expect(screen.getByRole('combobox')).toHaveAttribute( + 'value', + 'Dashboard 1' + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx new file mode 100644 index 00000000000..ebf47d3b74e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import { Dashboard } from '@linode/api-v4'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Box } from 'src/components/Box'; +import { Typography } from 'src/components/Typography'; +import { useCloudViewDashboardsQuery } from 'src/queries/cloudpulse/dashboards'; + +export interface CloudPulseDashboardSelectProps { + handleDashboardChange: (dashboard: Dashboard | undefined) => void; +} + +export const CloudPulseDashboardSelect = React.memo( + (props: CloudPulseDashboardSelectProps) => { + const { + data: dashboardsList, + error, + isLoading, + } = useCloudViewDashboardsQuery(true); //Fetch the list of dashboards + + const errorText: string = error ? 'Error loading dashboards' : ''; + + const placeHolder = 'Select a Dashboard'; + + // sorts dashboards by service type. Required due to unexpected autocomplete grouping behaviour + const getSortedDashboardsList = (options: Dashboard[]) => { + return options.sort( + (a, b) => -b.service_type.localeCompare(a.service_type) + ); + }; + + if (!dashboardsList) { + return ( + {}} + data-testid="cloudview-dashboard-select" + placeholder={placeHolder} + errorText={errorText} + /> + ); + } + + return ( + { + props.handleDashboardChange(dashboard); + }} + options={getSortedDashboardsList(dashboardsList.data)} + renderGroup={(params) => ( + + + {params.group} + + {params.children} + + )} + autoHighlight + clearOnBlur + data-testid="cloudview-dashboard-select" + errorText={errorText} + fullWidth + groupBy={(option: Dashboard) => option.service_type} + isOptionEqualToValue={(option, value) => option.label === value.label} + label="" + loading={isLoading} + noMarginTop + placeholder={placeHolder} + /> + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx new file mode 100644 index 00000000000..ea920629b82 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; +import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; + +const props: CloudPulseRegionSelectProps = { + handleRegionChange: vi.fn(), + selectedDashboard: undefined, + selectedRegion: undefined, +}; + +describe('CloudViewRegionSelect', () => { + it('should render a Region Select component', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('region-select')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx new file mode 100644 index 00000000000..1f45fb150b7 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -0,0 +1,32 @@ +/* eslint-disable no-console */ +import { Dashboard } from '@linode/api-v4'; +import * as React from 'react'; + +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +export interface CloudPulseRegionSelectProps { + handleRegionChange: (region: string | undefined) => void; + selectedDashboard: Dashboard | undefined; + selectedRegion: string | undefined; +} + +export const CloudPulseRegionSelect = React.memo( + (props: CloudPulseRegionSelectProps) => { + const { data: regions } = useRegionsQuery(); + + return ( + props.handleRegionChange(region?.id)} + regions={regions ? regions : []} + disabled={!props.selectedDashboard} + value={props.selectedRegion} + /> + ); + } +); 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..26c797e231f --- /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-1', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: 'linode-2', + }) + ).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-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-4', + }) + ).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-5', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: 'linode-6', + }) + ).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-7' })); + fireEvent.click(screen.getByRole('option', { name: 'linode-8' })); + + expect( + screen.getByRole('option', { + name: 'linode-7', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-8', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-9', + }) + ).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/CloudPulseTimeRangeSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx new file mode 100644 index 00000000000..5d6bc306e72 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx @@ -0,0 +1,49 @@ +import { DateTime } from 'luxon'; + +import { generateStartTime } from './CloudPulseTimeRangeSelect'; + +describe('Utility Functions', () => { + it('should create values as functions that return the correct datetime', () => { + const GMT_november_20_2019_849PM = 1574282998; + + expect( + generateStartTime('Past 30 Minutes', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ minutes: 30 }) + .toSeconds() + ); + + expect( + generateStartTime('Past 12 Hours', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ hours: 12 }) + .toSeconds() + ); + + expect( + generateStartTime('Past 24 Hours', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ hours: 24 }) + .toSeconds() + ); + + expect( + generateStartTime('Past 7 Days', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ days: 7 }) + .toSeconds() + ); + + expect( + generateStartTime('Past 30 Days', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ hours: 24 * 30 }) + .toSeconds() + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx new file mode 100644 index 00000000000..8a7d8740592 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; + +import Select, { + BaseSelectProps, + Item, +} from 'src/components/EnhancedSelect/Select'; + +interface Props + extends Omit< + BaseSelectProps, false>, + 'defaultValue' | 'onChange' + > { + defaultValue?: Labels; + handleStatsChange?: (start: number, end: number) => void; +} + +const PAST_7_DAYS = 'Past 7 Days'; +const PAST_12_HOURS = 'Past 12 Hours'; +const PAST_24_HOURS = 'Past 24 Hours'; +const PAST_30_DAYS = 'Past 30 Days'; +const PAST_30_MINUTES = 'Past 30 Minutes'; +export type Labels = + | 'Past 7 Days' + | 'Past 12 Hours' + | 'Past 24 Hours' + | 'Past 30 Days' + | 'Past 30 Minutes'; + +export const CloudPulseTimeRangeSelect = React.memo((props: Props) => { + const { defaultValue, handleStatsChange, ...restOfSelectProps } = props; + + /* + the time range is the label instead of the value because it's a lot harder + to keep Date.now() consistent with this state. We can get the actual + values when it comes time to make the request. + + Use the value from user preferences if available, then fall back to + the default that was passed to the component, and use Past 30 Minutes + if all else fails. + + @todo Validation here to make sure that the value from user preferences + is a valid time window. + */ + const [selectedTimeRange, setTimeRange] = React.useState( + PAST_30_MINUTES + ); + + /* + Why division by 1000? + + Because the LongView API doesn't expect the start and date time + to the nearest millisecond - if you send anything more than 10 digits + you won't get any data back + */ + const nowInSeconds = Date.now() / 1000; + + React.useEffect(() => { + // Do the math and send start/end values to the consumer + // (in most cases the consumer has passed defaultValue={'last 30 minutes'} + // but the calcs to turn that into start/end numbers live here) + if (!!handleStatsChange) { + handleStatsChange( + Math.round(generateStartTime(selectedTimeRange, nowInSeconds)), + Math.round(nowInSeconds) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTimeRange]); + + const options = generateSelectOptions(); + + const handleChange = (item: Item) => { + setTimeRange(item.value); + }; + + return ( +
    - - - Status - Message - - - - {Object.keys(statuses).map((status, key) => { - const messageCreator = statuses[status]; - - let message = messageCreator(event); - message = applyBolding(message); - message = formatEventWithUsername( - event.action, - event.username, - message - ); - // eslint-disable-next-line xss/no-mixed-html - message = unsafe_MarkdownIt.render(message); - message = applyLinking(event, message); - - return ( - - - {' '} - - - - - - ); - })} - -
    - - ))} - - ); -}; - -export const HardCodedMessages: StoryObj = { - render: () => renderEventMessages(eventMessageCreators), -}; - -const customizableEvent: Event = eventFactory.build(); - -export const EventPlayground: StoryObj = { - argTypes: { - action: { - control: 'select', - options: EVENT_ACTIONS, - }, - status: { - control: 'select', - options: EVENT_STATUSES, - }, - }, - args: { - ...customizableEvent, - }, - render: (args) => ( - - ), -}; - -/** - * This renderer only loops through hard coded messages defined in `eventMessageCreators`. - * This means that it will not render messages coming straight from the API and therefore - * isn't an exhaustive list of all possible events. - * - * However a playground is available to generate message from a custom Event for testing purposes - */ - -const meta: Meta = { - args: {}, - title: 'Features/Events', -}; - -export default meta; diff --git a/packages/manager/src/features/Events/EventsLanding.styles.ts b/packages/manager/src/features/Events/EventsLanding.styles.ts index b4e5cf2e6dc..f0b853ebc4b 100644 --- a/packages/manager/src/features/Events/EventsLanding.styles.ts +++ b/packages/manager/src/features/Events/EventsLanding.styles.ts @@ -21,9 +21,9 @@ export const StyledLabelTableCell = styled(TableCell, { minWidth: 200, paddingLeft: 10, [theme.breakpoints.down('sm')]: { - width: '70%', + width: 'calc(100% - 250px)', }, - width: '60%', + width: 'calc(100% - 400px)', })); export const StyledH1Header = styled(H1Header, { diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 7486341c5cb..0caf895222f 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -10,9 +10,12 @@ 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'; import { EventRow } from './EventRow'; +import { EventRowV2 } from './EventRowV2'; import { StyledH1Header, StyledLabelTableCell, @@ -20,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; @@ -29,8 +30,9 @@ interface Props { 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; @@ -67,13 +69,21 @@ export const EventsLanding = (props: Props) => { } else { return ( <> - {events?.map((event) => ( - - ))} + {events?.map((event) => + flags.eventMessagesV2 ? ( + + ) : ( + + ) + )} {isFetchingNextPage && ( { - - - + {!flags.eventMessagesV2 && ( + + + + )} Event - Relative Date + {flags.eventMessagesV2 && ( + + + User + + + )} + Relative Date - + Absolute Date diff --git a/packages/manager/src/features/Events/EventsMessages.stories.tsx b/packages/manager/src/features/Events/EventsMessages.stories.tsx new file mode 100644 index 00000000000..72325c69c1f --- /dev/null +++ b/packages/manager/src/features/Events/EventsMessages.stories.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { Chip } from 'src/components/Chip'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { Typography } from 'src/components/Typography'; +import { eventFactory } from 'src/factories/events'; +import { eventMessages } from 'src/features/Events/factory'; + +import type { Event } from '@linode/api-v4/lib/account'; +import type { Meta, StoryObj } from '@storybook/react'; + +const event: Event = eventFactory.build({ + action: 'linode_boot', + entity: { + id: 1, + label: '{entity}', + type: 'linode', + url: 'https://google.com', + }, + message: 'message with a `ticked` word', + secondary_entity: { + id: 1, + label: '{secondary entity}', + type: 'linode', + url: 'https://google.com', + }, + status: '{status}' as Event['status'], + username: '{username}', +}); + +/** + * This story loops through all the known event messages keys, and displays their Cloud Manager message in a table. + */ +export const EventMessages: StoryObj = { + render: () => ( +
    + {Object.entries(eventMessages).map(([eventKey, statuses]) => ( +
    + + {eventKey} + +
    + + + Status + Message + + + + {Object.keys(statuses).map((status, key) => { + const message = statuses[status](event); + + return ( + + + + + + {message} + + + ); + })} + +
    + + ))} + + ), +}; + +const meta: Meta = { + args: {}, + title: 'Features/Event Messages', +}; + +export default meta; diff --git a/packages/manager/src/features/Events/constants.ts b/packages/manager/src/features/Events/constants.ts index afc1092c862..ad07694713c 100644 --- a/packages/manager/src/features/Events/constants.ts +++ b/packages/manager/src/features/Events/constants.ts @@ -1,4 +1,5 @@ -import type { Event } from '@linode/api-v4/lib/account'; +// TODO eventMessagesV2: delete when flag is removed +import type { Event } from '@linode/api-v4'; export const EVENT_ACTIONS: Event['action'][] = [ 'account_settings_update', @@ -14,10 +15,10 @@ export const EVENT_ACTIONS: Event['action'][] = [ 'database_create', 'database_credentials_reset', 'database_delete', + 'database_resize_create', + 'database_resize', 'database_update_failed', 'database_update', - 'database_resize', - 'database_resize_create', 'disk_create', 'disk_delete', 'disk_duplicate', @@ -81,12 +82,12 @@ export const EVENT_ACTIONS: Event['action'][] = [ 'nodebalancer_update', 'password_reset', 'placement_group_assign', - 'placement_group_became_non_compliant', 'placement_group_became_compliant', + 'placement_group_became_non_compliant', 'placement_group_create', + 'placement_group_delete', 'placement_group_unassign', 'placement_group_update', - 'placement_group_delete', 'profile_update', 'stackscript_create', 'stackscript_delete', @@ -96,6 +97,7 @@ export const EVENT_ACTIONS: Event['action'][] = [ 'subnet_create', 'subnet_delete', 'subnet_update', + 'tax_id_invalid', 'tfa_disabled', 'tfa_enabled', 'ticket_attachment_upload', @@ -127,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/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index 52695612984..95d06003d74 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import { Event } from '@linode/api-v4/lib/account'; import { path } from 'ramda'; @@ -608,37 +609,23 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { started: (e) => `Linode ${e.entity?.label ?? ''} is being booted (Lish initiated boot).`, }, - lke_node_create: { - // This event is a special case; a notification means the node creation failed. - // The entity is the node pool, but entity.label contains the cluster's label. - notification: (e) => - `Failed to create a node on Kubernetes Cluster${ - e.entity?.label ? ` ${e.entity.label}` : '' - }.`, - }, - lke_node_recycle: { - notification: (e) => - `The node for Kubernetes Cluster${ - e.entity?.label ? ` ${e.entity.label}` : '' - } has been recycled.`, - }, lke_cluster_create: { notification: (e) => `Kubernetes Cluster${ e.entity?.label ? ` ${e.entity.label}` : '' } has been created.`, }, - lke_cluster_update: { + lke_cluster_delete: { notification: (e) => `Kubernetes Cluster${ e.entity?.label ? ` ${e.entity.label}` : '' - } has been updated.`, + } has been deleted.`, }, - lke_cluster_delete: { + lke_cluster_recycle: { notification: (e) => `Kubernetes Cluster${ e.entity?.label ? ` ${e.entity.label}` : '' - } has been deleted.`, + } has been recycled.`, }, lke_cluster_regenerate: { notification: (e) => @@ -646,11 +633,11 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { e.entity?.label ? ` ${e.entity.label}` : '' } has been regenerated.`, }, - lke_cluster_recycle: { + lke_cluster_update: { notification: (e) => `Kubernetes Cluster${ e.entity?.label ? ` ${e.entity.label}` : '' - } has been recycled.`, + } has been updated.`, }, lke_control_plane_acl_create: { notification: (e) => @@ -658,17 +645,17 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { e.entity?.label ? ` ${e.entity.label}` : '' } has been created.`, }, - lke_control_plane_acl_update: { + lke_control_plane_acl_delete: { notification: (e) => `The IP ACL for Kubernetes Cluster${ e.entity?.label ? ` ${e.entity.label}` : '' - } has been updated.`, + } has been disabled.`, }, - lke_control_plane_acl_delete: { + lke_control_plane_acl_update: { notification: (e) => `The IP ACL for Kubernetes Cluster${ e.entity?.label ? ` ${e.entity.label}` : '' - } has been disabled.`, + } has been updated.`, }, lke_kubeconfig_regenerate: { notification: (e) => @@ -676,11 +663,19 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { e.entity?.label ? ` ${e.entity.label}` : '' } has been regenerated.`, }, - lke_token_rotate: { + lke_node_create: { + // This event is a special case; a notification means the node creation failed. + // The entity is the node pool, but entity.label contains the cluster's label. notification: (e) => - `The token for Kubernetes Cluster${ + `Failed to create a node on Kubernetes Cluster${ e.entity?.label ? ` ${e.entity.label}` : '' - } has been rotated.`, + }.`, + }, + lke_node_recycle: { + notification: (e) => + `The node for Kubernetes Cluster${ + e.entity?.label ? ` ${e.entity.label}` : '' + } has been recycled.`, }, lke_pool_create: { notification: (e) => @@ -700,6 +695,12 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { e.entity?.label ? ` ${e.entity.label}` : '' } has been recycled.`, }, + lke_token_rotate: { + notification: (e) => + `The token for Kubernetes Cluster${ + e.entity?.label ? ` ${e.entity.label}` : '' + } has been rotated.`, + }, longviewclient_create: { notification: (e) => `Longview Client ${e.entity!.label} has been created.`, }, @@ -861,6 +862,9 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { tag_delete: { notification: (e) => `Tag ${e.entity!.label} has been deleted.`, }, + tax_id_invalid: { + notification: (e) => `Tax Identification Number format is invalid.`, + }, tfa_disabled: { notification: (e) => `Two-factor authentication has been disabled.`, }, diff --git a/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx b/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx index e50fae1b63e..c5001671c03 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx +++ b/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import { Event } from '@linode/api-v4/lib/account'; import { Linode } from '@linode/api-v4/lib/linodes'; import { Region } from '@linode/api-v4/lib/regions'; diff --git a/packages/manager/src/features/Events/factories/account.tsx b/packages/manager/src/features/Events/factories/account.tsx new file mode 100644 index 00000000000..d280e255480 --- /dev/null +++ b/packages/manager/src/features/Events/factories/account.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const account: PartialEventMap<'account'> = { + account_agreement_eu_model: { + notification: () => ( + <> + The EU Model Contract has been signed. + + ), + }, + account_promo_apply: { + notification: () => ( + <> + A promo code was applied to your account. + + ), + }, + account_settings_update: { + notification: () => ( + <> + Your account settings have been updated. + + ), + }, + account_update: { + notification: () => ( + <> + Your account has been updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/backup.tsx b/packages/manager/src/features/Events/factories/backup.tsx new file mode 100644 index 00000000000..31344a45197 --- /dev/null +++ b/packages/manager/src/features/Events/factories/backup.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; + +import { Link } from 'src/components/Link'; + +import type { PartialEventMap } from '../types'; + +export const backup: PartialEventMap<'backups'> = { + backups_cancel: { + notification: (e) => ( + <> + Backups have been canceled for {e.entity!.label}. + + ), + }, + backups_enable: { + notification: (e) => ( + <> + Backups have been enabled for {e.entity!.label}. + + ), + }, + backups_restore: { + failed: (e) => ( + <> + Backup could not be restored for + {e.entity!.label}.{' '} + + Learn more about limits and considerations. + + + ), + finished: (e) => ( + <> + Backup restoration completed for {e.entity!.label}. + + ), + notification: (e) => ( + <> + Backup restoration completed for {e.entity!.label}. + + ), + scheduled: (e) => ( + <> + Backup restoration scheduled for {e.entity!.label}. + + ), + started: (e) => ( + <> + Backup restoration started for {e.entity!.label}. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/community.tsx b/packages/manager/src/features/Events/factories/community.tsx new file mode 100644 index 00000000000..acffa8ecb0b --- /dev/null +++ b/packages/manager/src/features/Events/factories/community.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const community: PartialEventMap<'community'> = { + community_like: { + notification: (e) => + e.entity?.label ? ( + <> + A post on has been{' '} + liked. + + ) : ( + <> + There has been a like on your community post. + + ), + }, + community_mention: { + notification: (e) => + e.entity?.label ? ( + <> + You have been mentioned in a post on{' '} + . + + ) : ( + <> + You have been mentioned in a community post. + + ), + }, + community_question_reply: { + notification: (e) => + e.entity?.label ? ( + <> + A reply has been posted to your question on{' '} + . + + ) : ( + <> + A reply has been posted to your question. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/credit.tsx b/packages/manager/src/features/Events/factories/credit.tsx new file mode 100644 index 00000000000..82323752152 --- /dev/null +++ b/packages/manager/src/features/Events/factories/credit.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const creditCard: PartialEventMap<'credit'> = { + credit_card_updated: { + notification: (e) => ( + <> + Your credit card information has been updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/database.tsx b/packages/manager/src/features/Events/factories/database.tsx new file mode 100644 index 00000000000..b34114cc9e8 --- /dev/null +++ b/packages/manager/src/features/Events/factories/database.tsx @@ -0,0 +1,192 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const database: PartialEventMap<'database'> = { + database_backup_create: { + notification: (e) => ( + <> + Database backup has been{' '} + created. + + ), + }, + database_backup_delete: { + notification: (e) => ( + <> + Database backup {e.entity?.label} has been deleted. + + ), + }, + database_backup_restore: { + notification: (e) => ( + <> + Database has been{' '} + restored from a backup. + + ), + }, + database_create: { + failed: (e) => ( + <> + Database could not{' '} + be created. + + ), + finished: (e) => ( + <> + Database has been{' '} + created. + + ), + notification: (e) => ( + <> + Database has been{' '} + created. + + ), + scheduled: (e) => ( + <> + Database is scheduled for{' '} + creation. + + ), + started: (e) => ( + <> + Database is being{' '} + created. + + ), + }, + database_credentials_reset: { + notification: (e) => ( + <> + Database credentials have been{' '} + reset. + + ), + }, + database_degraded: { + notification: (e) => ( + <> + Database has been{' '} + degraded. + + ), + }, + database_delete: { + notification: (e) => ( + <> + Database {e.entity?.label} has been deleted. + + ), + }, + database_failed: { + notification: (e) => ( + <> + Database could not{' '} + be updated. + + ), + }, + database_low_disk_space: { + finished: (e) => ( + <> + Low disk space alert for database {' '} + has cleared. + + ), + + notification: (e) => ( + <> + Database has{' '} + low disk space. + + ), + }, + database_resize: { + failed: (e) => ( + <> + Database could not{' '} + be resized. + + ), + finished: (e) => ( + <> + Database has been{' '} + resized. + + ), + scheduled: (e) => ( + <> + Database is scheduled for{' '} + resizing. + + ), + started: (e) => ( + <> + Database is{' '} + resizing. + + ), + }, + database_resize_create: { + notification: (e) => ( + <> + Database scheduled to be{' '} + resized. + + ), + }, + database_scale: { + failed: (e) => ( + <> + Database could not{' '} + be resized. + + ), + finished: (e) => ( + <> + Database has been{' '} + resized. + + ), + scheduled: (e) => ( + <> + Database is scheduled for{' '} + resizing. + + ), + started: (e) => ( + <> + Database is{' '} + resizing. + + ), + }, + database_update: { + finished: (e) => ( + <> + Database has been{' '} + updated. + + ), + }, + database_update_failed: { + notification: (e) => ( + <> + Database could not{' '} + be updated. + + ), + }, + database_upgrade: { + notification: (e) => ( + <> + Database {e.entity?.label} has been upgraded. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/disk.tsx b/packages/manager/src/features/Events/factories/disk.tsx new file mode 100644 index 00000000000..8168cdd76e4 --- /dev/null +++ b/packages/manager/src/features/Events/factories/disk.tsx @@ -0,0 +1,182 @@ +import * as React from 'react'; + +import { Link } from 'src/components/Link'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const disk: PartialEventMap<'disk'> = { + disk_create: { + failed: (e) => ( + <> + Disk could{' '} + not be added to Linode{' '} + . + + ), + finished: (e) => ( + <> + Disk has been{' '} + added to Linode . + + ), + notification: (e) => ( + <> + Disk has been{' '} + added to Linode . + + ), + scheduled: (e) => ( + <> + Disk is being{' '} + added to Linode . + + ), + started: (e) => ( + <> + Disk is being{' '} + added to . + + ), + }, + disk_delete: { + failed: (e) => ( + <> + Disk could{' '} + not be deleted on Linode{' '} + . + + ), + finished: (e) => ( + <> + Disk {e.secondary_entity?.label} on Linode{' '} + has been deleted. + + ), + notification: (e) => ( + <> + Disk {e.secondary_entity?.label} on Linode{' '} + has been deleted. + + ), + scheduled: (e) => ( + <> + Disk on Linode{' '} + is scheduled for deletion. + + ), + started: (e) => ( + <> + Disk on Linode{' '} + is being deleted. + + ), + }, + disk_duplicate: { + failed: (e) => ( + <> + Disk on Linode could{' '} + not be duplicated. + + ), + finished: (e) => ( + <> + Disk on Linode has been{' '} + duplicated. + + ), + notification: (e) => ( + <> + Disk on Linode has been{' '} + duplicated. + + ), + scheduled: (e) => ( + <> + Disk on Linode is scheduled to be{' '} + duplicated. + + ), + started: (e) => ( + <> + Disk on Linode is being{' '} + duplicated. + + ), + }, + disk_imagize: { + failed: (e) => ( + <> + Image could{' '} + not be created.{' '} + + Learn more about image technical specifications. + + . + + ), + finished: (e) => ( + <> + Image has been{' '} + created. + + ), + scheduled: (e) => ( + <> + Image is scheduled to be{' '} + created. + + ), + started: (e) => ( + <> + Image is being{' '} + created. + + ), + }, + disk_resize: { + failed: (e) => ( + <> + A disk on Linode could{' '} + not be resized.{' '} + + Learn more + + + ), + + finished: (e) => ( + <> + A disk on Linode has been{' '} + resized. + + ), + notification: (e) => ( + <> + A disk on Linode has been{' '} + resized. + + ), + scheduled: (e) => ( + <> + A disk on Linode is scheduled to be{' '} + resized. + + ), + started: (e) => ( + <> + A disk on Linode is being{' '} + resized. + + ), + }, + disk_update: { + notification: (e) => ( + <> + Disk has been{' '} + updated on Linode . + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/dns.tsx b/packages/manager/src/features/Events/factories/dns.tsx new file mode 100644 index 00000000000..88beb24a11c --- /dev/null +++ b/packages/manager/src/features/Events/factories/dns.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const dns: PartialEventMap<'dns'> = { + dns_record_create: { + notification: (e) => ( + <> + DNS record has been added to{' '} + . + + ), + }, + dns_record_delete: { + notification: (e) => ( + <> + DNS record has been removed from{' '} + . + + ), + }, + dns_zone_create: { + notification: (e) => ( + <> + DNS zone has been added to{' '} + . + + ), + }, + dns_zone_delete: { + notification: (e) => ( + <> + DNS zone has been removed from{' '} + . + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/domain.tsx b/packages/manager/src/features/Events/factories/domain.tsx new file mode 100644 index 00000000000..c6b02034ca3 --- /dev/null +++ b/packages/manager/src/features/Events/factories/domain.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; +import { EventMessage } from '../EventMessage'; + +import type { PartialEventMap } from '../types'; + +export const domain: PartialEventMap<'domain'> = { + domain_create: { + notification: (e) => ( + <> + Domain has been{' '} + created. + + ), + }, + domain_delete: { + notification: (e) => ( + <> + Domain {e.entity?.label} has been deleted. + + ), + }, + domain_import: { + notification: (e) => ( + <> + Domain has been{' '} + imported. + + ), + }, + domain_record_create: { + notification: (e) => ( + <> + has been added to{' '} + . + + ), + }, + domain_record_delete: { + notification: (e) => ( + <> + A domain record has been deleted from{' '} + . + + ), + }, + domain_record_update: { + notification: (e) => ( + <> + has been updated{' '} + for . + + ), + }, + domain_record_updated: { + notification: (e) => ( + <> + has been updated{' '} + for . + + ), + }, + domain_update: { + notification: (e) => ( + <> + Domain has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/entity.tsx b/packages/manager/src/features/Events/factories/entity.tsx new file mode 100644 index 00000000000..33b9afbc842 --- /dev/null +++ b/packages/manager/src/features/Events/factories/entity.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const entity: PartialEventMap<'entity'> = { + entity_transfer_accept: { + notification: () => ( + <> + A service transfer has been accepted. + + ), + }, + entity_transfer_accept_recipient: { + notification: () => ( + <> + You have accepted a service transfer. + + ), + }, + entity_transfer_cancel: { + notification: () => ( + <> + A service transfer has been canceled. + + ), + }, + entity_transfer_create: { + notification: () => ( + <> + A service transfer has been created. + + ), + }, + entity_transfer_fail: { + notification: () => ( + <> + A service transfer could not be{' '} + created. + + ), + }, + entity_transfer_stale: { + notification: () => ( + <> + A service transfer token has expired. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx new file mode 100644 index 00000000000..86f180fc1d9 --- /dev/null +++ b/packages/manager/src/features/Events/factories/firewall.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + +const secondaryFirewallEntityNameMap: Record< + FirewallDeviceEntityType, + string +> = { + linode: 'Linode', + nodebalancer: 'NodeBalancer', +}; + +export const firewall: PartialEventMap<'firewall'> = { + firewall_apply: { + notification: (e) => ( + <> + Firewall has been{' '} + applied. + + ), + }, + firewall_create: { + notification: (e) => ( + <> + Firewall has been{' '} + created. + + ), + }, + firewall_delete: { + notification: (e) => ( + <> + Firewall {e.entity?.label} has been deleted. + + ), + }, + firewall_device_add: { + notification: (e) => { + if (e.secondary_entity?.type) { + const secondaryEntityName = + secondaryFirewallEntityNameMap[e.secondary_entity.type]; + return ( + <> + {secondaryEntityName} {' '} + has been added to Firewall{' '} + . + + ); + } + return ( + <> + A device has been added to Firewall{' '} + . + + ); + }, + }, + firewall_device_remove: { + notification: (e) => { + if (e.secondary_entity?.type) { + const secondaryEntityName = + secondaryFirewallEntityNameMap[e.secondary_entity.type]; + return ( + <> + {secondaryEntityName} {' '} + has been removed from Firewall{' '} + . + + ); + } + return ( + <> + A device has been removed from Firewall{' '} + . + + ); + }, + }, + firewall_disable: { + notification: (e) => ( + <> + Firewall has been{' '} + disabled. + + ), + }, + firewall_enable: { + notification: (e) => ( + <> + Firewall has been{' '} + enabled. + + ), + }, + firewall_rules_update: { + notification: (e) => ( + <> + Firewall rules have been updated on{' '} + . + + ), + }, + firewall_update: { + notification: (e) => ( + <> + Firewall has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/host.tsx b/packages/manager/src/features/Events/factories/host.tsx new file mode 100644 index 00000000000..97b81ed9748 --- /dev/null +++ b/packages/manager/src/features/Events/factories/host.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const host: PartialEventMap<'host'> = { + host_reboot: { + failed: (e) => ( + <> + Linode could not be{' '} + booted (Host initiated restart). + + ), + finished: (e) => ( + <> + Linode has been{' '} + booted (Host initiated restart). + + ), + + scheduled: (e) => ( + <> + Linode is scheduled for a{' '} + reboot (Host initiated restart). + + ), + + started: (e) => ( + <> + Linode is being{' '} + booted (Host initiated restart). + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/image.tsx b/packages/manager/src/features/Events/factories/image.tsx new file mode 100644 index 00000000000..dbe4ef39cf2 --- /dev/null +++ b/packages/manager/src/features/Events/factories/image.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const image: PartialEventMap<'image'> = { + image_delete: { + failed: (e) => ( + <> + Image could not be{' '} + deleted. + + ), + finished: (e) => ( + <> + Image {e.entity?.label} has been deleted. + + ), + notification: (e) => ( + <> + Image {e.entity?.label} has been deleted. + + ), + scheduled: (e) => ( + <> + Image is scheduled to be{' '} + deleted. + + ), + started: (e) => ( + <> + Image is being{' '} + deleted. + + ), + }, + image_update: { + notification: (e) => ( + <> + Image has been{' '} + updated. + + ), + }, + image_upload: { + failed: (e) => ( + <> + Image could not be{' '} + uploaded: {e?.message?.replace(/(\d+)/g, '$1 MB')}. + + ), + + finished: (e) => ( + <> + Image has been{' '} + uploaded. + + ), + notification: (e) => ( + <> + Image has been{' '} + uploaded. + + ), + scheduled: (e) => ( + <> + Image is scheduled for{' '} + upload. + + ), + started: (e) => ( + <> + Image is being{' '} + uploaded. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/index.ts b/packages/manager/src/features/Events/factories/index.ts new file mode 100644 index 00000000000..77b7a8d85f6 --- /dev/null +++ b/packages/manager/src/features/Events/factories/index.ts @@ -0,0 +1,38 @@ +export * from './account'; +export * from './backup'; +export * from './community'; +export * from './credit'; +export * from './database'; +export * from './disk'; +export * from './dns'; +export * from './domain'; +export * from './entity'; +export * from './firewall'; +export * from './host'; +export * from './image'; +export * from './ipaddress'; +export * from './ipv6pool'; +export * from './lassie'; +export * from './linode'; +export * from './lish'; +export * from './lke'; +export * from './longviewclient'; +export * from './managed'; +export * from './nodebalancer'; +export * from './oAuth'; +export * from './obj'; +export * from './password'; +export * from './payment'; +export * from './placement'; +export * from './profile'; +export * from './reserved'; +export * from './stackscript'; +export * from './subnet'; +export * from './tag'; +export * from './tax'; +export * from './tfa'; +export * from './ticket'; +export * from './token'; +export * from './user'; +export * from './volume'; +export * from './vpc'; diff --git a/packages/manager/src/features/Events/factories/ipaddress.tsx b/packages/manager/src/features/Events/factories/ipaddress.tsx new file mode 100644 index 00000000000..c4d1e973536 --- /dev/null +++ b/packages/manager/src/features/Events/factories/ipaddress.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const ip: PartialEventMap<'ipaddress'> = { + ipaddress_update: { + notification: () => ( + <> + An IP address has been updated on your account. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/ipv6pool.tsx b/packages/manager/src/features/Events/factories/ipv6pool.tsx new file mode 100644 index 00000000000..eab1111de04 --- /dev/null +++ b/packages/manager/src/features/Events/factories/ipv6pool.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const ipv6pool: PartialEventMap<'ipv6pool'> = { + ipv6pool_add: { + notification: () => ( + <> + An IPv6 range has been added to your account. + + ), + }, + ipv6pool_delete: { + notification: () => ( + <> + An IPv6 range has been deleted from your account. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/lassie.tsx b/packages/manager/src/features/Events/factories/lassie.tsx new file mode 100644 index 00000000000..27cd4811843 --- /dev/null +++ b/packages/manager/src/features/Events/factories/lassie.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const lassie: PartialEventMap<'lassie'> = { + lassie_reboot: { + failed: (e) => ( + <> + Linode could not be booted by the + Lassie watchdog service. + + ), + finished: (e) => ( + <> + Linode has been{' '} + booted by the Lassie watchdog service. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + rebooted by the Lassie watchdog service. + + ), + started: (e) => ( + <> + Linode is being{' '} + booted by the Lassie watchdog service. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx new file mode 100644 index 00000000000..ba13193cdf1 --- /dev/null +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -0,0 +1,581 @@ +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: { + notification: (e) => ( + <> + An IP address has been added to Linode{' '} + . + + ), + }, + linode_boot: { + failed: (e) => ( + <> + Linode could not be{' '} + booted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + booted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + booted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + started: (e) => ( + <> + Linode is being{' '} + booted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + }, + linode_clone: { + failed: (e) => ( + <> + Linode {e.entity?.label} could not be{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + notification: (e) => ( + <> + Linode has been{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + started: (e) => ( + <> + Linode is being{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + }, + linode_config_create: { + notification: (e) => ( + <> + Config has been{' '} + created on Linode . + + ), + }, + linode_config_delete: { + notification: (e) => ( + <> + Config {e.secondary_entity?.label} has been deleted on + Linode . + + ), + }, + linode_config_update: { + notification: (e) => ( + <> + Config has been{' '} + updated on Linode . + + ), + }, + linode_create: { + failed: (e) => ( + <> + Linode {e.entity!.label} could not be{' '} + created. + + ), + finished: (e) => ( + <> + Linode has been{' '} + created. + + ), + scheduled: (e) => ( + <> + Linode {e.entity!.label} is scheduled for creation. + + ), + started: (e) => ( + <> + Linode {e.entity!.label} is being created. + + ), + }, + linode_delete: { + failed: (e) => ( + <> + Linode could not be{' '} + deleted. + + ), + finished: (e) => ( + <> + Linode {e.entity?.label} has been deleted. + + ), + notification: (e) => ( + <> + Linode {e.entity?.label} has been deleted. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + deleted. + + ), + started: (e) => ( + <> + Linode is being{' '} + deleted. + + ), + }, + linode_deleteip: { + notification: (e) => ( + <> + An IP address has been removed from Linode{' '} + . + + ), + }, + linode_migrate: { + failed: (e) => ( + <> + Migration failed for Linode{' '} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + migrated. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + migrated. + + ), + started: (e) => ( + <> + Linode is being{' '} + migrated. + + ), + }, + linode_migrate_datacenter: { + failed: (e) => ( + <> + Migration failed for Linode{' '} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + migrated. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + migrated. + + ), + started: (e) => , + }, + linode_migrate_datacenter_create: { + notification: (e) => ( + <> + Migration has been initiated for Linode{' '} + . + + ), + }, + linode_mutate: { + failed: (e) => ( + <> + Linode could not be{' '} + upgraded. + + ), + finished: (e) => ( + <> + Linode has been{' '} + upgraded. + + ), + notification: (e) => ( + <> + Linode is being{' '} + upgraded. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + upgraded. + + ), + started: (e) => ( + <> + Linode is being{' '} + upgraded. + + ), + }, + linode_mutate_create: { + notification: (e) => ( + <> + A resize has been initiated for Linode{' '} + . + + ), + }, + linode_reboot: { + failed: (e) => ( + <> + Linode could not be{' '} + rebooted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + rebooted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + rebooted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + started: (e) => ( + <> + Linode is being{' '} + rebooted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + }, + linode_rebuild: { + failed: (e) => ( + <> + Linode could not be{' '} + rebuilt. + + ), + finished: (e) => ( + <> + Linode has been{' '} + rebuilt. + + ), + scheduled: (e) => ( + <> + Linode is scheduled for{' '} + rebuild. + + ), + started: (e) => ( + <> + Linode is being{' '} + rebuilt. + + ), + }, + linode_resize: { + failed: (e) => ( + <> + Linode could not be{' '} + resized. + + ), + finished: (e) => ( + <> + Linode has been{' '} + resized. + + ), + notification: (e) => ( + <> + Linode is being{' '} + resized. + + ), + scheduled: (e) => ( + <> + Linode is scheduled for{' '} + resizing. + + ), + started: (e) => , + }, + linode_resize_create: { + notification: (e) => ( + <> + A resize has been initiated for Linode{' '} + . + + ), + }, + linode_resize_warm_create: { + notification: (e) => ( + <> + A warm resize has been initiated for Linode{' '} + . + + ), + }, + linode_shutdown: { + failed: (e) => ( + <> + Linode could not be{' '} + shut down. + + ), + finished: (e) => ( + <> + Linode has been{' '} + shut down. + + ), + scheduled: (e) => ( + <> + Linode is scheduled for{' '} + shutdown. + + ), + started: (e) => ( + <> + Linode is{' '} + shutting down. + + ), + }, + linode_snapshot: { + failed: (e) => ( + <> + Snapshot backup failed on Linode{' '} + .{' '} + + Learn more about limits and considerations + + . + + ), + finished: (e) => ( + <> + A snapshot backup has been created for Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Linode is scheduled for a snapshot + backup. + + ), + started: (e) => ( + <> + A snapshot backup is being created for Linode{' '} + . + + ), + }, + linode_update: { + notification: (e) => ( + <> + Linode has been{' '} + updated. + + ), + }, +}; + +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/lish.tsx b/packages/manager/src/features/Events/factories/lish.tsx new file mode 100644 index 00000000000..6de7638b609 --- /dev/null +++ b/packages/manager/src/features/Events/factories/lish.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const lish: PartialEventMap<'lish'> = { + lish_boot: { + failed: (e) => ( + <> + Linode could not be{' '} + booted (Lish initiated boot). + + ), + finished: (e) => ( + <> + Linode has been{' '} + booted (Lish initiated boot). + + ), + scheduled: (e) => ( + <> + Linode is scheduled to{' '} + boot (Lish initiated boot). + + ), + started: (e) => ( + <> + Linode is being{' '} + booted (Lish initiated boot). + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/lke.tsx b/packages/manager/src/features/Events/factories/lke.tsx new file mode 100644 index 00000000000..7b10cf71263 --- /dev/null +++ b/packages/manager/src/features/Events/factories/lke.tsx @@ -0,0 +1,132 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const lke: PartialEventMap<'lke'> = { + lke_cluster_create: { + notification: (e) => ( + <> + Kubernetes Cluster has been{' '} + created. + + ), + }, + lke_cluster_delete: { + notification: (e) => ( + <> + Kubernetes Cluster {e.entity?.label} has been deleted. + + ), + }, + lke_cluster_recycle: { + notification: (e) => ( + <> + Kubernetes Cluster has been{' '} + recycled. + + ), + }, + lke_cluster_regenerate: { + notification: (e) => ( + <> + Kubernetes Cluster has been{' '} + regenerated. + + ), + }, + lke_cluster_update: { + notification: (e) => ( + <> + Kubernetes Cluster has been{' '} + updated. + + ), + }, + lke_control_plane_acl_create: { + notification: (e) => ( + <> + The IP ACL for Kubernetes Cluster {' '} + has been created. + + ), + }, + lke_control_plane_acl_delete: { + notification: (e) => ( + <> + The IP ACL for Kubernetes Cluster {' '} + has been disabled. + + ), + }, + lke_control_plane_acl_update: { + notification: (e) => ( + <> + The IP ACL for Kubernetes Cluster {' '} + has been updated. + + ), + }, + lke_kubeconfig_regenerate: { + notification: (e) => ( + <> + The kubeconfig for Kubernetes Cluster{' '} + has been{' '} + regenerated. + + ), + }, + lke_node_create: { + // This event is a special case; a notification means the node creation failed. + // The entity is the node pool, but entity.label contains the cluster's label. + notification: (e) => ( + <> + Kubernetes Cluster node could not be{' '} + created + {e.entity?.label ? ' on ' : ''} + . + + ), + }, + lke_node_recycle: { + notification: (e) => ( + <> + The node for Kubernetes Cluster has + been recycled. + + ), + }, + lke_pool_create: { + notification: (e) => ( + <> + A Node Pool for Kubernetes Cluster {' '} + has been created. + + ), + }, + lke_pool_delete: { + notification: (e) => ( + <> + A Node Pool for Kubernetes Cluster {' '} + has been deleted. + + ), + }, + lke_pool_recycle: { + notification: (e) => ( + <> + A Node Pool for Kubernetes Cluster {' '} + has been recycled. + + ), + }, + lke_token_rotate: { + notification: (e) => ( + <> + The token for Kubernetes Cluster has + been rotated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/longviewclient.tsx b/packages/manager/src/features/Events/factories/longviewclient.tsx new file mode 100644 index 00000000000..f2d85f69b4a --- /dev/null +++ b/packages/manager/src/features/Events/factories/longviewclient.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const longviewclient: PartialEventMap<'longviewclient'> = { + longviewclient_create: { + notification: (e) => ( + <> + Longview Client has been{' '} + created. + + ), + }, + longviewclient_delete: { + notification: (e) => ( + <> + Longview Client {e.entity?.label} has been deleted. + + ), + }, + longviewclient_update: { + notification: (e) => ( + <> + Longview Client has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/managed.tsx b/packages/manager/src/features/Events/factories/managed.tsx new file mode 100644 index 00000000000..0ee8ecd21d7 --- /dev/null +++ b/packages/manager/src/features/Events/factories/managed.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const managed: PartialEventMap<'managed'> = { + managed_enabled: { + notification: () => ( + <> + Managed has been activated on your account. + + ), + }, + managed_service_create: { + notification: (e) => ( + <> + Managed service has been{' '} + created. + + ), + }, + managed_service_delete: { + notification: (e) => ( + <> + Managed service {e.entity?.label} has been deleted. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/nodebalancer.tsx b/packages/manager/src/features/Events/factories/nodebalancer.tsx new file mode 100644 index 00000000000..5d19a0e7a41 --- /dev/null +++ b/packages/manager/src/features/Events/factories/nodebalancer.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const nodebalancer: PartialEventMap<'nodebalancer'> = { + nodebalancer_config_create: { + notification: (e) => ( + <> + A config on NodeBalancer has been{' '} + created. + + ), + }, + nodebalancer_config_delete: { + notification: (e) => ( + <> + A config on NodeBalancer has been{' '} + deleted. + + ), + }, + nodebalancer_config_update: { + notification: (e) => ( + <> + A config on NodeBalancer has been{' '} + updated. + + ), + }, + nodebalancer_create: { + notification: (e) => ( + <> + NodeBalancer has been{' '} + created. + + ), + }, + nodebalancer_delete: { + notification: (e) => ( + <> + NodeBalancer {e.entity?.label} has been deleted. + + ), + }, + nodebalancer_node_create: { + notification: (e) => ( + <> + A node on NodeBalancer has been{' '} + created. + + ), + }, + nodebalancer_node_delete: { + notification: (e) => ( + <> + A node on NodeBalancer has been{' '} + deleted. + + ), + }, + nodebalancer_node_update: { + notification: (e) => ( + <> + A node on NodeBalancer has been{' '} + updated. + + ), + }, + nodebalancer_update: { + notification: (e) => ( + <> + NodeBalancer has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/oAuth.tsx b/packages/manager/src/features/Events/factories/oAuth.tsx new file mode 100644 index 00000000000..a50e28de7f8 --- /dev/null +++ b/packages/manager/src/features/Events/factories/oAuth.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const oAuth: PartialEventMap<'oauth'> = { + oauth_client_create: { + notification: (e) => ( + <> + OAuth App has been{' '} + created. + + ), + }, + oauth_client_delete: { + notification: (e) => ( + <> + OAuth App {e.entity?.label} has been deleted. + + ), + }, + oauth_client_secret_reset: { + notification: (e) => ( + <> + Secret for OAuth App has been{' '} + reset. + + ), + }, + oauth_client_update: { + notification: (e) => ( + <> + OAuth App has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/obj.tsx b/packages/manager/src/features/Events/factories/obj.tsx new file mode 100644 index 00000000000..973d28f30f3 --- /dev/null +++ b/packages/manager/src/features/Events/factories/obj.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const obj: PartialEventMap<'obj'> = { + obj_access_key_create: { + notification: (e) => ( + <> + Access Key has been{' '} + created. + + ), + }, + obj_access_key_delete: { + notification: (e) => ( + <> + Access Key {e.entity?.label} has been deleted. + + ), + }, + obj_access_key_update: { + notification: (e) => ( + <> + Access Key has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/password.tsx b/packages/manager/src/features/Events/factories/password.tsx new file mode 100644 index 00000000000..f5cb306445c --- /dev/null +++ b/packages/manager/src/features/Events/factories/password.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const password: PartialEventMap<'password'> = { + password_reset: { + failed: (e) => ( + <> + Password for Linode could{' '} + not be reset. + + ), + finished: (e) => ( + <> + Password for Linode has been{' '} + reset. + + ), + + scheduled: (e) => ( + <> + Password for Linode has been{' '} + scheduled. + + ), + started: (e) => ( + <> + Password for Linode is being{' '} + reset. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/payment.tsx b/packages/manager/src/features/Events/factories/payment.tsx new file mode 100644 index 00000000000..cc3b4cb497c --- /dev/null +++ b/packages/manager/src/features/Events/factories/payment.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const payment: PartialEventMap<'payment'> = { + payment_method_add: { + notification: () => ( + <> + A payment method has been added. + + ), + }, + payment_submitted: { + notification: () => ( + <> + A payment has been submitted. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/placement.tsx b/packages/manager/src/features/Events/factories/placement.tsx new file mode 100644 index 00000000000..5d71894f979 --- /dev/null +++ b/packages/manager/src/features/Events/factories/placement.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const placement: PartialEventMap<'placement'> = { + placement_group_assign: { + notification: (e) => ( + <> + Linode has been{' '} + assigned to Placement Group{' '} + . + + ), + }, + placement_group_became_compliant: { + notification: (e) => ( + <> + Placement Group has become{' '} + compliant. + + ), + }, + placement_group_became_non_compliant: { + notification: (e) => ( + <> + Placement Group has become{' '} + non-compliant. + + ), + }, + placement_group_create: { + notification: (e) => ( + <> + Placement Group has been{' '} + created. + + ), + }, + placement_group_delete: { + notification: (e) => ( + <> + Placement Group {e.entity?.label} has been deleted. + + ), + }, + placement_group_unassign: { + notification: (e) => ( + <> + Linode has been{' '} + unassigned from Placement Group{' '} + . + + ), + }, + placement_group_update: { + notification: (e) => ( + <> + Placement Group has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/profile.tsx b/packages/manager/src/features/Events/factories/profile.tsx new file mode 100644 index 00000000000..84940972920 --- /dev/null +++ b/packages/manager/src/features/Events/factories/profile.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const profile: PartialEventMap<'profile'> = { + profile_update: { + notification: (e) => ( + <> + Your profile has been updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/reserved.tsx b/packages/manager/src/features/Events/factories/reserved.tsx new file mode 100644 index 00000000000..45aaa20d2e8 --- /dev/null +++ b/packages/manager/src/features/Events/factories/reserved.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const reserved: PartialEventMap<'reserved'> = { + reserved_ip_assign: { + notification: () => ( + <> + A reserved IP address has been assigned to your + account. + + ), + }, + reserved_ip_create: { + notification: () => ( + <> + A reserved IP address has been created on your account. + + ), + }, + reserved_ip_delete: { + notification: () => ( + <> + A reserved IP address has been deleted from your + account. + + ), + }, + reserved_ip_unassign: { + notification: () => ( + <> + A reserved IP address has been unassigned from your + account. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/stackscript.tsx b/packages/manager/src/features/Events/factories/stackscript.tsx new file mode 100644 index 00000000000..a8bdfee4b52 --- /dev/null +++ b/packages/manager/src/features/Events/factories/stackscript.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const stackscript: PartialEventMap<'stackscript'> = { + stackscript_create: { + notification: (e) => ( + <> + StackScript has been{' '} + created. + + ), + }, + stackscript_delete: { + notification: (e) => ( + <> + StackScript {e.entity?.label} has been deleted. + + ), + }, + stackscript_publicize: { + notification: (e) => ( + <> + StackScript has been{' '} + made public. + + ), + }, + stackscript_revise: { + notification: (e) => ( + <> + StackScript has been{' '} + revised. + + ), + }, + stackscript_update: { + notification: (e) => ( + <> + StackScript has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/subnet.tsx b/packages/manager/src/features/Events/factories/subnet.tsx new file mode 100644 index 00000000000..a9d1c3ab13f --- /dev/null +++ b/packages/manager/src/features/Events/factories/subnet.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const subnet: PartialEventMap<'subnet'> = { + subnet_create: { + notification: (e) => ( + <> + Subnet has been{' '} + created in VPC{' '} + . + + ), + }, + subnet_delete: { + notification: (e) => ( + <> + Subnet {e.entity?.label} has been deleted in VPC{' '} + . + + ), + }, + subnet_update: { + notification: (e) => ( + <> + Subnet in VPC{' '} + has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/tag.tsx b/packages/manager/src/features/Events/factories/tag.tsx new file mode 100644 index 00000000000..534e25e7434 --- /dev/null +++ b/packages/manager/src/features/Events/factories/tag.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const tag: PartialEventMap<'tag'> = { + tag_create: { + notification: (e) => ( + <> + Tag has been{' '} + created. + + ), + }, + tag_delete: { + notification: (e) => ( + <> + Tag {e.entity?.label} has been deleted. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/tax.tsx b/packages/manager/src/features/Events/factories/tax.tsx new file mode 100644 index 00000000000..5ac7cb45211 --- /dev/null +++ b/packages/manager/src/features/Events/factories/tax.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const tax: PartialEventMap<'tax'> = { + tax_id_invalid: { + notification: () => ( + <> + Tax Identification Number format is invalid. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/tfa.tsx b/packages/manager/src/features/Events/factories/tfa.tsx new file mode 100644 index 00000000000..3a5da658062 --- /dev/null +++ b/packages/manager/src/features/Events/factories/tfa.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const tfa: PartialEventMap<'tfa'> = { + tfa_disabled: { + notification: () => ( + <> + Two-factor authentication has been disabled. + + ), + }, + tfa_enabled: { + notification: () => ( + <> + Two-factor authentication has been enabled. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/ticket.tsx b/packages/manager/src/features/Events/factories/ticket.tsx new file mode 100644 index 00000000000..e28ba8b25db --- /dev/null +++ b/packages/manager/src/features/Events/factories/ticket.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const ticket: PartialEventMap<'ticket'> = { + ticket_attachment_upload: { + notification: (e) => ( + <> + File has been successfully uploaded to support ticket + " + + ". + + ), + }, + ticket_create: { + notification: (e) => ( + <> + New support ticket " + + " has been created. + + ), + }, + ticket_update: { + notification: (e) => ( + <> + Support ticket " + + " has been updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/token.tsx b/packages/manager/src/features/Events/factories/token.tsx new file mode 100644 index 00000000000..ed35e289395 --- /dev/null +++ b/packages/manager/src/features/Events/factories/token.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const token: PartialEventMap<'token'> = { + token_create: { + notification: (e) => ( + <> + Token has been{' '} + created. + + ), + }, + token_delete: { + notification: (e) => ( + <> + Token {e.entity?.label} has been revoked. + + ), + }, + token_update: { + notification: (e) => ( + <> + Token has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/user.tsx b/packages/manager/src/features/Events/factories/user.tsx new file mode 100644 index 00000000000..e5e5323662c --- /dev/null +++ b/packages/manager/src/features/Events/factories/user.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const user: PartialEventMap<'user'> = { + user_create: { + notification: (e) => ( + <> + User has been{' '} + created. + + ), + }, + user_delete: { + notification: (e) => ( + <> + User {e.entity?.label} has been deleted. + + ), + }, + user_ssh_key_add: { + notification: () => ( + <> + An SSH key has been added to your profile. + + ), + }, + user_ssh_key_delete: { + notification: () => ( + <> + An SSH key has been deleted from your profile. + + ), + }, + user_ssh_key_update: { + notification: (e) => ( + <> + SSH key has been{' '} + updated in your profile. + + ), + }, + user_update: { + notification: (e) => ( + <> + User has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/volume.tsx b/packages/manager/src/features/Events/factories/volume.tsx new file mode 100644 index 00000000000..5991cb0573c --- /dev/null +++ b/packages/manager/src/features/Events/factories/volume.tsx @@ -0,0 +1,196 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const volume: PartialEventMap<'volume'> = { + volume_attach: { + failed: (e) => ( + <> + Volume could not be{' '} + attached to Linode{' '} + . + + ), + finished: (e) => ( + <> + Volume has been{' '} + attached to Linode{' '} + . + + ), + notification: (e) => ( + <> + Volume has been{' '} + attached to Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + attached to Linode{' '} + . + + ), + started: (e) => ( + <> + Volume is being{' '} + attached to Linode{' '} + . + + ), + }, + volume_clone: { + notification: (e) => ( + <> + Volume has been{' '} + cloned. + + ), + }, + volume_create: { + failed: (e) => ( + <> + Volume could not be{' '} + created. + + ), + finished: (e) => ( + <> + Volume has been{' '} + created. + + ), + notification: (e) => ( + <> + Volume has been{' '} + created. + + ), + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + created. + + ), + started: (e) => ( + <> + Volume is being{' '} + created. + + ), + }, + volume_delete: { + failed: (e) => ( + <> + Volume could not be{' '} + deleted. + + ), + finished: (e) => ( + <> + Volume {e.entity?.label} has been deleted. + + ), + notification: (e) => ( + <> + Volume {e.entity?.label} has been deleted. + + ), + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + deleted. + + ), + started: (e) => ( + <> + Volume is being{' '} + deleted. + + ), + }, + volume_detach: { + failed: (e) => ( + <> + Volume could not be{' '} + detached from Linode{' '} + . + + ), + finished: (e) => ( + <> + Volume has been{' '} + detached from Linode{' '} + . + + ), + notification: (e) => ( + <> + Volume has been{' '} + detached from Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + detached from Linode{' '} + . + + ), + started: (e) => ( + <> + Volume is being{' '} + detached from Linode{' '} + . + + ), + }, + volume_migrate: { + failed: (e) => ( + <> + Volume could not be{' '} + migrated to NVMe. + + ), + finished: (e) => ( + <> + Volume has been{' '} + migrated to NVMe. + + ), + started: (e) => ( + <> + Volume is being{' '} + migrated to NVMe. + + ), + }, + volume_migrate_scheduled: { + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + migrated to NVMe. + + ), + }, + volume_resize: { + notification: (e) => ( + <> + Volume has been{' '} + resized. + + ), + }, + volume_update: { + notification: (e) => ( + <> + Volume has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/vpc.tsx b/packages/manager/src/features/Events/factories/vpc.tsx new file mode 100644 index 00000000000..1a539cbb100 --- /dev/null +++ b/packages/manager/src/features/Events/factories/vpc.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const vpc: PartialEventMap<'vpc'> = { + vpc_create: { + notification: (e) => ( + <> + VPC has been{' '} + created. + + ), + }, + vpc_delete: { + notification: (e) => ( + <> + VPC {e.entity?.label} has been deleted. + + ), + }, + vpc_update: { + notification: (e) => ( + <> + VPC has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factory.test.tsx b/packages/manager/src/features/Events/factory.test.tsx new file mode 100644 index 00000000000..f3fb6abef78 --- /dev/null +++ b/packages/manager/src/features/Events/factory.test.tsx @@ -0,0 +1,14 @@ +import { EventActionKeys } from '@linode/api-v4'; + +import { eventMessages } from './factory'; + +/** + * This test ensures any event message added to our types has a corresponding message in our factory. + */ +describe('eventMessages', () => { + it('should have a message for each EventAction', () => { + EventActionKeys.forEach((action) => { + expect(eventMessages).toHaveProperty(action); + }); + }); +}); diff --git a/packages/manager/src/features/Events/factory.tsx b/packages/manager/src/features/Events/factory.tsx new file mode 100644 index 00000000000..7a1dcddde67 --- /dev/null +++ b/packages/manager/src/features/Events/factory.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { Typography } from 'src/components/Typography'; + +import * as factories from './factories'; + +import type { EventMap, OptionalEventMap } from './types'; +import type { Event } from '@linode/api-v4'; + +/** + * The event Message Mapper + * + * It aggregates all the event messages from the factories and wraps them with Typography. + * The typography intentionally wraps the message in a span to prevent nested paragraphs while adhering to the design system's typography. + */ + +const wrapWithTypography = ( + Component: (e: Partial) => JSX.Element | string +) => { + return (e: Partial) => { + const result = Component(e); + return {result}; + }; +}; + +export const withTypography = (eventMap: EventMap): OptionalEventMap => { + return Object.fromEntries( + Object.entries(eventMap).map(([action, statuses]) => [ + action, + Object.fromEntries( + Object.entries(statuses).map(([status, func]) => [ + status, + wrapWithTypography(func), + ]) + ), + ]) + ); +}; + +export const eventMessages: EventMap = Object.keys(factories).reduce( + (acc, factoryName) => ({ + ...acc, + ...withTypography(factories[factoryName]), + }), + {} as EventMap +); diff --git a/packages/manager/src/features/Events/types.ts b/packages/manager/src/features/Events/types.ts new file mode 100644 index 00000000000..528b5212e4d --- /dev/null +++ b/packages/manager/src/features/Events/types.ts @@ -0,0 +1,21 @@ +import type { Event, EventAction, EventStatus } from '@linode/api-v4'; + +type PrefixByUnderscore = T extends `${infer s}_${string}` ? s : never; + +type EventActionPrefixes = PrefixByUnderscore; + +export type OptionalEventMap = { + [K in EventAction]?: EventMessage; +}; + +export type EventMessage = { + [S in EventStatus]?: (e: Event) => JSX.Element | string; +}; + +export type PartialEventMap = { + [K in Extract]: EventMessage; +}; + +export type EventMap = { + [K in EventAction]: EventMessage; +}; diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx new file mode 100644 index 00000000000..89ce3f0c328 --- /dev/null +++ b/packages/manager/src/features/Events/utils.test.tsx @@ -0,0 +1,164 @@ +import { eventFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { + formatEventTimeRemaining, + formatProgressEvent, + getEventMessage, +} from './utils'; + +import type { Event } from '@linode/api-v4'; +import { DateTime } from 'luxon'; + +describe('getEventMessage', () => { + const mockEvent1: Event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + status: 'finished', + }); + + const mockEvent2: Event = eventFactory.build({ + action: 'linode_config_create', + entity: { + id: 123, + label: 'test-linode', + type: 'linode', + }, + secondary_entity: { + id: 456, + label: 'test-config', + type: 'linode', + }, + status: 'notification', + }); + + it('returns the correct message for a given event', () => { + const message = getEventMessage(mockEvent1); + + const { container, getByRole } = renderWithTheme(message); + + expect(container.querySelector('span')).toHaveTextContent( + /Linode test-linode has been created./i + ); + expect(container.querySelector('strong')).toHaveTextContent('created'); + expect(getByRole('link')).toHaveAttribute('href', '/linodes/123'); + }); + + it('returns the correct message for a given event with a secondary entity', () => { + const message = getEventMessage(mockEvent2); + + const { container, getAllByRole } = renderWithTheme(message); + + expect(container.querySelector('span')).toHaveTextContent( + /Config test-config has been created on Linode test-linode./i + ); + expect(container.querySelector('strong')).toHaveTextContent('created'); + + const links = getAllByRole('link'); + expect(links.length).toBe(2); + expect(links[0]).toHaveAttribute('href', '/linodes/456'); + expect(links[1]).toHaveAttribute('href', '/linodes/123'); + }); + + it('returns the correct message for a manual input event', () => { + const message = getEventMessage({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + type: 'linode', + }, + status: 'failed', + }); + + const { container } = renderWithTheme(message); + + expect(container.querySelector('span')).toHaveTextContent( + /Linode test-linode could not be created./i + ); + + const boldedWords = container.querySelectorAll('strong'); + expect(boldedWords).toHaveLength(2); + expect(boldedWords[0]).toHaveTextContent('not'); + 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 new file mode 100644 index 00000000000..a7d3f301796 --- /dev/null +++ b/packages/manager/src/features/Events/utils.tsx @@ -0,0 +1,126 @@ +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'; + +type EventMessageManualInput = { + action: Event['action']; + entity?: Partial; + secondary_entity?: Partial; + status: Event['status']; +}; + +/** + * The event Message Getter + * Intentionally avoiding parsing and formatting, and should remain as such. + * + * Defining two function signatures for getEventMessage: + * - A function that takes a full Event object (event page and notification center) + * - A function that takes an object with action, status, entity, and secondary_entity (getting a message for a snackbar for instance, where we manually pass the action & status) + * + * Using typescript overloads allows for both Event and EventMessageInput types. + * + * We don't include defaulting to the API message response here because: + * - we want to control the message output (our types require us to define one) and rather show nothing than a broken message. + * - the API message is empty 99% of the time and when present, isn't meant to be displayed as a full message, rather a part of it. (ex: `domain_record_create`) + */ +export function getEventMessage(event: Event): JSX.Element | null | string; +export function getEventMessage( + event: EventMessageManualInput +): JSX.Element | null | string; +export function getEventMessage( + event: Event | EventMessageManualInput +): JSX.Element | null | string { + if (!event?.action || !event?.status) { + return null; + } + + const message = eventMessages[event?.action]?.[event.status]; + + 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/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index 1ae36cb4743..5efc15e910b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -14,7 +14,7 @@ import { useAddFirewallDeviceMutation, useAllFirewallsQuery, } from 'src/queries/firewalls'; -import { useGrants, useProfile } from 'src/queries/profile'; +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'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index c867c4c1f67..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'; +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/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx index fe17defa93e..7b1cfcb8fc6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx @@ -14,10 +14,7 @@ export const FirewallDeviceRow = React.memo( const { deviceEntityID, deviceID, deviceLabel, deviceType } = props; return ( - + { 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 : nodeBalancerQueryKey; - // 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 28782cb1f99..d888aa9759d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -1,4 +1,5 @@ import { styled } from '@mui/material/styles'; +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -7,11 +8,15 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Notice } from 'src/components/Notice/Notice'; import { Prompt } from 'src/components/Prompt/Prompt'; import { Typography } from 'src/components/Typography'; -import { useUpdateFirewallRulesMutation } from 'src/queries/firewalls'; +import { + useAllFirewallDevicesQuery, + useUpdateFirewallRulesMutation, +} from 'src/queries/firewalls'; +import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { FirewallRuleDrawer } from './FirewallRuleDrawer'; -import { FirewallRuleTable } from './FirewallRuleTable'; import { hasModified as _hasModified, curriedFirewallRuleEditorReducer, @@ -20,6 +25,7 @@ import { prepareRules, stripExtendedFields, } from './firewallRuleEditor'; +import { FirewallRuleTable } from './FirewallRuleTable'; import { parseFirewallRuleError } from './shared'; import type { FirewallRuleDrawerMode } from './FirewallRuleDrawer.types'; @@ -51,6 +57,8 @@ export const FirewallRulesLanding = React.memo((props: Props) => { const { mutateAsync: updateFirewallRules } = useUpdateFirewallRulesMutation( firewallID ); + const { data: devices } = useAllFirewallDevicesQuery(firewallID); + const queryClient = useQueryClient(); const { enqueueSnackbar } = useSnackbar(); @@ -193,6 +201,28 @@ export const FirewallRulesLanding = React.memo((props: Props) => { updateFirewallRules(finalRules) .then((_rules) => { setSubmitting(false); + // Invalidate Firewalls assigned to NodeBalancers and Linodes. + 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' }); outboundDispatch({ rules: _rules.outbound ?? [], type: 'RESET' }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index 3b44b84ab13..f0e58906668 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -12,7 +12,7 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { useFirewallQuery, useMutateFirewall } from 'src/queries/firewalls'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { checkIfUserCanModifyFirewall } from '../shared'; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index b60668ff09a..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,14 +19,8 @@ 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 nodebalancerQueryKey } from 'src/queries/nodebalancers'; -import { useGrants } from 'src/queries/profile'; +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'; import { @@ -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([ - nodebalancerQueryKey, - 'nodebalancer', - nodebalancerId, - 'firewalls', - ]); - }); - } - if (onFirewallCreated) { onFirewallCreated(response); } diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx index 26611f72110..1d97f27a09e 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { checkIfUserCanModifyFirewall } from '../shared'; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index 33fd6a16ce3..fc36b3089ec 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -1,52 +1,41 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancerQueryKey } 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 ?? -1 - ); + const { mode, onClose, open, selectedFirewall } = props; const { error: updateError, isLoading: isUpdating, mutateAsync: updateFirewall, - } = useMutateFirewall(selectedFirewallId ?? -1); + } = useMutateFirewall(selectedFirewall.id); const { error: deleteError, isLoading: isDeleting, mutateAsync: deleteFirewall, - } = useDeleteFirewall(selectedFirewallId ?? -1); + } = useDeleteFirewall(selectedFirewall.id); const requestMap = { delete: () => deleteFirewall(), @@ -68,21 +57,30 @@ export const FirewallDialog = React.memo((props: Props) => { const onSubmit = async () => { await requestMap[mode](); - if (mode === 'delete') { - devices?.forEach((device) => { - const deviceType = device.entity.type; - queryClient.invalidateQueries([ - deviceType === 'linode' ? linodesQueryKey : nodebalancerQueryKey, - deviceType, - device.entity.id, - 'firewalls', - ]); - queryClient.invalidateQueries([firewallQueryKey]); - }); + + // Invalidate Firewalls assigned to NodeBalancers and Linodes when Firewall is enabled, disabled, or deleted. + 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 21ff9542266..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,14 @@ const FirewallLanding = () => { onClose={onCloseCreateDrawer} open={isCreateFirewallDrawerOpen} /> - setIsModalOpen(false)} - open={isModalOpen} - selectedFirewallId={selectedFirewallId} - selectedFirewallLabel={selectedFirewall?.label ?? ''} - /> + {selectedFirewall && ( + setIsModalOpen(false)} + open={isModalOpen} + 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..e24fcdc7924 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx @@ -58,7 +58,7 @@ describe('FirewallRow', () => { const { getByTestId, getByText } = render( wrapWithTableBody() ); - getByTestId('firewall-row-0'); + getByTestId('firewall-row-1'); getByText(firewall.label); getByText(capitalize(firewall.status)); getByText(getRuleString(getCountOfRules(firewall.rules))); @@ -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 cb1f31da9a3..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,25 +5,22 @@ 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); return ( - + {label} @@ -37,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) => ( - - {idx > 0 && `, `} - {thisDevice.entity.label} - + {firstThree.map((entity, idx) => ( + + {idx > 0 && ', '} + + {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/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index 48167eb63ce..65d4a952805 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -10,7 +10,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useAccount, useMutateAccount } from 'src/queries/account/account'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; import { StyledGrid } from './EmailBounce.styles'; diff --git a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx index 1f459809a46..2d600740b1b 100644 --- a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx +++ b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx @@ -7,8 +7,8 @@ import { switchAccountSessionContext } from 'src/context/switchAccountSessionCon import { SwitchAccountSessionDialog } from 'src/features/Account/SwitchAccounts/SwitchAccountSessionDialog'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; import { useFlags } from 'src/hooks/useFlags'; -import { useProfile } from 'src/queries/profile'; -import { useSecurityQuestions } from 'src/queries/securityQuestions'; +import { useProfile } from 'src/queries/profile/profile'; +import { useSecurityQuestions } from 'src/queries/profile/securityQuestions'; import { SessionExpirationDialog } from '../Account/SwitchAccounts/SessionExpirationDialog'; import { APIMaintenanceBanner } from './APIMaintenanceBanner'; @@ -17,16 +17,16 @@ import { ComplianceUpdateModal } from './ComplianceUpdateModal'; import { EmailBounceNotificationSection } from './EmailBounce'; import { RegionStatusBanner } from './RegionStatusBanner'; import { TaxCollectionBanner } from './TaxCollectionBanner'; +import { DesignUpdateBanner } from './TokensUpdateBanner'; import { VerificationDetailsBanner } from './VerificationDetailsBanner'; + export const GlobalNotifications = () => { const flags = useFlags(); const { data: profile } = useProfile(); const sessionContext = React.useContext(switchAccountSessionContext); const sessionExpirationContext = React.useContext(_sessionExpirationContext); - const isChildUser = - Boolean(flags.parentChildAccountAccess) && profile?.user_type === 'child'; - const isProxyUser = - Boolean(flags.parentChildAccountAccess) && profile?.user_type === 'proxy'; + const isChildUser = profile?.user_type === 'child'; + const isProxyUser = profile?.user_type === 'proxy'; const { data: securityQuestions } = useSecurityQuestions({ enabled: isChildUser, }); @@ -53,6 +53,7 @@ export const GlobalNotifications = () => { return ( <> + diff --git a/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx b/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx new file mode 100644 index 00000000000..73efd621c61 --- /dev/null +++ b/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; + +export const DesignUpdateBanner = () => { + const flags = useFlags(); + const designUpdateFlag = flags.cloudManagerDesignUpdatesBanner; + + if (!designUpdateFlag || !designUpdateFlag.enabled) { + return null; + } + const { key, link } = designUpdateFlag; + + /** + * This banner is a reusable banner for future Cloud Manager design updates. + * Since this banner is dismissible, we want to be able to dynamically change the key, + * so we can show it again as needed to users who have dismissed it in the past in the case of a new series of UI updates. + * + * Flag shape is as follows: + * + * { + * "enabled": boolean, + * "key": "some-key", + * "link": "link to docs" + * } + * + */ + return ( + + + We are improving the Cloud Manager experience for our users.{' '} + Read more about recent updates. + + + ); +}; diff --git a/packages/manager/src/features/Help/Panels/PopularPosts.tsx b/packages/manager/src/features/Help/Panels/PopularPosts.tsx index 96d2d47feac..ec5f14daa7e 100644 --- a/packages/manager/src/features/Help/Panels/PopularPosts.tsx +++ b/packages/manager/src/features/Help/Panels/PopularPosts.tsx @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { Theme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -18,7 +18,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ margin: `${theme.spacing(6)} 0`, }, withSeparator: { - borderLeft: `1px solid ${theme.palette.divider}`, + borderLeft: `1px solid ${theme.borderColors.divider}`, paddingLeft: theme.spacing(4), [theme.breakpoints.down('sm')]: { borderLeft: 'none', diff --git a/packages/manager/src/features/Help/Panels/SearchPanel.tsx b/packages/manager/src/features/Help/Panels/SearchPanel.tsx index 3a2150403b3..49cb8a97d1c 100644 --- a/packages/manager/src/features/Help/Panels/SearchPanel.tsx +++ b/packages/manager/src/features/Help/Panels/SearchPanel.tsx @@ -22,7 +22,10 @@ const StyledRootContainer = styled(Paper, { label: 'StyledRootContainer', })(({ theme }) => ({ alignItems: 'center', - backgroundColor: theme.color.green, + backgroundColor: + theme.name === 'dark' + ? theme.palette.primary.light + : theme.palette.primary.dark, display: 'flex', flexDirection: 'column', justifyContent: 'center', @@ -36,7 +39,7 @@ const StyledRootContainer = styled(Paper, { const StyledH1Header = styled(H1Header, { label: 'StyledH1Header', })(({ theme }) => ({ - color: theme.name === 'dark' ? theme.color.black : theme.color.white, + color: theme.color.white, marginBottom: theme.spacing(), position: 'relative', textAlign: 'center', diff --git a/packages/manager/src/features/Images/ImageSelect.test.tsx b/packages/manager/src/features/Images/ImageSelect.test.tsx index cf49ce439ea..58f40b9a9a3 100644 --- a/packages/manager/src/features/Images/ImageSelect.test.tsx +++ b/packages/manager/src/features/Images/ImageSelect.test.tsx @@ -49,6 +49,7 @@ describe('ImageSelect', () => { expect(items[0]).toHaveProperty('label', groupNameMap.recommended); expect(items[0].options).toHaveLength(2); }); + it('should handle multiple groups', () => { const items = getImagesOptions([ recommendedImage1, @@ -60,12 +61,14 @@ describe('ImageSelect', () => { const deleted = items.find((item) => item.label === groupNameMap.deleted); expect(deleted!.options).toHaveLength(1); }); + it('should properly format GroupType options as RS Item type', () => { const category = getImagesOptions([recommendedImage1])[0]; const option = category.options[0]; expect(option).toHaveProperty('label', recommendedImage1.label); expect(option).toHaveProperty('value', recommendedImage1.id); }); + it('should handle empty input', () => { expect(getImagesOptions([])).toEqual([]); }); @@ -74,8 +77,9 @@ describe('ImageSelect', () => { describe('ImageSelect component', () => { it('should render', () => { const { getByText } = renderWithTheme(); - getByText(/image-0/i); + getByText(/image-1(?!\d)/i); }); + it('should display an error', () => { const imageError = 'An error'; const { getByText } = renderWithTheme( diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx deleted file mode 100644 index 9006c9a3be3..00000000000 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; -import * as React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useHistory } from 'react-router-dom'; -import { makeStyles } from 'tss-react/mui'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Checkbox } from 'src/components/Checkbox'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { Link } from 'src/components/Link'; -import { LinodeCLIModal } from 'src/components/LinodeCLIModal/LinodeCLIModal'; -import { Notice } from 'src/components/Notice/Notice'; -import { Paper } from 'src/components/Paper'; -import { Prompt } from 'src/components/Prompt/Prompt'; -import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { TextField } from 'src/components/TextField'; -import { Typography } from 'src/components/Typography'; -import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; -import { Dispatch } from 'src/hooks/types'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; -import { useFlags } from 'src/hooks/useFlags'; -import { - reportAgreementSigningError, - useAccountAgreements, - useMutateAccountAgreements, -} from 'src/queries/account/agreements'; -import { useGrants, useProfile } from 'src/queries/profile'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { redirectToLogin } from 'src/session'; -import { ApplicationState } from 'src/store'; -import { setPendingUpload } from 'src/store/pendingUpload'; -import { getErrorMap } from 'src/utilities/errorUtils'; -import { getGDPRDetails } from 'src/utilities/formatRegion'; -import { wrapInQuotes } from 'src/utilities/stringUtils'; - -import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; - -const useStyles = makeStyles()((theme: Theme) => ({ - browseFilesButton: { - marginLeft: '1rem', - }, - cliModalButton: { - ...theme.applyLinkStyles, - fontFamily: theme.font.bold, - }, - cloudInitCheckboxWrapper: { - marginLeft: '3px', - marginTop: theme.spacing(2), - }, - container: { - '& .MuiFormHelperText-root': { - marginBottom: theme.spacing(2), - }, - minWidth: '100%', - paddingBottom: theme.spacing(), - paddingTop: theme.spacing(2), - }, - helperText: { - marginTop: theme.spacing(2), - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '90%', - }, -})); - -const cloudInitTooltipMessage = ( - - Only check this box if your Custom Image is compatible with cloud-init, or - has cloud-init installed, and the config has been changed to use our data - service.{' '} - - Learn how. - - -); - -const imageSizeLimitsMessage = ( - - Image files must be raw disk images (.img) compressed using gzip (.gz). The - maximum file size is 5 GB (compressed) and maximum image size is 6 GB - (uncompressed). - -); - -export interface Props { - changeDescription: (e: React.ChangeEvent) => void; - changeIsCloudInit: () => void; - changeLabel: (e: React.ChangeEvent) => void; - description: string; - isCloudInit: boolean; - label: string; -} - -export const ImageUpload: React.FC = (props) => { - const { - changeDescription, - changeIsCloudInit, - changeLabel, - description, - isCloudInit, - label, - } = props; - - const { data: profile } = useProfile(); - const { data: grants } = useGrants(); - const { data: agreements } = useAccountAgreements(); - const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); - - const { classes } = useStyles(); - const regions = useRegionsQuery().data ?? []; - const dispatch: Dispatch = useDispatch(); - const { push } = useHistory(); - const flags = useFlags(); - - const [hasSignedAgreement, setHasSignedAgreement] = React.useState( - false - ); - - const [region, setRegion] = React.useState(''); - const [errors, setErrors] = React.useState(); - const [linodeCLIModalOpen, setLinodeCLIModalOpen] = React.useState( - false - ); - - const { showGDPRCheckbox } = getGDPRDetails({ - agreements, - profile, - regions, - selectedRegionId: region, - }); - - // This holds a "cancel function" from the Axios instance that handles image - // uploads. Calling this function will cancel the HTTP request. - const [cancelFn, setCancelFn] = React.useState<(() => void) | null>(null); - - // Whether or not there is an upload pending. This is stored in Redux since - // high-level components like AuthenticationWrapper need to read it. - const pendingUpload = useSelector( - (state) => state.pendingUpload - ); - - // Keep track of the session token since we may need to grab the user a new - // one after a long upload (if their session has expired). - const currentToken = useCurrentToken(); - - const canCreateImage = - Boolean(!profile?.restricted) || Boolean(grants?.global?.add_images); - - // Called after a user confirms they want to navigate to another part of - // Cloud during a pending upload. When we have refresh tokens this won't be - // necessary; the user will be able to navigate to other components and we - // will show the upload progress in the lower part of the screen. For now we - // box the user on this page so we can handle token expiry (semi)-gracefully. - const onConfirm = (nextLocation: string) => { - if (cancelFn) { - cancelFn(); - } - - dispatch(setPendingUpload(false)); - - // If the user's session has expired we need to send them to Login to get - // a new token. They will be redirected back to path they were trying to - // reach. - if (!currentToken) { - redirectToLogin(nextLocation); - } else { - push(nextLocation); - } - }; - - const onSuccess = () => { - if (hasSignedAgreement) { - updateAccountAgreements({ - eu_model: true, - privacy_policy: true, - }).catch(reportAgreementSigningError); - } - }; - - const uploadingDisabled = - !label || - !region || - !canCreateImage || - (showGDPRCheckbox && !hasSignedAgreement); - - const errorMap = getErrorMap(['label', 'description', 'region'], errors); - - const cliLabel = formatForCLI(label, 'label'); - const cliDescription = formatForCLI(description, 'description'); - const cliRegion = formatForCLI(region, 'region'); - const linodeCLICommand = `linode-cli image-upload --label ${cliLabel} --description ${cliDescription} --region ${cliRegion} FILE`; - - return ( - <> - - {({ handleCancel, handleConfirm, isModalOpen }) => { - return ( - ( - - )} - onClose={handleCancel} - open={isModalOpen} - title="Leave this page?" - > - - An upload is in progress. If you navigate away from this page, - the upload will be canceled. - - - ); - }} - - - - {errorMap.none ? : null} - {!canCreateImage ? ( - - ) : null} - -
    - - - - {flags.metadata && ( -
    - -
    - )} - - {showGDPRCheckbox ? ( - setHasSignedAgreement(e.target.checked)} - /> - ) : null} - - {imageSizeLimitsMessage} - - - Custom Images are billed at $0.10/GB per month based on the - uncompressed image size. - - - - Or, upload an image using the{' '} - - . For more information, please see{' '} - - our guide on using the Linode CLI - - . - -
    -
    - setLinodeCLIModalOpen(false)} - /> - - ); -}; - -export default ImageUpload; - -const formatForCLI = (value: string, fallback: string) => { - return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; -}; 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..7047a89e20a --- /dev/null +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -0,0 +1,263 @@ +import { waitFor } from '@testing-library/react'; +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 pre-fill Linode and Disk from search params', async () => { + const linode = linodeFactory.build(); + const disk = linodeDiskFactory.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])); + }) + ); + + const { getByLabelText } = renderWithTheme(, { + MemoryRouter: { + initialEntries: [ + `/images/create/disk?selectedLinode=${linode.id}&selectedDisk=${disk.id}`, + ], + }, + }); + + await waitFor(() => { + expect(getByLabelText('Linode')).toHaveValue(linode.label); + expect(getByLabelText('Disk')).toHaveValue(disk.label); + }); + }); + + 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.'); + }); + + it('should auto-populate image label based on linode and disk', async () => { + const linode = linodeFactory.build(); + const disk1 = linodeDiskFactory.build(); + const disk2 = linodeDiskFactory.build(); + const image = imageFactory.build(); + + 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/linode/instances/:id/disks', () => { + return HttpResponse.json(makeResourcePage([disk1, disk2])); + }), + http.post('*/v4/images', () => { + return HttpResponse.json(image); + }) + ); + + const { findByText, getByLabelText, 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(disk1.label); + + await userEvent.click(diskOption); + + // Image label should auto-populate + const imageLabel = getByLabelText('Label'); + expect(imageLabel).toHaveValue(`${linode.label}-${disk1.label}`); + + // Image label should update + await userEvent.click(diskSelect); + + const disk2Option = await findByText(disk2.label); + await userEvent.click(disk2Option); + + expect(imageLabel).toHaveValue(`${linode.label}-${disk2.label}`); + + // Image label should not override user input + const customLabel = 'custom-label'; + await userEvent.clear(imageLabel); + await userEvent.type(imageLabel, customLabel); + expect(imageLabel).toHaveValue(customLabel); + await userEvent.click(diskSelect); + await userEvent.click(diskOption); + expect(imageLabel).toHaveValue(customLabel); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 86e89d65d67..4de39587203 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -1,18 +1,20 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { CreateImagePayload } from '@linode/api-v4'; import { createImageSchema } from '@linode/validation'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Checkbox } from 'src/components/Checkbox'; +import { DISK_ENCRYPTION_IMAGES_CAVEAT_COPY } from 'src/components/DiskEncryption/constants'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { SupportLink } from 'src/components/SupportLink'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; @@ -25,11 +27,19 @@ import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGran import { useEventsPollingActions } from 'src/queries/events/events'; import { useCreateImageMutation } from 'src/queries/images'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; -import { useGrants } from 'src/queries/profile'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useGrants } from 'src/queries/profile/profile'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; + +import type { CreateImagePayload } from '@linode/api-v4'; export const CreateImageTab = () => { - const [selectedLinodeId, setSelectedLinodeId] = React.useState( - null + const location = useLocation(); + + const queryParams = React.useMemo( + () => getQueryParamsFromQueryString(location.search), + [location.search] ); const { @@ -38,8 +48,12 @@ export const CreateImageTab = () => { handleSubmit, resetField, setError, + setValue, watch, } = useForm({ + defaultValues: { + disk_id: +queryParams.selectedDisk, + }, mode: 'onBlur', resolver: yupResolver(createImageSchema), }); @@ -59,6 +73,10 @@ export const CreateImageTab = () => { globalGrantType: 'add_images', }); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + const onSubmit = handleSubmit(async (values) => { try { await createImage(values); @@ -80,6 +98,15 @@ export const CreateImageTab = () => { } }); + const [selectedLinodeId, setSelectedLinodeId] = React.useState( + queryParams.selectedLinode ? +queryParams.selectedLinode : null + ); + + const { data: selectedLinode } = useLinodeQuery( + selectedLinodeId ?? -1, + selectedLinodeId !== null + ); + const { data: disks, error: disksError, @@ -90,8 +117,49 @@ export const CreateImageTab = () => { const selectedDisk = disks?.find((disk) => disk.id === selectedDiskId) ?? null; + React.useEffect(() => { + if (formState.touchedFields.label) { + return; + } + if (selectedLinode) { + setValue('label', `${selectedLinode.label}-${selectedDisk?.label ?? ''}`); + } else { + resetField('label'); + } + }, [ + selectedLinode, + selectedDisk, + formState.touchedFields.label, + setValue, + resetField, + ]); + const isRawDisk = selectedDisk?.filesystem === 'raw'; + const { data: regionsData } = useRegionsQuery(); + + const linodeIsInDistributedRegion = getIsDistributedRegion( + regionsData ?? [], + selectedLinode?.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 ( @@ -109,11 +177,11 @@ export const CreateImageTab = () => { variant="error" /> )} - + Select Linode & Disk By default, Linode images are limited to 6144 MB of data per disk. - Ensure your content doesn't exceed this limit, or{' '} + Ensure your content doesn’t exceed this limit, or{' '} { text="open a support ticket" title="Request to increase Image size limit when capturing from Linode disk" />{' '} - to request a higher limit. Additionally, images can't be created - from a raw disk or a disk that's formatted using a custom file - system. + to request a higher limit. Additionally, images can’t be + created from a raw disk or a disk that’s formatted using a + custom file system. + {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) { @@ -152,10 +219,18 @@ export const CreateImageTab = () => { } }} disabled={isImageCreateRestricted} + helperText={linodeSelectHelperText} noMarginTop required value={selectedLinodeId} /> + {showDiskEncryptionWarning && ( + + ({ fontFamily: theme.font.normal })}> + {DISK_ENCRYPTION_IMAGES_CAVEAT_COPY} + + + )} ( { - + Image Details ( @@ -238,7 +313,6 @@ export const CreateImageTab = () => {
    } - interactive status="help" /> diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx index c954d558318..d1bf1d2b064 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx @@ -1,38 +1,22 @@ import * as React from 'react'; -import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useRouteMatch } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; 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 })) +); + const CreateImageTab = React.lazy(() => import('./CreateImageTab').then((module) => ({ default: module.CreateImageTab, })) ); -const ImageUpload = React.lazy(() => import('../ImageUpload')); export const ImageCreate = () => { const { url } = useRouteMatch(); - const { location } = useHistory(); - - const [label, setLabel] = React.useState( - location?.state ? location.state.imageLabel : '' - ); - const [description, setDescription] = React.useState( - location?.state ? location.state.imageDescription : '' - ); - const [isCloudInit, setIsCloudInit] = React.useState(false); - - const handleSetLabel = (e: React.ChangeEvent) => { - const value = e.target.value; - setLabel(value); - }; - - const handleSetDescription = (e: React.ChangeEvent) => { - const value = e.target.value; - setDescription(value); - }; const tabs: NavTab[] = [ { @@ -41,16 +25,7 @@ export const ImageCreate = () => { title: 'Capture Image', }, { - render: ( - setIsCloudInit(!isCloudInit)} - changeLabel={handleSetLabel} - description={description} - isCloudInit={isCloudInit} - label={label} - /> - ), + render: , routeName: `${url}/upload`, title: 'Upload Image', }, diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx new file mode 100644 index 00000000000..51bf538c31e --- /dev/null +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -0,0 +1,430 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; +import { flushSync } from 'react-dom'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Checkbox } from 'src/components/Checkbox'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { Prompt } from 'src/components/Prompt/Prompt'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { Stack } from 'src/components/Stack'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; +import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; +import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; +import { Dispatch } from 'src/hooks/types'; +import { useFlags } from 'src/hooks/useFlags'; +import { usePendingUpload } from 'src/hooks/usePendingUpload'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { + reportAgreementSigningError, + useAccountAgreements, + useMutateAccountAgreements, +} from 'src/queries/account/agreements'; +import { useUploadImageMutation } from 'src/queries/images'; +import { useProfile } from 'src/queries/profile/profile'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +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 { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; +import { + ImageUploadFormData, + ImageUploadNavigationState, +} from './ImageUpload.utils'; +import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; +import { uploadImageFile } from '../requests'; + +import type { AxiosError, AxiosProgressEvent } from 'axios'; + +export const ImageUpload = () => { + const { location } = useHistory(); + + const dispatch = useDispatch(); + const hasPendingUpload = usePendingUpload(); + const { push } = useHistory(); + const flags = useFlags(); + + const [uploadProgress, setUploadProgress] = useState(); + const cancelRef = React.useRef<(() => void) | null>(null); + const [hasSignedAgreement, setHasSignedAgreement] = useState(false); + const [linodeCLIModalOpen, setLinodeCLIModalOpen] = useState(false); + + const { data: profile } = useProfile(); + const { data: agreements } = useAccountAgreements(); + const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); + const { data: regions } = useRegionsQuery(); + const { mutateAsync: createImage } = useUploadImageMutation(); + const { enqueueSnackbar } = useSnackbar(); + + const form = useForm({ + defaultValues: { + description: location.state?.imageDescription, + label: location.state?.imageLabel, + }, + mode: 'onBlur', + resolver: yupResolver(ImageUploadSchema), + }); + + const onSubmit = form.handleSubmit(async (values) => { + const { file, ...createPayload } = values; + + try { + const { image, upload_to } = await createImage(createPayload); + + // Let the entire app know that there's a pending upload via Redux. + // High-level components like AuthenticationWrapper need to know + // this, so the user isn't redirected to Login if the token expires. + dispatch(setPendingUpload(true)); + + recordImageAnalytics('start', file); + + try { + const { cancel, request } = uploadImageFile( + upload_to, + file, + setUploadProgress + ); + + cancelRef.current = cancel; + + await request(); + + if (hasSignedAgreement) { + updateAccountAgreements({ + eu_model: true, + privacy_policy: true, + }).catch(reportAgreementSigningError); + } + + enqueueSnackbar( + `Image ${image.label} uploaded successfully. It is being processed and will be available shortly.`, + { variant: 'success' } + ); + + recordImageAnalytics('success', file); + + // Force a re-render so that `hasPendingUpload` is false when navigating away + // from the upload page. We need this to make the work as expected. + flushSync(() => { + dispatch(setPendingUpload(false)); + }); + + push('/images'); + } catch (error) { + // Handle an Axios error for the actual image upload + form.setError('root', { message: (error as AxiosError).message }); + // Update Redux to show we have no upload in progress + dispatch(setPendingUpload(false)); + recordImageAnalytics('fail', file); + } + } catch (errors) { + // Handle API errors from the POST /v4/images/upload + for (const error of errors) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + window.scrollTo({ top: 0 }); + form.setError('root', { message: error.reason }); + } + } + // Update Redux to show we have no upload in progress + dispatch(setPendingUpload(false)); + } + }); + + const selectedRegionId = form.watch('region'); + + const { showGDPRCheckbox } = getGDPRDetails({ + agreements, + profile, + regions, + selectedRegionId, + }); + + const isImageCreateRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_images', + }); + + // Called after a user confirms they want to navigate to another part of + // Cloud during a pending upload. When we have refresh tokens this won't be + // necessary; the user will be able to navigate to other components and we + // will show the upload progress in the lower part of the screen. For now we + // box the user on this page so we can handle token expiry (semi)-gracefully. + const onConfirm = (nextLocation: string) => { + if (cancelRef.current) { + cancelRef.current(); + } + + dispatch(setPendingUpload(false)); + + push(nextLocation); + }; + + return ( + + + + + + Image Details + + {form.formState.errors.root?.message && ( + + )} + {isImageCreateRestricted && ( + + )} + ( + + )} + control={form.control} + name="label" + /> + {flags.metadata && ( + + ( + + Only check this box if your Custom Image is compatible + with cloud-init, or has cloud-init installed, and the + config has been changed to use our data service.{' '} + + Learn how. + + + } + checked={field.value ?? false} + onChange={field.onChange} + text="This image is cloud-init compatible" + /> + )} + control={form.control} + name="cloud_init" + /> + + )} + ( + field.onChange(region.id)} + regionFilter="core" // Images service will not be supported for Gecko Beta + regions={regions ?? []} + value={field.value ?? null} + /> + )} + control={form.control} + name="region" + /> + ( + + field.onChange(items.map((item) => item.value)) + } + value={ + field.value?.map((tag) => ({ label: tag, value: tag })) ?? + [] + } + tagError={fieldState.error?.message} + /> + )} + control={form.control} + name="tags" + /> + ( + + )} + control={form.control} + name="description" + /> + {showGDPRCheckbox && ( + setHasSignedAgreement(e.target.checked)} + /> + )} + + + + Image Upload + + {form.formState.errors.file?.message && ( + + )} + + + Image files must be raw disk images (.img) compressed using gzip + (.gz). The maximum file size is 5 GB (compressed) and maximum + image size is 6 GB (uncompressed). + + + + Custom Images are billed at $0.10/GB per month based on the + uncompressed image size. + + ( + { + form.setError('file', {}); + field.onChange(files[0]); + }} + onDropRejected={(fileRejections) => { + let message = ''; + switch (fileRejections[0].errors[0].code) { + case 'file-invalid-type': + message = + 'Only raw disk images (.img) compressed using gzip (.gz) can be uploaded.'; + break; + case 'file-too-large': + message = `Max file size (${ + readableBytes(MAX_FILE_SIZE_IN_BYTES).formatted + }) exceeded`; + break; + default: + message = fileRejections[0].errors[0].message; + } + form.setError('file', { message }); + form.resetField('file', { keepError: true }); + }} + disabled={isImageCreateRestricted} + isUploading={form.formState.isSubmitting} + progress={uploadProgress} + /> + )} + control={form.control} + name="file" + /> + + + + + + + + setLinodeCLIModalOpen(false)} + /> + + {({ handleCancel, handleConfirm, isModalOpen }) => { + return ( + + } + onClose={handleCancel} + open={isModalOpen} + title="Leave this page?" + > + + An upload is in progress. If you navigate away from this page, + the upload will be canceled. + + + ); + }} + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts new file mode 100644 index 00000000000..8e890f341f7 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts @@ -0,0 +1,41 @@ +import { uploadImageSchema } from '@linode/validation'; +import { mixed } from 'yup'; + +import { sendImageUploadEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { readableBytes } from 'src/utilities/unitConversions'; + +import type { ImageUploadPayload } from '@linode/api-v4'; + +export const recordImageAnalytics = ( + action: 'fail' | 'start' | 'success', + file: File +) => { + const readableFileSize = readableBytes(file.size).formatted; + sendImageUploadEvent(action, readableFileSize); +}; + +/** + * We extend the image upload payload to contain the file + * so we can use react-hook-form to manage all of the form state. + */ +export interface ImageUploadFormData extends ImageUploadPayload { + file: File; +} + +/** + * We extend the image upload schema to contain the file + * so we can use react-hook-form to validate all of the + * form state at once. + */ +export const ImageUploadSchema = uploadImageSchema.shape({ + file: mixed().required('Image is required.'), +}); + +/** + * We use navigation state to pre-fill the upload form + * when the user "retries" an upload. + */ +export interface ImageUploadNavigationState { + imageDescription?: string; + imageLabel?: string; +} diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx new file mode 100644 index 00000000000..ba282570ae2 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; + +import type { ImageUploadFormData } from './ImageUpload.utils'; + +describe('ImageUploadCLIDialog', () => { + it('should render a title', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Upload Image with the Linode CLI')).toBeVisible(); + }); + + it('should render nothing when isOpen is false', () => { + const { container } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render a default CLI command with no form data', () => { + const { getByDisplayValue } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect( + getByDisplayValue( + 'linode-cli image-upload --label [LABEL] --description [DESCRIPTION] --region [REGION] FILE' + ) + ).toBeVisible(); + }); + + it('should render a CLI command based on form data', () => { + const { + getByDisplayValue, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + description: 'this is my cool image', + label: 'my-image', + region: 'us-east', + }, + }, + }); + + expect( + getByDisplayValue( + 'linode-cli image-upload --label "my-image" --description "this is my cool image" --region "us-east" FILE' + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx new file mode 100644 index 00000000000..898ec158279 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; +import { Dialog } from 'src/components/Dialog/Dialog'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { sendCLIClickEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { wrapInQuotes } from 'src/utilities/stringUtils'; + +import type { ImageUploadFormData } from './ImageUpload.utils'; + +interface ImageUploadSuccessDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export const ImageUploadCLIDialog = (props: ImageUploadSuccessDialogProps) => { + const { isOpen, onClose } = props; + + const form = useFormContext(); + + const { description, label, region } = form.getValues(); + + const cliLabel = formatForCLI(label, 'label'); + const cliDescription = formatForCLI(description ?? '', 'description'); + const cliRegion = formatForCLI(region, 'region'); + + const command = `linode-cli image-upload --label ${cliLabel} --description ${cliDescription} --region ${cliRegion} FILE`; + + return ( + + sendCLIClickEvent('Image Upload'), + }} + expand + hideLabel + label="CLI Command" + noMarginTop + sx={{ fontFamily: 'UbuntuMono, monospace, sans-serif' }} + value={command} + /> + + For more information, please see{' '} + + our guide on using the Linode CLI + + . + + + ); +}; + +const formatForCLI = (value: string, fallback: string) => { + return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; +}; diff --git a/packages/manager/src/features/Images/ImagesDrawer.tsx b/packages/manager/src/features/Images/ImagesDrawer.tsx deleted file mode 100644 index ac08178c47b..00000000000 --- a/packages/manager/src/features/Images/ImagesDrawer.tsx +++ /dev/null @@ -1,406 +0,0 @@ -import { Disk, getLinodeDisks } from '@linode/api-v4/lib/linodes'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; -import { useSnackbar } from 'notistack'; -import { equals } from 'ramda'; -import * as React from 'react'; -import { useHistory } from 'react-router-dom'; -import { makeStyles } from 'tss-react/mui'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { Notice } from 'src/components/Notice/Notice'; -import { TextField } from 'src/components/TextField'; -import { Typography } from 'src/components/Typography'; -import { IMAGE_DEFAULT_LIMIT } from 'src/constants'; -import { DiskSelect } from 'src/features/Linodes/DiskSelect/DiskSelect'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { useEventsPollingActions } from 'src/queries/events/events'; -import { - useCreateImageMutation, - useUpdateImageMutation, -} from 'src/queries/images'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; - -import { useImageAndLinodeGrantCheck } from './utils'; - -const useStyles = makeStyles()((theme: Theme) => ({ - actionPanel: { - marginTop: theme.spacing(2), - }, - helperText: { - paddingTop: theme.spacing(0.5), - }, - root: {}, - suffix: { - fontSize: '.9rem', - marginRight: theme.spacing(1), - }, -})); - -export interface Props { - changeDescription: (e: React.ChangeEvent) => void; - changeDisk: (disk: null | string) => void; - changeLabel: (e: React.ChangeEvent) => void; - changeLinode: (linodeId: number) => void; - description?: string; - // Only used from LinodeDisks to pre-populate the selected Disk - disks?: Disk[]; - imageId?: string; - label?: string; - mode: DrawerMode; - onClose: () => void; - open: boolean; - selectedDisk: null | string; - selectedLinode: null | number; -} - -type CombinedProps = Props; - -export type DrawerMode = 'closed' | 'create' | 'edit' | 'imagize' | 'restore'; - -const createImageText = 'Create Image'; - -const titleMap: Record = { - closed: '', - create: createImageText, - edit: 'Edit Image', - imagize: createImageText, - restore: 'Restore from Image', -}; - -const buttonTextMap: Record = { - closed: '', - create: createImageText, - edit: 'Save Changes', - imagize: createImageText, - restore: 'Restore Image', -}; - -export const ImagesDrawer = (props: CombinedProps) => { - const { - changeDescription, - changeDisk, - changeLabel, - changeLinode, - description, - imageId, - label, - mode, - onClose, - open, - selectedDisk, - selectedLinode, - } = props; - - const { classes } = useStyles(); - const { enqueueSnackbar } = useSnackbar(); - const history = useHistory(); - const { - canCreateImage, - permissionedLinodes: availableLinodes, - } = useImageAndLinodeGrantCheck(); - - const { checkForNewEvents } = useEventsPollingActions(); - - const [mounted, setMounted] = React.useState(false); - const [notice, setNotice] = React.useState(undefined); - const [submitting, setSubmitting] = React.useState(false); - const [errors, setErrors] = React.useState(undefined); - - const [disks, setDisks] = React.useState([]); - - const { mutateAsync: updateImage } = useUpdateImageMutation(); - const { mutateAsync: createImage } = useCreateImageMutation(); - - React.useEffect(() => { - setMounted(true); - - if (props.disks) { - // for the 'imagizing' mode - setDisks(props.disks); - } - - return () => { - setMounted(false); - }; - }, [props.disks]); - - React.useEffect(() => { - if (!selectedLinode) { - setDisks([]); - } - - if (selectedLinode) { - getLinodeDisks(selectedLinode) - .then((response) => { - if (!mounted) { - return; - } - - const filteredDisks = response.data.filter( - (disk) => disk.filesystem !== 'swap' - ); - if (!equals(disks, filteredDisks)) { - setDisks(filteredDisks); - } - }) - .catch((_) => { - if (!mounted) { - return; - } - - if (mounted) { - setErrors([ - { - field: 'disk_id', - reason: 'Could not retrieve disks for this Linode.', - }, - ]); - } - }); - } - }, [selectedLinode]); - - const handleLinodeChange = (linodeID: number) => { - // Clear any errors - setErrors(undefined); - changeLinode(linodeID); - }; - - const handleDiskChange = (diskID: null | string) => { - // Clear any errors - setErrors(undefined); - changeDisk(diskID); - }; - - const close = () => { - onClose(); - if (mounted) { - setErrors(undefined); - setNotice(undefined); - setSubmitting(false); - } - }; - - 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 }) - .then(() => { - if (!mounted) { - return; - } - - close(); - }) - .catch((errorResponse: APIError[]) => { - if (!mounted) { - return; - } - - setSubmitting(false); - setErrors( - getAPIErrorOrDefault(errorResponse, 'Unable to edit Image') - ); - }); - return; - - case 'create': - case 'imagize': - createImage({ - description: safeDescription, - disk_id: Number(selectedDisk), - label, - }) - .then(() => { - if (!mounted) { - return; - } - - checkForNewEvents(); - - setSubmitting(false); - - close(); - - enqueueSnackbar('Image scheduled for creation.', { - variant: 'info', - }); - }) - .catch((errorResponse: APIError[]) => { - if (!mounted) { - return; - } - - setSubmitting(false); - setErrors( - getAPIErrorOrDefault( - errorResponse, - 'There was an error creating the image.' - ) - ); - }); - 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 checkRequirements = () => { - // When creating an image, disable the submit button until a Linode and - // disk are selected. When restoring to an existing Linode, the Linode select is the only field. - // When imagizing, the Linode is selected already so only check for a disk selection. - const isDiskSelected = Boolean(selectedDisk); - - switch (mode) { - case 'create': - return !(isDiskSelected && selectedLinode); - case 'imagize': - return !isDiskSelected; - case 'restore': - return !selectedLinode; - default: - return false; - } - }; - - const requirementsMet = checkRequirements(); - - 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 diskError = hasErrorFor('disk_id'); - - return ( - - {!canCreateImage ? ( - - ) : null} - {generalError && ( - - )} - - {notice && } - - {['create', 'restore'].includes(mode) && ( - { - if (linode !== null) { - handleLinodeChange(linode.id); - } - }} - optionsFilter={(linode) => - availableLinodes ? availableLinodes.includes(linode.id) : true - } - clearable={false} - disabled={!canCreateImage} - errorText={linodeError} - value={selectedLinode} - /> - )} - - {['create', 'imagize'].includes(mode) && ( - <> - - - Linode Images are limited to {IMAGE_DEFAULT_LIMIT} MB of data per - disk by default. Please ensure that your disk content does not - exceed this size limit, or open a Support ticket to request a higher - limit. Additionally, Linode Images cannot be created if you are - using raw disks or disks that have been formatted using custom - filesystems. - - - )} - - {['create', 'edit', 'imagizing'].includes(mode) && ( - - - - - )} - - - - ); -}; 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..b02932582f1 --- /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/1', { + 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..09c2d02e8b2 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -0,0 +1,161 @@ +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 { 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; + open: boolean; +} +export const EditImageDrawer = (props: Props) => { + const { image, onClose, open } = props; + + const { canCreateImage } = useImageAndLinodeGrantCheck(); + + const defaultValues = { + description: image?.description ?? undefined, + label: image?.label, + tags: image?.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/ImageRegions/ImageRegionRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx new file mode 100644 index 00000000000..7c8d8bd17f8 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx @@ -0,0 +1,42 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { regionFactory } from 'src/factories/regions'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ImageRegionRow } from './ImageRegionRow'; + +describe('ImageRegionRow', () => { + it('renders a region label and status', async () => { + const region = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByText } = renderWithTheme( + + ); + + expect(getByText('creating')).toBeVisible(); + expect(await findByText('Newark, NJ')).toBeVisible(); + }); + + it('calls onRemove when the remove button is clicked', async () => { + const onRemove = vi.fn(); + + const { getByLabelText } = renderWithTheme( + + ); + + const removeButton = getByLabelText('Remove us-east'); + + await userEvent.click(removeButton); + + expect(onRemove).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx new file mode 100644 index 00000000000..a3a1ccd292b --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx @@ -0,0 +1,64 @@ +import Close from '@mui/icons-material/Close'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { Flag } from 'src/components/Flag'; +import { IconButton } from 'src/components/IconButton'; +import { Stack } from 'src/components/Stack'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { ImageRegionStatus } from '@linode/api-v4'; +import type { Status } from 'src/components/StatusIcon/StatusIcon'; + +type ExtendedImageRegionStatus = 'unsaved' | ImageRegionStatus; + +interface Props { + onRemove: () => void; + region: string; + status: ExtendedImageRegionStatus; +} + +export const ImageRegionRow = (props: Props) => { + const { onRemove, region, status } = props; + + const { data: regions } = useRegionsQuery(); + + const actualRegion = regions?.find((r) => r.id === region); + + return ( + + + + {actualRegion?.label ?? region} + + + {status} + + + + + + + ); +}; + +const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Readonly< + Record +> = { + available: 'active', + creating: 'other', + pending: 'other', + 'pending deletion': 'other', + 'pending replication': 'inactive', + replicating: 'other', + timedout: 'inactive', + unsaved: 'inactive', +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx new file mode 100644 index 00000000000..c3623e4d789 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx @@ -0,0 +1,108 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { imageFactory, 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 { ManageImageRegionsForm } from './ManageImageRegionsForm'; + +describe('ManageImageRegionsDrawer', () => { + it('should render a save button and a cancel button', () => { + const image = imageFactory.build(); + const { getByText } = renderWithTheme( + + ); + + const cancelButton = getByText('Cancel').closest('button'); + const saveButton = getByText('Save').closest('button'); + + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + expect(saveButton).toBeVisible(); + expect(saveButton).toBeDisabled(); // The save button should become enabled when regions are changed + }); + + it('should render existing regions and their statuses', async () => { + const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + { + region: 'us-west', + status: 'pending replication', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText } = renderWithTheme( + + ); + + await findByText('Newark, NJ'); + await findByText('available'); + await findByText('Place, CA'); + await findByText('pending replication'); + }); + + it('should render a status of "unsaved" when a new region is selected', async () => { + const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText, getByLabelText, getByText } = renderWithTheme( + + ); + + const saveButton = getByText('Save').closest('button'); + + expect(saveButton).toBeVisible(); + + // Verify the save button is disabled because no changes have been made + expect(saveButton).toBeDisabled(); + + const regionSelect = getByLabelText('Add Regions'); + + // Open the Region Select + await userEvent.click(regionSelect); + + // Select new region + await userEvent.click(await findByText('us-west', { exact: false })); + + // Close the Region Multi-Select to that selections are committed to the list + await userEvent.type(regionSelect, '{escape}'); + + expect(getByText('Place, CA')).toBeVisible(); + expect(getByText('unsaved')).toBeVisible(); + + // Verify the save button is enabled because changes have been made + expect(saveButton).toBeEnabled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx new file mode 100644 index 00000000000..f50c82a36aa --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx @@ -0,0 +1,150 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { updateImageRegionsSchema } from '@linode/validation'; +import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useUpdateImageRegionsMutation } from 'src/queries/images'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import { ImageRegionRow } from './ImageRegionRow'; + +import type { Image, UpdateImageRegionsPayload } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + onClose: () => void; +} + +export const ManageImageRegionsForm = (props: Props) => { + const { image, onClose } = props; + + const imageRegionIds = image?.regions.map(({ region }) => region) ?? []; + + const { enqueueSnackbar } = useSnackbar(); + const { data: regions } = useRegionsQuery(); + const { mutateAsync } = useUpdateImageRegionsMutation(image?.id ?? ''); + + const [selectedRegions, setSelectedRegions] = useState([]); + + const { + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + setError, + setValue, + watch, + } = useForm({ + defaultValues: { regions: imageRegionIds }, + resolver: yupResolver(updateImageRegionsSchema), + values: { regions: imageRegionIds }, + }); + + const onSubmit = async (data: UpdateImageRegionsPayload) => { + try { + await mutateAsync(data); + + enqueueSnackbar('Image regions successfully updated.', { + variant: 'success', + }); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }; + + const values = watch(); + + return ( +
    + {errors.root?.message && ( + + )} + + Custom images are billed monthly, at $.10/GB. Check out{' '} + + this guide + {' '} + for details on managing your Linux system's disk space. + + { + setValue('regions', [...values.regions, ...selectedRegions], { + shouldDirty: true, + shouldValidate: true, + }); + setSelectedRegions([]); + }} + regions={(regions ?? []).filter( + (r) => !values.regions.includes(r.id) && r.site_type === 'core' + )} + currentCapability={undefined} + errorText={errors.regions?.message} + label="Add Regions" + onChange={setSelectedRegions} + placeholder="Select Regions" + selectedIds={selectedRegions} + /> + + Image will be available in these regions ({values.regions.length}) + + ({ + backgroundColor: theme.palette.background.paper, + p: 2, + py: 1, + })} + variant="outlined" + > + + {values.regions.length === 0 && ( + + No Regions Selected + + )} + {values.regions.map((regionId) => ( + + setValue( + 'regions', + values.regions.filter((r) => r !== regionId), + { shouldDirty: true, shouldValidate: true } + ) + } + status={ + image?.regions.find( + (regionItem) => regionItem.region === regionId + )?.status ?? 'unsaved' + } + key={regionId} + region={regionId} + /> + ))} + + + + + ); +}; 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..2d09bb8cbbc --- /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 62% rename from packages/manager/src/features/Images/ImageRow.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index e3eee26d2fc..1c3c07dacc2 100644 --- a/packages/manager/src/features/Images/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -1,42 +1,54 @@ -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'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +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 {} +export const ImageRow = (props: Props) => { + const { event, handlers, image, multiRegionsEnabled } = props; -const ImageRow = (props: 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} + + )} - + ); @@ -137,5 +171,3 @@ const ProgressDisplay: React.FC<{ ); }; - -export default React.memo(ImageRow); diff --git a/packages/manager/src/features/Images/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx similarity index 58% rename from packages/manager/src/features/Images/ImagesActionMenu.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index 357ab4a97b3..90bd29494c3 100644 --- a/packages/manager/src/features/Images/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -1,15 +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) => void; - onRestore?: (imageID: string) => void; + onEdit?: (image: Image) => void; + onManageRegions?: (image: Image) => void; + onRestore?: (image: Image) => void; onRetry?: ( imageID: string, label: string, @@ -17,28 +19,26 @@ export interface Handlers { ) => void; } -interface Props extends Handlers { - description: null | string; - event: Event | undefined; - id: string; - label: string; - status?: ImageStatus; +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, - } = props; + } = handlers; const actions: Action[] = React.useMemo(() => { const isDisabled = status && status !== 'available'; @@ -47,34 +47,35 @@ export const ImagesActionMenu = (props: Props) => { return isFailed ? [ { - onClick: () => { - onRetry?.(id, label, description); - }, + onClick: () => onRetry?.(id, label, description), title: 'Retry', }, { - onClick: () => { - onCancelFailed?.(id); - }, + onClick: () => onCancelFailed?.(id), title: 'Cancel', }, ] : [ { disabled: isDisabled, - onClick: () => { - onEdit?.(label, description ?? ' ', id); - }, + 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); - }, + onClick: () => onDeploy?.(id), title: 'Deploy to New Linode', tooltip: isDisabled ? 'Image is not yet available for use.' @@ -82,39 +83,37 @@ 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.' : undefined, }, { - onClick: () => { - onDelete?.(label, id, status); - }, - title: isAvailable ? 'Delete' : 'Cancel Upload', + onClick: () => onDelete?.(label, id, status), + title: isAvailable ? 'Delete' : 'Cancel', }, ]; }, [ status, - description, + event, + onRetry, id, label, - onDelete, - onRestore, - onDeploy, - onEdit, - onRetry, + description, onCancelFailed, - event, + 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..f0e753fe8b1 --- /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('Rebuild an Existing Linode from an 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 65% rename from packages/manager/src/features/Images/ImagesLanding.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 48f10fa6da6..28d72c8abb5 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -1,19 +1,20 @@ -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'; import { CircleProgress } from 'src/components/CircleProgress'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { Drawer } from 'src/components/Drawer'; 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'; @@ -25,10 +26,11 @@ 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 { 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 { ManageImageRegionsForm } from './ImageRegions/ManageImageRegionsForm'; +import { ImageRow } from './ImageRow'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; +import { RebuildImageDrawer } from './RebuildImageDrawer'; + +import type { Handlers as ImageHandlers } from './ImagesActionMenu'; +import type { ImageStatus } from '@linode/api-v4'; +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; - selectedDisk: null | string; - selectedLinode?: number; -} - interface ImageDialogState { error?: string; image?: string; @@ -79,16 +79,6 @@ interface ImageDialogState { submitting: boolean; } -interface ImagesLandingProps extends ImageDrawerState, ImageDialogState {} - -const defaultDrawerState = { - description: '', - label: '', - mode: 'edit' as DrawerMode, - open: false, - selectedDisk: null, -}; - 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,35 @@ 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 [selectedImageId, setSelectedImageId] = React.useState(); + + const [ + isManageRegionsDrawerOpen, + setIsManageRegionsDrawerOpen, + ] = React.useState(false); + const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); + const [isRebuildDrawerOpen, setIsRebuildDrawerOpen] = React.useState(false); + + const selectedImage = + manualImages?.data.find((i) => i.id === selectedImageId) ?? + automaticImages?.data.find((i) => i.id === selectedImageId); const [dialog, setDialogState] = React.useState( defaultDialogState @@ -290,26 +309,6 @@ export const ImagesLanding: React.FC = () => { queryClient.invalidateQueries(imageQueries.paginated._def); }; - const openForEdit = (label: string, description: string, imageID: string) => { - setDrawer({ - description, - imageID, - label, - mode: 'edit', - open: true, - selectedDisk: null, - }); - }; - - const openForRestore = (imageID: string) => { - setDrawer({ - imageID, - mode: 'restore', - open: true, - selectedDisk: null, - }); - }; - const deployNewLinode = (imageID: string) => { history.push({ pathname: `/linodes/create/`, @@ -318,136 +317,60 @@ 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 getActions = () => { - return ( - - ); + const resetSearch = () => { + queryParams.delete(searchQueryKey); + history.push({ search: queryParams.toString() }); }; - const closeImageDrawer = () => { - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - open: false, - })); - }; - - 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: (image) => { + setSelectedImageId(image.id); + setIsEditDrawerOpen(true); + }, + onManageRegions: multiRegionsEnabled + ? (image) => { + setSelectedImageId(image.id); + setIsManageRegionsDrawerOpen(true); + } + : undefined, + onRestore: (image) => { + setSelectedImageId(image.id); + setIsRebuildDrawerOpen(true); + }, onRetry: onRetryClick, }; - const renderError = (_: APIError[]) => { + if (manualImagesLoading || automaticImagesLoading) { + return ; + } + + if (manualImagesError || automaticImagesError) { return ( ); - }; - - const renderLoading = () => { - return ; - }; - - const renderEmpty = () => { - return ; - }; - - if (manualImagesLoading || automaticImagesLoading) { - return renderLoading(); - } - - /** Error State */ - if (manualImagesError) { - return renderError(manualImagesError); - } - - if (automaticImagesError) { - return renderError(automaticImagesError); } - /** Empty States */ if ( - (!manualImagesData || manualImagesData.length === 0) && - (!automaticImagesData || automaticImagesData.length === 0) + manualImages.results === 0 && + automaticImages.results === 0 && + !imageLabelFromParam ) { - return renderEmpty(); + return ; } - const noManualImages = ( - - ); - - const noAutomaticImages = ( - - ); + const isFetching = manualImagesIsFetching || automaticImagesIsFetching; return ( @@ -458,6 +381,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 @@ -480,7 +429,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) => ( - - )) - : noManualImages} + {manualImages.results === 0 && ( + + )} + {manualImages.data.map((manualImage) => ( + + ))} = () => { - {automaticImagesData.length > 0 - ? automaticImagesData.map((automaticImage) => ( - - )) - : noAutomaticImages} + {automaticImages.results === 0 && ( + + )} + {automaticImages.data.map((automaticImage) => ( + + ))} = () => { pageSize={paginationForAutomaticImages.pageSize} /> - {renderImageDrawer()} + setIsEditDrawerOpen(false)} + open={isEditDrawerOpen} + /> + setIsRebuildDrawerOpen(false)} + open={isRebuildDrawerOpen} + /> + setIsManageRegionsDrawerOpen(false)} + open={isManageRegionsDrawerOpen} + title={`Manage Regions for ${selectedImage?.label}`} + > + setIsManageRegionsDrawerOpen(false)} + /> + + } title={ dialogAction === 'cancel' ? 'Cancel Upload' : `Delete Image ${dialog.image}` } - actions={getActions} onClose={closeDialog} open={dialog.open} > @@ -608,27 +622,3 @@ export const ImagesLanding: React.FC = () => { }; 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..b2ddbb5aa01 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx @@ -0,0 +1,59 @@ +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('Rebuild an Existing Linode from an Image'); + + // Verify image label is displayed + getByText(props.image.label); + }); + + 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('Rebuild Linode')); + + expect(mockHistoryPush).toBeCalledWith({ + pathname: '/linodes/1/rebuild', + search: 'selectedImageId=private%2F1', + }); + }); +}); 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..dc2bf134a93 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -0,0 +1,117 @@ +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 { DescriptionList } from 'src/components/DescriptionList/DescriptionList'; +import { Divider } from 'src/components/Divider'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; +import { Stack } from 'src/components/Stack'; +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; + open: boolean; +} + +export const RebuildImageDrawer = (props: Props) => { + const { image, onClose, open } = 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} + placeholder="Select Linode or Type to Search" + 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..eba0b26ca23 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', () => { @@ -13,6 +13,7 @@ describe('getImageLabelForLinode', () => { }); expect(getImageLabelForLinode(linode, images)).toBe('Cool Image'); }); + it('falls back to the linodes image id if there is no match in the images array', () => { const linode = linodeFactory.build({ image: 'public/cool-image', @@ -23,6 +24,7 @@ describe('getImageLabelForLinode', () => { }); expect(getImageLabelForLinode(linode, images)).toBe('public/cool-image'); }); + it('returns null if the linode does not have an image', () => { const linode = linodeFactory.build({ image: null, @@ -31,3 +33,25 @@ 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: 1 }, + }); + const failedEvent = eventFactory.build({ + entity: { id: 2 }, + status: 'failed', + }); + const unrelatedEvent = eventFactory.build(); + + expect( + getEventsForImages(images, [successfulEvent, failedEvent, unrelatedEvent]) + ).toEqual({ + ['private/1']: successfulEvent, + ['private/2']: failedEvent, + }); + }); +}); diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index cb7b2efed85..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'; +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/KubernetesClusterRow.test.tsx b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx index f914a962fd2..8a9d945d3b2 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx @@ -3,10 +3,12 @@ import * as React from 'react'; import { kubernetesClusterFactory, regionFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { wrapWithTableBody, wrapWithTheme } from 'src/utilities/testHelpers'; -import { KubernetesClusterRow, Props } from './KubernetesClusterRow'; +import { KubernetesClusterRow } from './KubernetesClusterRow'; + +import type { Props } from './KubernetesClusterRow'; const cluster = kubernetesClusterFactory.build({ region: 'us-central' }); @@ -36,11 +38,11 @@ describe('ClusterRow component', () => { }) ); - const { getByText, findByText } = render( + const { findByText, getByText } = render( wrapWithTableBody() ); - getByText('cluster-0'); + getByText('cluster-1'); await findByText('Fake Region, NC'); }); diff --git a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx index 8f3c3177a3d..966a9d37aac 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx @@ -1,8 +1,8 @@ import { KubeNodePoolResponse, KubernetesCluster } from '@linode/api-v4'; import Grid from '@mui/material/Unstable_Grid2'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; import { Link } from 'react-router-dom'; +import { makeStyles } from 'tss-react/mui'; import { Chip } from 'src/components/Chip'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; @@ -83,7 +83,6 @@ export const KubernetesClusterRow = (props: Props) => { return ( { 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..f9770335db0 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx @@ -1,15 +1,14 @@ -import { waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; -import { regionFactory } from 'src/factories'; +import { regionFactory, typeFactory } 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 +16,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), @@ -32,13 +31,23 @@ const renderComponent = (_props: Props) => renderWithTheme(); describe('KubeCheckoutBar', () => { + beforeAll(() => { + vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useSpecificTypes: vi + .fn() + .mockImplementation(() => [{ data: typeFactory.build() }]), + }; + }); + }); + it('should render helper text and disable create button until a region has been selected', async () => { - const { findByText, getByTestId, getByText } = renderWithTheme( - + const { findByText, getByText } = renderWithTheme( + ); - await waitForElementToBeRemoved(getByTestId('circle-progress')); - await findByText(LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE); expect(getByText('Create Cluster').closest('button')).toHaveAttribute( 'aria-disabled', @@ -47,9 +56,7 @@ describe('KubeCheckoutBar', () => { }); it('should render a section for each pool', async () => { - const { getByTestId, queryAllByTestId } = renderComponent(props); - - await waitForElementToBeRemoved(getByTestId('circle-progress')); + const { queryAllByTestId } = renderComponent(props); expect(queryAllByTestId('node-pool-summary')).toHaveLength(pools.length); }); @@ -84,12 +91,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 - await findByText(/\$180\.00/); + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA + await findByText(/\$183\.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(/\$255\.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(/\$183\.00/); + getByText(/\$--.--\/month/); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx index 874229f9a5a..c2cfb96908a 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx @@ -1,17 +1,15 @@ -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'; import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; import { useAccountAgreements } from 'src/queries/account/agreements'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { getGDPRDetails } from 'src/utilities/formatRegion'; @@ -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/NodePoolsDisplay/NodePool.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx index ed4ff6d7878..3d2a6e72708 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx @@ -1,7 +1,3 @@ -import { - AutoscaleSettings, - PoolNodeResponse, -} from '@linode/api-v4/lib/kubernetes'; import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; @@ -13,8 +9,15 @@ import { Typography } from 'src/components/Typography'; import { NodeTable } from './NodeTable'; +import type { + AutoscaleSettings, + PoolNodeResponse, +} from '@linode/api-v4/lib/kubernetes'; +import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; + interface Props { autoscaler: AutoscaleSettings; + encryptionStatus: EncryptionStatus | undefined; handleClickResize: (poolId: number) => void; isOnlyNodePool: boolean; nodes: PoolNodeResponse[]; @@ -35,14 +38,14 @@ const useStyles = makeStyles()((theme: Theme) => ({ paddingRight: 8, }, deletePoolBtn: { - marginBottom: 3, paddingRight: 8, }, })); -const NodePool: React.FC = (props) => { +export const NodePool = (props: Props) => { const { autoscaler, + encryptionStatus, handleClickResize, isOnlyNodePool, nodes, @@ -126,6 +129,7 @@ const NodePool: React.FC = (props) => { xs={12} > = (props) => { ); }; - -export default NodePool; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx index e4f5d34f542..97cb7c652e8 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx @@ -1,14 +1,14 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; +import Grid from '@mui/material/Unstable_Grid2'; import React, { useState } from 'react'; import { Waypoint } from 'react-waypoint'; +import { makeStyles } from 'tss-react/mui'; import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { Typography } from 'src/components/Typography'; import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; import { useAllKubernetesNodePoolQuery } from 'src/queries/kubernetes'; import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; @@ -18,7 +18,7 @@ import { RecycleNodePoolDialog } from '../RecycleNodePoolDialog'; import { AddNodePoolDrawer } from './AddNodePoolDrawer'; import { AutoscalePoolDialog } from './AutoscalePoolDialog'; import { DeleteNodePoolDialog } from './DeleteNodePoolDialog'; -import NodePool from './NodePool'; +import { NodePool } from './NodePool'; import { RecycleNodeDialog } from './RecycleNodeDialog'; import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; @@ -152,7 +152,7 @@ export const NodePoolsDisplay = (props: Props) => { {_pools?.map((thisPool) => { - const { id, nodes } = thisPool; + const { disk_encryption, id, nodes } = thisPool; const thisPoolType = types?.find( (thisType) => thisType.id === thisPool.type @@ -181,6 +181,7 @@ export const NodePoolsDisplay = (props: Props) => { setIsRecycleNodeOpen(true); }} autoscaler={thisPool.autoscaler} + encryptionStatus={disk_encryption} handleClickResize={handleOpenResizeDrawer} isOnlyNodePool={pools?.length === 1} nodes={nodes ?? []} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx index a41e1178f6e..27a278a2ec1 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx @@ -1,4 +1,3 @@ -import { APIError } from '@linode/api-v4/lib/types'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -13,6 +12,8 @@ import { useInProgressEvents } from 'src/queries/events/events'; import NodeActionMenu from './NodeActionMenu'; import { StyledCopyTooltip, StyledTableRow } from './NodeTable.styles'; +import type { APIError } from '@linode/api-v4/lib/types'; + export interface NodeRow { instanceId?: number; instanceStatus?: string; @@ -70,7 +71,7 @@ export const NodeRow = React.memo((props: NodeRowProps) => { const displayIP = ip ?? ''; return ( - + @@ -100,7 +101,7 @@ export const NodeRow = React.memo((props: NodeRowProps) => { )} - + {linodeError ? ( ({ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts index 482ab5b66fc..f272e64c72c 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts @@ -1,8 +1,10 @@ import { styled } from '@mui/material/styles'; +import VerticalDivider from 'src/assets/icons/divider-vertical.svg'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Table } from 'src/components/Table'; import { TableRow } from 'src/components/TableRow'; +import { Typography } from 'src/components/Typography'; export const StyledTableRow = styled(TableRow, { label: 'TableRow', @@ -19,7 +21,6 @@ export const StyledTableRow = styled(TableRow, { opacity: 1, }, marginLeft: 4, - top: 1, })); export const StyledTable = styled(Table, { @@ -40,3 +41,15 @@ export const StyledCopyTooltip = styled(CopyTooltip, { marginLeft: 4, top: 1, })); + +export const StyledVerticalDivider = styled(VerticalDivider, { + label: 'StyledVerticalDivider', +})(({ theme }) => ({ + margin: `0 ${theme.spacing(2)}`, +})); + +export const StyledTypography = styled(Typography, { + label: 'StyledTypography', +})(({ theme }) => ({ + margin: `0 0 0 ${theme.spacing()}`, +})); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index 4298d833767..49216784a3f 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -4,13 +4,14 @@ import { kubeLinodeFactory } from 'src/factories/kubernetesCluster'; import { linodeFactory } from 'src/factories/linodes'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { NodeTable, Props } from './NodeTable'; +import { NodeTable, Props, encryptionStatusTestId } from './NodeTable'; const mockLinodes = linodeFactory.buildList(3); const mockKubeNodes = kubeLinodeFactory.buildList(3); const props: Props = { + encryptionStatus: 'enabled', nodes: mockKubeNodes, openRecycleNodeDialog: vi.fn(), poolId: 1, @@ -20,6 +21,29 @@ const props: Props = { beforeAll(() => linodeFactory.resetSequenceNumber()); describe('NodeTable', () => { + const mocks = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; + }); + + vi.mock('src/components/DiskEncryption/utils.ts', async () => { + const actual = await vi.importActual( + 'src/components/DiskEncryption/utils.ts' + ); + return { + ...actual, + __esModule: true, + useIsDiskEncryptionFeatureEnabled: mocks.useIsDiskEncryptionFeatureEnabled.mockImplementation( + () => { + return { + isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent + }; + } + ), + }; + }); + it('includes label, status, and IP columns', () => { const { findByText } = renderWithTheme(); mockLinodes.forEach(async (thisLinode) => { @@ -28,8 +52,32 @@ describe('NodeTable', () => { await findByText('Ready'); }); }); + it('includes the Pool ID', () => { const { getByText } = renderWithTheme(); getByText('Pool ID 1'); }); + + it('does not display the encryption status of the pool if the account lacks the capability or the feature flag is off', () => { + // situation where isDiskEncryptionFeatureEnabled === false + const { queryByTestId } = renderWithTheme(); + const encryptionStatusFragment = queryByTestId(encryptionStatusTestId); + + expect(encryptionStatusFragment).not.toBeInTheDocument(); + }); + + it('displays the encryption status of the pool if the feature flag is on and the account has the capability', () => { + mocks.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce(() => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + }); + + const { queryByTestId } = renderWithTheme(); + const encryptionStatusFragment = queryByTestId(encryptionStatusTestId); + + expect(encryptionStatusFragment).toBeInTheDocument(); + + mocks.useIsDiskEncryptionFeatureEnabled.mockRestore(); + }); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index abb227b83c6..3d27a03c5a7 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -1,6 +1,10 @@ -import { PoolNodeResponse } from '@linode/api-v4/lib/kubernetes'; import * as React from 'react'; +import Lock from 'src/assets/icons/lock.svg'; +import Unlock from 'src/assets/icons/unlock.svg'; +import { Box } from 'src/components/Box'; +import { DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY } from 'src/components/DiskEncryption/constants'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -11,26 +15,45 @@ import { TableFooter } from 'src/components/TableFooter'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { LinodeWithMaintenance } from 'src/utilities/linodes'; import { NodeRow as _NodeRow } from './NodeRow'; -import { StyledTable } from './NodeTable.styles'; +import { + StyledTable, + StyledTypography, + StyledVerticalDivider, +} from './NodeTable.styles'; import type { NodeRow } from './NodeRow'; +import type { PoolNodeResponse } from '@linode/api-v4/lib/kubernetes'; +import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; +import type { LinodeWithMaintenance } from 'src/utilities/linodes'; export interface Props { + encryptionStatus: EncryptionStatus | undefined; nodes: PoolNodeResponse[]; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; poolId: number; typeLabel: string; } +export const encryptionStatusTestId = 'encryption-status-fragment'; + export const NodeTable = React.memo((props: Props) => { - const { nodes, openRecycleNodeDialog, poolId, typeLabel } = props; + const { + encryptionStatus, + nodes, + openRecycleNodeDialog, + poolId, + typeLabel, + } = props; const { data: linodes, error, isLoading } = useAllLinodesQuery(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); const rowData = nodes.map((thisNode) => nodeToRow(thisNode, linodes ?? [])); @@ -65,7 +88,7 @@ export const NodeTable = React.memo((props: Props) => { ({ ...theme.applyTableHeaderStyles, - width: '35%', + width: '25%', })} active={orderBy === 'instanceStatus'} direction={order} @@ -77,7 +100,7 @@ export const NodeTable = React.memo((props: Props) => { ({ ...theme.applyTableHeaderStyles, - width: '15%', + width: '35%', })} active={orderBy === 'ip'} direction={order} @@ -116,7 +139,26 @@ export const NodeTable = React.memo((props: Props) => { - Pool ID {poolId} + {isDiskEncryptionFeatureEnabled && + encryptionStatus !== undefined ? ( + + Pool ID {poolId} + + + + ) : ( + Pool ID {poolId} + )} @@ -157,3 +199,26 @@ export const nodeToRow = ( nodeStatus: node.status, }; }; + +export const EncryptedStatus = ({ + encryptionStatus, + tooltipText, +}: { + encryptionStatus: EncryptionStatus; + tooltipText: string | undefined; +}) => { + return encryptionStatus === 'enabled' ? ( + <> + + Encrypted + + ) : encryptionStatus === 'disabled' ? ( + <> + + + Not Encrypted + + {tooltipText ? : null} + + ) : null; +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx index bd6e2ad6352..3bf7367eef6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx @@ -1,16 +1,28 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { nodePoolFactory } from 'src/factories/kubernetesCluster'; +import { nodePoolFactory, typeFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { Props, ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; +import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; + +import type { Props } from './ResizeNodePoolDrawer'; const pool = nodePoolFactory.build({ type: 'g6-standard-1', }); const smallPool = nodePoolFactory.build({ count: 2 }); +vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useSpecificTypes: vi + .fn() + .mockReturnValue([{ data: typeFactory.build({ label: 'Linode 1 GB' }) }]), + }; +}); + const props: Props = { kubernetesClusterId: 1, kubernetesRegionId: 'us-east', 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} + + + )} void; @@ -67,16 +67,21 @@ export const KubernetesPlansPanel = (props: Props) => { Boolean(flags.soldOutChips) && selectedRegionId !== undefined ); - const _types = replaceOrAppendPlaceholder512GbPlans(types); + const _types = types.filter( + (type) => + !type.id.includes('dedicated-edge') && !type.id.includes('nanode-edge') + ); + const plans = getPlanSelectionsByPlanType( - flags.disableLargestGbPlans ? _types : types + flags.disableLargestGbPlans + ? replaceOrAppendPlaceholder512GbPlans(_types) + : _types ); const tabs = Object.keys(plans).map((plan: LinodeTypeClass) => { const plansMap: PlanSelectionType[] = plans[plan]; const { allDisabledPlans, - hasDisabledPlans, hasMajorityOfPlansDisabled, plansForThisLinodeTypeClass, } = extractPlansInformation({ @@ -94,7 +99,7 @@ export const KubernetesPlansPanel = (props: Props) => { isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan( plan )} - hasDisabledPlans={hasDisabledPlans} + hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} hasSelectedRegion={hasSelectedRegion} planType={plan} regionsData={regionsData} diff --git a/packages/manager/src/features/Kubernetes/index.tsx b/packages/manager/src/features/Kubernetes/index.tsx index a25b9a50694..82aebd910fd 100644 --- a/packages/manager/src/features/Kubernetes/index.tsx +++ b/packages/manager/src/features/Kubernetes/index.tsx @@ -7,21 +7,29 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; const KubernetesLanding = React.lazy( () => 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/Access.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx deleted file mode 100644 index ce033562c43..00000000000 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; - -import UserSSHKeyPanel from 'src/components/AccessPanel/UserSSHKeyPanel'; -import { Divider } from 'src/components/Divider'; -import { Paper } from 'src/components/Paper'; -import { Skeleton } from 'src/components/Skeleton'; -import { inputMaxWidth } from 'src/foundations/themes/light'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; - -import type { CreateLinodeRequest } from '@linode/api-v4'; - -const PasswordInput = React.lazy( - () => import('src/components/PasswordInput/PasswordInput') -); - -export const Access = () => { - const { control } = useFormContext(); - - const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_linodes', - }); - - return ( - - } - > - ( - - )} - control={control} - name="root_pass" - /> - - - ( - - )} - control={control} - name="authorized_users" - /> - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx index 22c630358ba..8a996cf9417 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx @@ -1,14 +1,17 @@ -import { CreateLinodeRequest } from '@linode/api-v4'; import React, { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { ApiAwarenessModal } from '../LinodesCreate/ApiAwarenessModal/ApiAwarenessModal'; import { getLinodeCreatePayload } from './utilities'; +import type { CreateLinodeRequest } from '@linode/api-v4'; + export const Actions = () => { const [isAPIAwarenessModalOpen, setIsAPIAwarenessModalOpen] = useState(false); @@ -23,8 +26,12 @@ export const Actions = () => { }); const onOpenAPIAwareness = async () => { - if (await trigger(undefined, { shouldFocus: true })) { + sendApiAwarenessClickEvent('Button', 'Create Using Command Line'); + if (await trigger()) { + // If validation is successful, we open the dialog. setIsAPIAwarenessModalOpen(true); + } else { + scrollErrorIntoView(undefined, { behavior: 'smooth' }); } }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.test.tsx index deb0d69cbf5..3378359875f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.test.tsx @@ -19,8 +19,8 @@ describe('Linode Create v2 Addons', () => { expect(heading.tagName).toBe('H2'); }); - it('renders a warning if an edge region is selected', async () => { - const region = regionFactory.build({ site_type: 'edge' }); + it('renders a warning if a distributed region is selected', async () => { + const region = regionFactory.build({ site_type: 'distributed' }); server.use( http.get('*/v4/regions', () => { @@ -34,7 +34,7 @@ describe('Linode Create v2 Addons', () => { }); await findByText( - 'Backups and Private IP are currently not available for Edge regions.' + 'Backups and Private IP are currently not available for distributed regions.' ); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx index f7c33cfb1cf..de19fca6b22 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx @@ -23,15 +23,17 @@ export const Addons = () => { [regions, regionId] ); - const isEdgeRegionSelected = selectedRegion?.site_type === 'edge'; + const isDistributedRegionSelected = + selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge'; return ( Add-ons - {isEdgeRegionSelected && ( + {isDistributedRegionSelected && ( )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.test.tsx index 2a713062704..c22078b3730 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.test.tsx @@ -13,7 +13,7 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { Backups } from './Backups'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { LinodeCreateFormValues } from '../utilities'; describe('Linode Create V2 Backups Addon', () => { it('should render a label and checkbox', () => { @@ -30,7 +30,7 @@ describe('Linode Create V2 Backups Addon', () => { it('should get its value from the form context', () => { const { getByRole, - } = renderWithThemeAndHookFormContext({ + } = renderWithThemeAndHookFormContext({ component: , useFormOptions: { defaultValues: { backups_enabled: true } }, }); @@ -64,8 +64,8 @@ describe('Linode Create V2 Backups Addon', () => { expect(checkbox).toBeChecked(); }); - it('should be disabled if an edge region is selected', async () => { - const region = regionFactory.build({ site_type: 'edge' }); + it('should be disabled if a distributed region is selected', async () => { + const region = regionFactory.build({ site_type: 'distributed' }); server.use( http.get('*/v4/regions', () => { @@ -75,7 +75,7 @@ describe('Linode Create V2 Backups Addon', () => { const { getByRole, - } = renderWithThemeAndHookFormContext({ + } = renderWithThemeAndHookFormContext({ component: , useFormOptions: { defaultValues: { region: region.id } }, }); @@ -101,7 +101,7 @@ describe('Linode Create V2 Backups Addon', () => { const { getByRole, - } = renderWithThemeAndHookFormContext({ + } = renderWithThemeAndHookFormContext({ component: , }); @@ -111,4 +111,19 @@ describe('Linode Create V2 Backups Addon', () => { expect(checkbox).toBeDisabled(); }); }); + + it('renders a warning if disk encryption is enabled and backups are enabled', async () => { + const { + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { backups_enabled: true, disk_encryption: 'enabled' }, + }, + }); + + expect( + getByText('Virtual Machine Backups are not encrypted.') + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx index 77bbafdaa52..2f2a590e576 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx @@ -1,10 +1,12 @@ import React, { useMemo } from 'react'; -import { useController, useWatch } from 'react-hook-form'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; import { Checkbox } from 'src/components/Checkbox'; import { Currency } from 'src/components/Currency'; +import { DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY } from 'src/components/DiskEncryption/constants'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; @@ -15,20 +17,24 @@ import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { getBackupsEnabledValue } from './utilities'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { LinodeCreateFormValues } from '../utilities'; export const Backups = () => { - const { field } = useController({ + const { control } = useFormContext(); + const { field } = useController({ + control, name: 'backups_enabled', }); + const [regionId, typeId, diskEncryption] = useWatch({ + control, + name: ['region', 'type', 'disk_encryption'], + }); + const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', }); - const regionId = useWatch({ name: 'region' }); - const typeId = useWatch({ name: 'type' }); - const { data: type } = useTypeQuery(typeId, Boolean(typeId)); const { data: regions } = useRegionsQuery(); const { data: accountSettings } = useAccountSettings(); @@ -45,22 +51,25 @@ export const Backups = () => { const isAccountBackupsEnabled = accountSettings?.backups_enabled ?? false; - const isEdgeRegionSelected = selectedRegion?.site_type === 'edge'; + const isDistributedRegionSelected = + selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge'; + + const checked = getBackupsEnabledValue({ + accountBackupsEnabled: isAccountBackupsEnabled, + isDistributedRegion: isDistributedRegionSelected, + value: field.value, + }); return ( + Backups {backupsMonthlyPrice && ( @@ -69,6 +78,17 @@ export const Backups = () => {
    )} + {checked && diskEncryption === 'enabled' && ( + + )} {isAccountBackupsEnabled ? ( @@ -86,6 +106,7 @@ export const Backups = () => { } + checked={checked} control={} onChange={field.onChange} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.test.tsx index b974c672a47..feab56c9ce1 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.test.tsx @@ -37,8 +37,8 @@ describe('Linode Create V2 Private IP Add-on', () => { expect(checkbox).toBeChecked(); }); - it('should be disabled if an edge region is selected', async () => { - const region = regionFactory.build({ site_type: 'edge' }); + it('should be disabled if a distributed region is selected', async () => { + const region = regionFactory.build({ site_type: 'distributed' }); server.use( http.get('*/v4/regions', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.tsx index 8f6bace7b1a..9c640e5fb2f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.tsx @@ -28,7 +28,9 @@ export const PrivateIP = () => { [regions, regionId] ); - const isEdgeRegionSelected = selectedRegion?.site_type === 'edge'; + const isDistributedRegionSelected = + selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge'; return ( { } checked={field.value ?? false} control={} - disabled={isEdgeRegionSelected || isLinodeCreateRestricted} + disabled={isDistributedRegionSelected || isLinodeCreateRestricted} onChange={field.onChange} /> ); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.test.ts index 6b7bfa2bee4..3d7d4a6bd9e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.test.ts @@ -5,21 +5,21 @@ describe('getBackupsEnabledValue', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: true, - isEdgeRegion: false, + isDistributedRegion: false, value: false, }) ).toBe(true); expect( getBackupsEnabledValue({ accountBackupsEnabled: true, - isEdgeRegion: false, + isDistributedRegion: false, value: true, }) ).toBe(true); expect( getBackupsEnabledValue({ accountBackupsEnabled: true, - isEdgeRegion: false, + isDistributedRegion: false, value: undefined, }) ).toBe(true); @@ -29,14 +29,14 @@ describe('getBackupsEnabledValue', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: false, - isEdgeRegion: false, + isDistributedRegion: false, value: true, }) ).toBe(true); expect( getBackupsEnabledValue({ accountBackupsEnabled: false, - isEdgeRegion: false, + isDistributedRegion: false, value: false, }) ).toBe(false); @@ -46,7 +46,7 @@ describe('getBackupsEnabledValue', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: false, - isEdgeRegion: false, + isDistributedRegion: false, value: undefined, }) ).toBe(false); @@ -56,31 +56,31 @@ describe('getBackupsEnabledValue', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: undefined, - isEdgeRegion: false, + isDistributedRegion: false, value: false, }) ).toBe(false); expect( getBackupsEnabledValue({ accountBackupsEnabled: undefined, - isEdgeRegion: false, + isDistributedRegion: false, value: true, }) ).toBe(true); }); - it('should always return false if an edge region is selected becuase edge regions do not support backups', () => { + it('should always return false if a distributed region is selected because distributed regions do not support backups', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: undefined, - isEdgeRegion: true, + isDistributedRegion: true, value: true, }) ).toBe(false); expect( getBackupsEnabledValue({ accountBackupsEnabled: undefined, - isEdgeRegion: true, + isDistributedRegion: true, value: false, }) ).toBe(false); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.ts index 5eee1d33e93..fccad7aa0c7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.ts @@ -1,11 +1,11 @@ interface BackupsEnabledOptions { accountBackupsEnabled: boolean | undefined; - isEdgeRegion: boolean; + isDistributedRegion: boolean; value: boolean | undefined; } export const getBackupsEnabledValue = (options: BackupsEnabledOptions) => { - if (options.isEdgeRegion) { + if (options.isDistributedRegion) { return false; } diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx index 663c3462840..7d191db7f98 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx @@ -30,7 +30,6 @@ export const Details = () => { { + 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(); + }); + + it('should disable distributed regions if the selected image does not have the `distributed-images` capability', async () => { + const image = imageFactory.build({ capabilities: [] }); + + const distributedRegion = regionFactory.build({ + capabilities: ['Linodes'], + site_type: 'distributed', + }); + const coreRegion = regionFactory.build({ + capabilities: ['Linodes'], + site_type: 'core', + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json( + makeResourcePage([coreRegion, distributedRegion]) + ); + }), + http.get('*/v4/images/:id', () => { + return HttpResponse.json(image); + }) + ); + + const { + findByText, + getByLabelText, + } = renderWithThemeAndHookFormContext({ + component: , + options: { + MemoryRouter: { initialEntries: ['/linodes/create?type=Images'] }, + }, + useFormOptions: { + defaultValues: { + image: image.id, + }, + }, + }); + + const regionSelect = getByLabelText('Region'); + + await userEvent.click(regionSelect); + + const distributedRegionOption = await findByText(distributedRegion.id, { + exact: false, + }); + + expect(distributedRegionOption.closest('li')?.textContent).toContain( + 'The selected image cannot be deployed to a distributed region.' + ); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index 3371925f1f6..92aa0b7fae8 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -1,33 +1,178 @@ 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 { useImageQuery } from 'src/queries/images'; +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 { getDisabledRegions } from './Region.utils'; +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, selectedImage] = useWatch({ + control, + name: ['linode', 'image'], + }); + + const { data: image } = useImageQuery( + selectedImage ?? '', + Boolean(selectedImage) + ); + + 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' + ); + + const disabledRegions = getDisabledRegions({ + linodeCreateTab: params.type, + regions: regions ?? [], + selectedImage: image, + }); + return ( - + + + Region + + + + {showCrossDataCenterCloneWarning && ( + + theme.font.bold}> + {CROSS_DATA_CENTER_CLONE_WARNING} + + + )} + onChange(region)} + 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/Region.utils.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts new file mode 100644 index 00000000000..4fb2cd8fb7c --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts @@ -0,0 +1,40 @@ +import { imageFactory, regionFactory } from 'src/factories'; + +import { getDisabledRegions } from './Region.utils'; + +describe('getDisabledRegions', () => { + it('disables distributed regions if the selected image does not have the distributed capability', () => { + const distributedRegion = regionFactory.build({ site_type: 'distributed' }); + const coreRegion = regionFactory.build({ site_type: 'core' }); + + const image = imageFactory.build({ capabilities: [] }); + + const result = getDisabledRegions({ + linodeCreateTab: 'Images', + regions: [distributedRegion, coreRegion], + selectedImage: image, + }); + + expect(result).toStrictEqual({ + [distributedRegion.id]: { + reason: + 'The selected image cannot be deployed to a distributed region.', + }, + }); + }); + + it('does not disable any regions if the selected image has the distributed regions capability', () => { + const distributedRegion = regionFactory.build({ site_type: 'distributed' }); + const coreRegion = regionFactory.build({ site_type: 'core' }); + + const image = imageFactory.build({ capabilities: ['distributed-images'] }); + + const result = getDisabledRegions({ + linodeCreateTab: 'Images', + regions: [distributedRegion, coreRegion], + selectedImage: image, + }); + + expect(result).toStrictEqual({}); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts new file mode 100644 index 00000000000..d611a82971f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts @@ -0,0 +1,42 @@ +import type { LinodeCreateType } from '../LinodesCreate/types'; +import type { Image, Region } from '@linode/api-v4'; +import type { DisableRegionOption } from 'src/components/RegionSelect/RegionSelect.types'; + +interface DisabledRegionOptions { + linodeCreateTab: LinodeCreateType | undefined; + regions: Region[]; + selectedImage: Image | undefined; +} + +/** + * Returns regions that should be disabled on the Linode Create flow. + * + * @returns key/value pairs for disabled regions. the key is the region id and the value is why the region is disabled + */ +export const getDisabledRegions = (options: DisabledRegionOptions) => { + const { linodeCreateTab, regions, selectedImage } = options; + + // On the images tab, we disabled distributed regions if: + // - The user has selected an Image + // - The selected image does not have the `distributed-images` capability + if ( + linodeCreateTab === 'Images' && + selectedImage && + !selectedImage.capabilities.includes('distributed-images') + ) { + const disabledRegions: Record = {}; + + for (const region of regions) { + if (region.site_type === 'distributed') { + disabledRegions[region.id] = { + reason: + 'The selected image cannot be deployed to a distributed region.', + }; + } + } + + return disabledRegions; + } + + return {}; +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx similarity index 61% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx index 303005d5803..acd014cd307 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx @@ -1,20 +1,27 @@ import { waitFor } from '@testing-library/react'; import React from 'react'; -import { profileFactory, sshKeyFactory } from 'src/factories'; +import { + accountFactory, + profileFactory, + regionFactory, + sshKeyFactory, +} from 'src/factories'; import { grantsFactory } from 'src/factories/grants'; 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'; -describe('Access', () => { +import type { LinodeCreateFormValues } from './utilities'; + +describe('Security', () => { it( 'should render a root password input', async () => { const { findByLabelText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const rootPasswordInput = await findByLabelText('Root Password'); @@ -27,7 +34,7 @@ describe('Access', () => { it('should render a SSH Keys heading', async () => { const { getAllByText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const heading = getAllByText('SSH Keys')[0]; @@ -38,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'); @@ -63,7 +70,7 @@ describe('Access', () => { ); const { findByLabelText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const rootPasswordInput = await findByLabelText('Root Password'); @@ -90,7 +97,7 @@ describe('Access', () => { ); const { findByText, getByRole } = renderWithThemeAndHookFormContext({ - component: , + component: , }); // Make sure the restricted user's SSH keys are loaded @@ -103,4 +110,53 @@ describe('Access', () => { expect(getByRole('checkbox')).toBeDisabled(); }); }); + + it('should show Linode disk encryption if the flag is on and the account has the capability', async () => { + server.use( + http.get('*/v4/account', () => { + return HttpResponse.json( + accountFactory.build({ capabilities: ['Disk Encryption'] }) + ); + }) + ); + + const { findByText } = renderWithThemeAndHookFormContext({ + component: , + options: { flags: { linodeDiskEncryption: true } }, + }); + + const heading = await findByText('Disk Encryption'); + + expect(heading).toBeVisible(); + expect(heading.tagName).toBe('H3'); + }); + + it('should disable disk encryption if the selected region does not support it', async () => { + const region = regionFactory.build({ + capabilities: [], + }); + + const account = accountFactory.build({ capabilities: ['Disk Encryption'] }); + + server.use( + http.get('*/v4/account', () => { + return HttpResponse.json(account); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { + findByLabelText, + } = renderWithThemeAndHookFormContext({ + component: , + options: { flags: { linodeDiskEncryption: true } }, + useFormOptions: { defaultValues: { region: region.id } }, + }); + + await findByLabelText( + '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/Security.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx new file mode 100644 index 00000000000..916632806b6 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +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'; +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'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +const PasswordInput = React.lazy( + () => import('src/components/PasswordInput/PasswordInput') +); + +export const Security = () => { + const { control } = useFormContext(); + + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const { data: regions } = useRegionsQuery(); + const regionId = useWatch({ control, name: 'region' }); + + const selectedRegion = regions?.find((r) => r.id === regionId); + + const regionSupportsDiskEncryption = selectedRegion?.capabilities.includes( + 'Disk Encryption' + ); + + const isDistributedRegion = getIsDistributedRegion( + regions ?? [], + selectedRegion?.id ?? '' + ); + + const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_linodes', + }); + + return ( + + + Security + + } + > + ( + + )} + control={control} + name="root_pass" + /> + + + ( + + )} + control={control} + name="authorized_users" + /> + {isDiskEncryptionFeatureEnabled && ( + <> + + ( + + field.onChange(checked ? 'enabled' : 'disabled') + } + disabled={!regionSupportsDiskEncryption} + error={fieldState.error?.message} + isEncryptDiskChecked={field.value === 'enabled'} + /> + )} + control={control} + name="disk_encryption" + /> + + )} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx similarity index 82% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx index 9357c2c95d8..05994a8951b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx @@ -85,7 +85,7 @@ describe('Linode Create v2 Summary', () => { await findByText(region.label); }); - it('should render a plan (type) label if a type is selected', async () => { + it('should render a plan (type) label if a region and type are selected', async () => { const type = typeFactory.build(); server.use( @@ -96,7 +96,9 @@ describe('Linode Create v2 Summary', () => { const { findByText } = renderWithThemeAndHookFormContext({ component: , - useFormOptions: { defaultValues: { type: type.id } }, + useFormOptions: { + defaultValues: { region: 'fake-region', type: type.id }, + }, }); await findByText(type.label); @@ -220,4 +222,46 @@ describe('Linode Create v2 Summary', () => { expect(getByText('VLAN Attached')).toBeVisible(); }); + + it('should render "Encrypted" if disk encryption is enabled', async () => { + const { + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { disk_encryption: 'enabled' }, + }, + }); + + expect(getByText('Encrypted')).toBeVisible(); + }); + + it('should render correct pricing for Marketplace app cluster deployments', async () => { + const type = typeFactory.build({ + price: { hourly: 0.5, monthly: 2 }, + }); + + server.use( + http.get('*/v4/linode/types/*', () => { + return HttpResponse.json(type); + }) + ); + + const { + findByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + region: 'fake-region', + stackscript_data: { + cluster_size: 5, + }, + type: type.id, + }, + }, + }); + + await findByText(`5 Nodes - $10/month $2.50/hr`); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx similarity index 87% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx index e4e9c2da42d..73277af3bc7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx @@ -10,9 +10,11 @@ import { Typography } from 'src/components/Typography'; import { useImageQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; +import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; -import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; + +import { getLinodePrice } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -33,6 +35,8 @@ export const Summary = () => { placementGroupId, vlanLabel, vpcId, + diskEncryption, + clusterSize, ] = useWatch({ control, name: [ @@ -46,6 +50,8 @@ export const Summary = () => { 'placement_group.id', 'interfaces.1.label', 'interfaces.0.vpc_id', + 'disk_encryption', + 'stackscript_data.cluster_size', ], }); @@ -55,13 +61,12 @@ export const Summary = () => { const region = regions?.find((r) => r.id === regionId); - // @todo handle marketplace cluster pricing (support many nodes by looking at UDF data) - const price = getLinodeRegionPrice(type, regionId); - const backupsPrice = renderMonthlyPriceToCorrectDecimalPlace( getMonthlyBackupsPrice({ region: regionId, type }) ); + const price = getLinodePrice({ type, regionId, clusterSize }); + const summaryItems = [ { item: { @@ -77,10 +82,10 @@ export const Summary = () => { }, { item: { - details: `$${price?.monthly}/month`, - title: type?.label, + details: price, + title: type ? formatStorageUnits(type.label) : typeId, }, - show: Boolean(type), + show: price, }, { item: { @@ -119,12 +124,18 @@ export const Summary = () => { }, show: Boolean(firewallId), }, + { + item: { + title: 'Encrypted', + }, + show: diskEncryption === 'enabled', + }, ]; const summaryItemsToShow = summaryItems.filter((item) => item.show); return ( - + Summary {label} {summaryItemsToShow.length === 0 ? ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts new file mode 100644 index 00000000000..1da6463ccd4 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts @@ -0,0 +1,33 @@ +import { linodeTypeFactory } from 'src/factories'; + +import { getLinodePrice } from './utilities'; + +describe('getLinodePrice', () => { + it('gets a price for a normal Linode', () => { + const type = linodeTypeFactory.build({ + price: { hourly: 0.1, monthly: 5 }, + }); + + const result = getLinodePrice({ + clusterSize: undefined, + regionId: 'fake-region-id', + type, + }); + + expect(result).toBe('$5/month'); + }); + + it('gets a price for a Marketplace Cluster deployment', () => { + const type = linodeTypeFactory.build({ + price: { hourly: 0.2, monthly: 5 }, + }); + + const result = getLinodePrice({ + clusterSize: '3', + regionId: 'fake-region-id', + type, + }); + + expect(result).toBe('3 Nodes - $15/month $0.60/hr'); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts new file mode 100644 index 00000000000..9fc3df07966 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts @@ -0,0 +1,42 @@ +import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; +import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; + +import type { LinodeType } from '@linode/api-v4'; + +interface LinodePriceOptions { + clusterSize: string | undefined; + regionId: string | undefined; + type: LinodeType | undefined; +} + +export const getLinodePrice = (options: LinodePriceOptions) => { + const { clusterSize, regionId, type } = options; + const price = getLinodeRegionPrice(type, regionId); + + const isCluster = clusterSize !== undefined; + + if ( + regionId === undefined || + price === undefined || + price.monthly === null || + price.hourly === null + ) { + return undefined; + } + + if (isCluster) { + const numberOfNodes = Number(clusterSize); + + const totalMonthlyPrice = renderMonthlyPriceToCorrectDecimalPlace( + price.monthly * numberOfNodes + ); + + const totalHourlyPrice = renderMonthlyPriceToCorrectDecimalPlace( + price.hourly * numberOfNodes + ); + + return `${numberOfNodes} Nodes - $${totalMonthlyPrice}/month $${totalHourlyPrice}/hr`; + } + + return `$${renderMonthlyPriceToCorrectDecimalPlace(price.monthly)}/month`; +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Backups/BackupSelect.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Backups/BackupSelect.tsx index e00f06e22df..11e1ee972cf 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Backups/BackupSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Backups/BackupSelect.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { useController, useWatch } from 'react-hook-form'; import { Box } from 'src/components/Box'; -import { CircularProgress } from 'src/components/CircularProgress'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { LinearProgress } from 'src/components/LinearProgress'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; @@ -38,11 +38,7 @@ export const BackupSelect = () => { } if (isFetching) { - return ( - - - - ); + return ; } if (hasNoBackups) { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/Clone.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/Clone.tsx index 3b9e989973a..0bf8cc097b3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/Clone.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/Clone.tsx @@ -1,27 +1,17 @@ import React from 'react'; -import { useFormContext } from 'react-hook-form'; -import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; -import { LinodeCreateFormValues } from '../../utilities'; import { LinodeSelectTable } from '../../shared/LinodeSelectTable'; import { CloneWarning } from './CloneWarning'; export const Clone = () => { - const { - formState: { errors }, - } = useFormContext(); - return ( Select Linode to Clone From - {errors.linode?.message && ( - - )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/CloneWarning.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/CloneWarning.tsx index b8af8b69cd0..d9ba1436410 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/CloneWarning.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/CloneWarning.tsx @@ -9,12 +9,13 @@ export const CloneWarning = () => { - This newly created Linode will be created with the same password and - SSH Keys (if any) as the original Linode. + To help avoid data corruption during the cloning + process, we recommend powering off your Compute Instance prior to + cloning. - To help avoid data corruption during the cloning process, we recommend - powering off your Compute Instance prior to cloning. + This newly created Linode will be created with the same password and + SSH Keys (if any) as the original Linode. 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 { it('renders a header', () => { @@ -27,4 +30,27 @@ describe('Images', () => { expect(getByLabelText('Images')).toBeVisible(); expect(getByPlaceholderText('Choose an image')).toBeVisible(); }); + + it('renders a "Indicates compatibility with distributed compute regions." notice if the user has at least one image with the distributed capability', async () => { + server.use( + http.get('*/v4/images', () => { + const images = [ + imageFactory.build({ capabilities: [] }), + imageFactory.build({ capabilities: ['distributed-images'] }), + imageFactory.build({ capabilities: [] }), + ]; + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { findByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect( + await findByText( + 'Indicates compatibility with distributed compute regions.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index 484b2e6f27d..17542f0e313 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -1,15 +1,24 @@ import React from 'react'; -import { useController } from 'react-hook-form'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; +import { Box } from 'src/components/Box'; import { ImageSelectv2 } from 'src/components/ImageSelectv2/ImageSelectv2'; +import { getAPIFilterForImageSelect } from 'src/components/ImageSelectv2/utilities'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { useAllImagesQuery } from 'src/queries/images'; +import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { LinodeCreateFormValues } from '../utilities'; +import type { Image } from '@linode/api-v4'; export const Images = () => { - const { field, fieldState } = useController({ + const { control, setValue } = useFormContext(); + const { field, fieldState } = useController({ + control, name: 'image', }); @@ -17,17 +26,59 @@ export const Images = () => { globalGrantType: 'add_linodes', }); + const regionId = useWatch({ control, name: 'region' }); + + const { data: regions } = useRegionsQuery(); + + const onChange = (image: Image | null) => { + field.onChange(image?.id ?? null); + + const selectedRegion = regions?.find((r) => r.id === regionId); + + // Non-"distributed compatible" Images must only be deployed to core sites. + // Clear the region field if the currently selected region is a distributed site and the Image is only core compatible. + // @todo: delete this logic when all Images are "distributed compatible" + if ( + image && + !image.capabilities.includes('distributed-images') && + selectedRegion?.site_type === 'distributed' + ) { + setValue('region', ''); + } + }; + + const { data: images } = useAllImagesQuery( + {}, + getAPIFilterForImageSelect('private') + ); + + // @todo: delete this logic when all Images are "distributed compatible" + const showDistributedCapabilityNotice = images?.some((image) => + image.capabilities.includes('distributed-images') + ); + return ( Choose an Image - field.onChange(image?.id ?? null)} - value={field.value} - variant="private" - /> + + + {showDistributedCapabilityNotice && ( + + + + Indicates compatibility with distributed compute regions. + + + )} + ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx new file mode 100644 index 00000000000..da8b1684d81 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx @@ -0,0 +1,56 @@ +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AppDetailDrawerv2 } from './AppDetailDrawer'; + +describe('AppDetailDrawer', () => { + it('should render an app', () => { + const { getByText } = renderWithTheme( + + ); + + // Verify title renders + expect(getByText('WordPress')).toBeVisible(); + + // Verify description renders + expect( + getByText( + 'Flexible, open source content management system (CMS) for content-focused websites of any kind.' + ) + ).toBeVisible(); + + // Verify website renders with link + const website = getByText('https://wordpress.org/'); + expect(website).toBeVisible(); + expect(website).toHaveAttribute('href', 'https://wordpress.org/'); + + // Verify guide renders with link + const guide = getByText('Deploy WordPress through the Linode Marketplace'); + expect(guide).toBeVisible(); + expect(guide).toHaveAttribute( + 'href', + 'https://www.linode.com/docs/products/tools/marketplace/guides/wordpress/' + ); + }); + + it('should call onClose if the close button is clicked', async () => { + const onClose = vi.fn(); + const { getByLabelText } = renderWithTheme( + + ); + + await userEvent.click(getByLabelText('Close drawer')); + + expect(onClose).toHaveBeenCalled(); + }); + + it('should not render if open is false', async () => { + const { container } = renderWithTheme( + + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx new file mode 100644 index 00000000000..6d6118c2d66 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx @@ -0,0 +1,213 @@ +import Close from '@mui/icons-material/Close'; +import Drawer from '@mui/material/Drawer'; +import IconButton from '@mui/material/IconButton'; +import { Theme } from '@mui/material/styles'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; +import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; + +const useStyles = makeStyles()((theme: Theme) => ({ + appName: { + color: '#fff !important', + fontFamily: theme.font.bold, + fontSize: '2.2rem', + lineHeight: '2.5rem', + textAlign: 'center', + }, + button: { + color: 'white !important', + margin: theme.spacing(2), + position: 'absolute', + }, + container: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + padding: theme.spacing(4), + }, + description: { + lineHeight: 1.5, + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + }, + image: { + width: 50, + }, + link: { + fontSize: '0.875rem', + lineHeight: '24px', + wordBreak: 'break-word', + }, + logoContainer: { + gap: theme.spacing(), + height: 225, + padding: theme.spacing(2), + }, + paper: { + [theme.breakpoints.up('sm')]: { + width: 480, + }, + }, +})); + +interface Props { + onClose: () => void; + open: boolean; + stackScriptId: number | undefined; +} + +export const AppDetailDrawerv2 = (props: Props) => { + const { onClose, open, stackScriptId } = props; + const { classes } = useStyles(); + + const selectedApp = stackScriptId ? oneClickApps[stackScriptId] : null; + + const gradient = { + backgroundImage: `url(/assets/marketplace-background.png),linear-gradient(to right, #${selectedApp?.colors.start}, #${selectedApp?.colors.end})`, + }; + + return ( + + + + + + + {selectedApp ? ( + <> + + {`${selectedApp.name} + + + + + {selectedApp.summary} + + + {selectedApp.website && ( + + Website + + {selectedApp.website} + + + )} + {selectedApp.related_guides && ( + + Guides + + {selectedApp.related_guides.map((link, idx) => ( + + {sanitizeHTML({ + sanitizingTier: 'flexible', + text: link.title, + })} + + ))} + + + )} + {selectedApp.tips && ( + + Tips + + {selectedApp.tips.map((tip, idx) => ( + + {tip} + + ))} + + + )} + + + ) : ( + + App Details Not Found + + We were unable to load the details of this app. + + + + )} + + ); +}; + +// remove this when we make the svgs white via css +const REUSE_WHITE_ICONS = { + 'mongodbmarketplaceocc.svg': 'mongodb.svg', + 'postgresqlmarketplaceocc.svg': 'postgresql.svg', + 'redissentinelmarketplaceocc.svg': 'redis.svg', +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.test.tsx new file mode 100644 index 00000000000..e65774076c6 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.test.tsx @@ -0,0 +1,80 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { stackScriptFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AppSection } from './AppSection'; + +describe('AppSection', () => { + it('should render a title', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Test title')).toBeVisible(); + }); + + it('should render apps', () => { + const app = stackScriptFactory.build({ + id: 0, + label: 'Linode Marketplace App', + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Linode Marketplace App')).toBeVisible(); + }); + + it('should call `onOpenDetailsDrawer` when the details button is clicked for an app', async () => { + const app = stackScriptFactory.build({ id: 0 }); + const onOpenDetailsDrawer = vi.fn(); + + const { getByLabelText } = renderWithTheme( + + ); + + await userEvent.click(getByLabelText(`Info for "${app.label}"`)); + + expect(onOpenDetailsDrawer).toHaveBeenCalledWith(app.id); + }); + + it('should call `onSelect` when an app is clicked', async () => { + const app = stackScriptFactory.build({ id: 0 }); + const onSelect = vi.fn(); + + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText(app.label)); + + expect(onSelect).toHaveBeenCalledWith(app); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.tsx new file mode 100644 index 00000000000..29948e18034 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSection.tsx @@ -0,0 +1,48 @@ +import Grid from '@mui/material/Unstable_Grid2'; +import React from 'react'; + +import { Divider } from 'src/components/Divider'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; + +import { AppSelectionCard } from './AppSelectionCard'; + +import type { StackScript } from '@linode/api-v4'; + +interface Props { + onOpenDetailsDrawer: (stackscriptId: number) => void; + onSelect: (stackscript: StackScript) => void; + selectedStackscriptId: null | number | undefined; + stackscripts: StackScript[]; + title: string; +} + +export const AppSection = (props: Props) => { + const { + onOpenDetailsDrawer, + onSelect, + selectedStackscriptId, + stackscripts, + title, + } = props; + + return ( + + {title} + + + {stackscripts?.map((stackscript) => ( + onOpenDetailsDrawer(stackscript.id)} + onSelect={() => onSelect(stackscript)} + /> + ))} + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.test.tsx index c47d0a85166..182f7ec02fe 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.test.tsx @@ -12,7 +12,7 @@ import { uniqueCategories } from './utilities'; describe('Marketplace', () => { it('should render a header', () => { const { getByText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const heading = getByText('Select an App'); @@ -23,7 +23,7 @@ describe('Marketplace', () => { it('should render a search field', () => { const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const input = getByPlaceholderText('Search for app name'); @@ -34,7 +34,7 @@ describe('Marketplace', () => { it('should render a category select', () => { const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const input = getByPlaceholderText('Select category'); @@ -55,7 +55,7 @@ describe('Marketplace', () => { getByPlaceholderText, getByText, } = renderWithThemeAndHookFormContext({ - component: , + component: , }); await waitFor(() => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx index 294f4cb653b..43082645282 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx @@ -1,76 +1,47 @@ -import Grid from '@mui/material/Unstable_Grid2'; -import React from 'react'; -import { useController, useFormContext } from 'react-hook-form'; +import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; -import { CircularProgress } from 'src/components/CircularProgress'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; -import { getDefaultUDFData } from '../StackScripts/UserDefinedFields/utilities'; -import { AppSelectionCard } from './AppSelectionCard'; +import { AppsList } from './AppsList'; import { categoryOptions } from './utilities'; import type { LinodeCreateFormValues } from '../../utilities'; +import type { AppCategory } from 'src/features/OneClickApps/types'; -export const AppSelect = () => { - const { setValue } = useFormContext(); - const { field } = useController({ - name: 'stackscript_id', - }); +interface Props { + /** + * Opens the Marketplace App details drawer for the given app + */ + onOpenDetailsDrawer: (stackscriptId: number) => void; +} - const { data: apps, error, isLoading } = useMarketplaceAppsQuery(true); +export const AppSelect = (props: Props) => { + const { onOpenDetailsDrawer } = props; - const renderContent = () => { - if (isLoading) { - return ( - - - - ); - } + const { + formState: { errors }, + } = useFormContext(); - if (error) { - return ; - } + const { isLoading } = useMarketplaceAppsQuery(true); - return ( - - {apps?.map((app) => ( - { - setValue( - 'stackscript_data', - getDefaultUDFData(app.user_defined_fields) - ); - field.onChange(app.id); - }} - checked={field.value === app.id} - iconUrl={app.logo_url} - key={app.label} - label={app.label} - onOpenDetailsDrawer={() => alert('details')} - /> - ))} - - ); - }; + const [query, setQuery] = useState(''); + const [category, setCategory] = useState(); return ( Select an App + {errors.stackscript_id?.message && ( + + )} { 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" /> - {renderContent()} + diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx index 67261c65a98..44bc65f9d06 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx @@ -51,7 +51,7 @@ export const AppSelectionCard = (props: Props) => { const renderIcon = iconUrl === '' ? () => - : () => {`${label}; + : () => {`${label}; const renderVariant = () => ( void; + /** + * The search query + */ + query: string; +} + +export const AppsList = (props: Props) => { + const { category, onOpenDetailsDrawer, query } = props; + const { data: stackscripts, error, isLoading } = useMarketplaceAppsQuery( + true + ); + + const { setValue } = useFormContext(); + const { field } = useController({ + name: 'stackscript_id', + }); + + const onSelect = (stackscript: StackScript) => { + setValue( + 'stackscript_data', + getDefaultUDFData(stackscript.user_defined_fields) + ); + field.onChange(stackscript.id); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ; + } + + if (category || query) { + const filteredStackScripts = getFilteredApps({ + category, + query, + stackscripts, + }); + + return ( + + {filteredStackScripts?.map((stackscript) => ( + onOpenDetailsDrawer(stackscript.id)} + onSelect={() => onSelect(stackscript)} + /> + ))} + + ); + } + + const sections = getAppSections(stackscripts); + + return ( + + {sections.map(({ stackscripts, title }) => ( + + ))} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/Marketplace.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/Marketplace.tsx index e5ff344b4f8..fbd93b5c363 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/Marketplace.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/Marketplace.tsx @@ -1,17 +1,29 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Stack } from 'src/components/Stack'; import { StackScriptImages } from '../StackScripts/StackScriptImages'; import { UserDefinedFields } from '../StackScripts/UserDefinedFields/UserDefinedFields'; +import { AppDetailDrawerv2 } from './AppDetailDrawer'; import { AppSelect } from './AppSelect'; export const Marketplace = () => { + const [drawerStackScriptId, setDrawerStackScriptId] = useState(); + + const onOpenDetailsDrawer = (stackscriptId: number) => { + setDrawerStackScriptId(stackscriptId); + }; + return ( - - + + + setDrawerStackScriptId(undefined)} + open={drawerStackScriptId !== undefined} + stackScriptId={drawerStackScriptId} + /> ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.test.ts new file mode 100644 index 00000000000..1719bdd48f5 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.test.ts @@ -0,0 +1,91 @@ +import { stackScriptFactory } from 'src/factories'; + +import { getFilteredApps } from './utilities'; + +const mysql = stackScriptFactory.build({ id: 607026, label: 'MySQL' }); +const piHole = stackScriptFactory.build({ id: 970522, label: 'Pi-Hole' }); +const vault = stackScriptFactory.build({ id: 1037038, label: 'Vault' }); + +const stackscripts = [mysql, piHole, vault]; + +describe('getFilteredApps', () => { + 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 2845ee7a3f4..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,10 +1,13 @@ -import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; +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 * we can generate a dynamic list of category options. */ -const categories = oneClickApps.reduce((acc, app) => { +const categories = Object.values(oneClickApps).reduce((acc, app) => { return [...acc, ...app.categories]; }, []); @@ -16,3 +19,122 @@ export const uniqueCategories = Array.from(new Set(categories)); export const categoryOptions = uniqueCategories.map((category) => ({ label: category, })); + +/** + * Returns an array of Marketplace app sections given an array + * of Marketplace app StackScripts + */ +export const getAppSections = (stackscripts: StackScript[]) => { + // To check if an app is 'new', we check our own 'oneClickApps' list for the 'isNew' value + const newApps = stackscripts.filter( + (stackscript) => oneClickApps[stackscript.id]?.isNew + ); + + // Items are ordered by popularity already, take the first 10 + const popularApps = stackscripts.slice(0, 10); + + // In the all apps section, show everything in alphabetical order + const allApps = [...stackscripts].sort((a, b) => + a.label.toLowerCase().localeCompare(b.label.toLowerCase()) + ); + + return [ + { + stackscripts: newApps, + title: 'New apps', + }, + { + stackscripts: popularApps, + title: 'Popular apps', + }, + { + stackscripts: allApps, + title: 'All apps', + }, + ]; +}; + +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/Tabs/StackScripts/StackScriptDetailsDialog.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.test.tsx index c1be7504701..056051ece2b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.test.tsx @@ -8,7 +8,9 @@ import { StackScriptDetailsDialog } from './StackScriptDetailsDialog'; describe('StackScriptDetailsDialog', () => { it('should render StackScript data from the API', async () => { - const stackscript = stackScriptFactory.build(); + const stackscript = stackScriptFactory.build({ + id: 1234, + }); server.use( http.get('*/v4/linode/stackscripts/:id', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx index 63ea8bbd5d3..91bcffd5696 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx @@ -26,21 +26,19 @@ export const StackScriptSelection = () => { // Reset the selected image, the selected StackScript, and the StackScript data when changing tabs. reset((prev) => ({ ...prev, - image: null, - stackscript_data: null, - stackscript_id: null, + image: undefined, + stackscript_data: undefined, + stackscript_id: undefined, })); }; + + const error = formState.errors.stackscript_id?.message; + return ( Create From: - {formState.errors.stackscript_id && ( - + {error && ( + )} { + const [query, setQuery] = useState(); + const { handleOrderChange, order, orderBy } = useOrder({ order: 'desc', orderBy: 'deployments_total', @@ -64,17 +75,26 @@ export const StackScriptSelectionList = ({ type }: Props) => { ? communityStackScriptFilter : accountStackScriptFilter; + const { + error: searchParseError, + filter: searchFilter, + } = getAPIFilterFromQuery(query, { + searchableFieldsWithoutOperator: ['username', 'label', 'description'], + }); + const { data, error, fetchNextPage, hasNextPage, + isFetching, isFetchingNextPage, isLoading, } = useStackScriptsInfiniteQuery( { ['+order']: order, ['+order_by']: orderBy, + ...searchFilter, ...filter, }, !hasPreselectedStackScript @@ -120,8 +140,38 @@ export const StackScriptSelectionList = ({ type }: Props) => { } return ( - - + + + {isFetching && } + {searchParseError && ( + + )} + setQuery('')} + size="small" + > + + + + ), + }} + tooltipText={ + type === 'Community' + ? 'Hint: try searching for a specific item by prepending your search term with "username:", "label:", or "description:"' + : undefined + } + hideLabel + label="Search" + onChange={debounce(400, (e) => setQuery(e.target.value))} + placeholder="Search StackScripts" + spellCheck={false} + value={query} + /> +
    @@ -153,6 +203,7 @@ export const StackScriptSelectionList = ({ type }: Props) => { stackscript={stackscript} /> ))} + {data?.pages[0].results === 0 && } {error && } {isLoading && } {(isFetchingNextPage || hasNextPage) && ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx index b61b0f8aa94..1dc9dc84a1b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx @@ -57,8 +57,15 @@ export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { .manyof!.split(',') .map((option) => ({ label: option })); + const value = options.filter((option) => + field.value?.split(',').includes(option.label) + ); + return ( { + field.onChange(options.map((option) => option.label).join(',')); + }} textFieldProps={{ required: isRequired, }} @@ -66,9 +73,10 @@ export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { label={userDefinedField.label} multiple noMarginTop - onChange={(e, options) => field.onChange(options.join(','))} options={options} - value={field.value?.split(',') ?? []} + // If options are selected, hide the placeholder + placeholder={value.length > 0 ? ' ' : undefined} + value={value} /> ); } @@ -78,6 +86,8 @@ export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { .oneof!.split(',') .map((option) => ({ label: option })); + const value = options.find((option) => option.label === field.value); + if (options.length > 4) { return ( { }} disableClearable label={userDefinedField.label} - onChange={(_, option) => field.onChange(option.label)} + onChange={(_, option) => field.onChange(option?.label ?? '')} options={options} - value={options.find((option) => option.label === field.value)} + value={value} /> ); } @@ -135,7 +145,6 @@ export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { onChange={(e) => field.onChange(e.target.value)} placeholder={isTokenPassword ? 'Enter your token' : 'Enter a password.'} required={isRequired} - tooltipInteractive={isTokenPassword} value={field.value ?? ''} /> ); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.test.tsx index 35097a438ed..5c82e90e1cd 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.test.tsx @@ -5,6 +5,7 @@ import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { UserDefinedFields } from './UserDefinedFields'; +import { getDefaultUDFData } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -58,4 +59,53 @@ describe('UserDefinedFields', () => { await findByLabelText(udf.label, { exact: false }); } }); + + it('should render a notice if this is a cluster', async () => { + const stackscript = stackScriptFactory.build({ + id: 0, + label: 'Marketplace App for Redis', + user_defined_fields: [ + { + default: '3', + label: 'Cluster Size', + name: 'cluster_size', + oneof: '3,5', + }, + ], + }); + + server.use( + http.get('*/linode/stackscripts/:id', () => { + return HttpResponse.json(stackscript); + }) + ); + + const { + findByText, + getByLabelText, + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stackscript_data: getDefaultUDFData(stackscript.user_defined_fields), + stackscript_id: stackscript.id, + }, + }, + }); + + // Very the title renders + await findByText(`${stackscript.label} Setup`); + + // Verify the defuault cluster size is selected + expect(getByLabelText('3')).toBeChecked(); + + // Verify the cluster notice shows + expect(getByText('You are creating a cluster with 3 nodes.')).toBeVisible(); + + // Verify the details button renders + expect( + getByLabelText(`View details for ${stackscript.label}`) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx index bbb5c03c393..8e3df1374bb 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx @@ -1,24 +1,39 @@ import { CreateLinodeRequest } from '@linode/api-v4'; +import { decode } from 'he'; import React from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; +import Info from 'src/assets/icons/info.svg'; import { Box } from 'src/components/Box'; +import { IconButton } from 'src/components/IconButton'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { ShowMoreExpansion } from 'src/components/ShowMoreExpansion'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { useStackScriptQuery } from 'src/queries/stackscripts'; import { UserDefinedFieldInput } from './UserDefinedFieldInput'; import { separateUDFsByRequiredStatus } from './utilities'; -export const UserDefinedFields = () => { - const stackscriptId = useWatch({ - name: 'stackscript_id', - }); +interface Props { + /** + * Opens the Marketplace App details drawer for a given StackScript + * + * This is optional because we use this components for regular StackScripts too + * and they don't have details drawers + */ + onOpenDetailsDrawer?: (stackscriptId: number) => void; +} + +export const UserDefinedFields = ({ onOpenDetailsDrawer }: Props) => { + const { control, formState } = useFormContext(); - const { formState } = useFormContext(); + const [stackscriptId, stackscriptData] = useWatch({ + control, + name: ['stackscript_id', 'stackscript_data'], + }); const hasStackscriptSelected = stackscriptId !== null && stackscriptId !== undefined; @@ -34,6 +49,15 @@ export const UserDefinedFields = () => { userDefinedFields ); + const clusterSize = stackscriptData?.['cluster_size']; + + const isCluster = clusterSize !== null && clusterSize !== undefined; + + const marketplaceAppInfo = + stackscriptId !== null && stackscriptId !== undefined + ? oneClickApps[stackscriptId] + : undefined; + if (!stackscript || userDefinedFields?.length === 0) { return null; } @@ -41,13 +65,39 @@ export const UserDefinedFields = () => { return ( - {stackscript.label} Setup + {marketplaceAppInfo ? ( + + {`${stackscript.label} + + {decode(stackscript.label.replace('One-Click', ''))} Setup + + onOpenDetailsDrawer?.(stackscriptId!)} + size="large" + > + + + + ) : ( + {stackscript.label} Setup + )} {formState.errors.stackscript_data && ( )} + {isCluster && ( + + )} {requiredUDFs.map((field) => ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.test.ts index e772afa65bb..3a01f2aa795 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.test.ts @@ -46,6 +46,7 @@ describe('getDefaultUDFData', () => { ]; expect(getDefaultUDFData(udfs)).toStrictEqual({ + password: '', username: 'admin', }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts index c72c4f43fbc..869c91867dc 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts @@ -31,9 +31,11 @@ export const getIsUDFRequired = (udf: UserDefinedField) => export const getDefaultUDFData = ( userDefinedFields: UserDefinedField[] ): Record => - userDefinedFields.reduce((accum, eachField) => { - if (eachField.default) { - accum[eachField.name] = eachField.default; + userDefinedFields.reduce((accum, field) => { + if (field.default) { + accum[field.name] = field.default; + } else { + accum[field.name] = ''; } return accum; }, {}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx index e570a4f0013..53310b3c284 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx @@ -97,7 +97,6 @@ export const UserData = () => { disabled={isLinodeCreateRestricted} errorText={fieldState.error?.message} expand - inputRef={field.ref} label="User Data" labelTooltipText="Compatible formats include cloud-config data and executable scripts." multiline diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx index 13dd5e70ab6..f21a50c4687 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx @@ -41,7 +41,6 @@ export const UserDataHeading = () => { . } - interactive status="help" sxTooltipIcon={{ p: 0 }} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx index 1cae85da5ed..063caeebd3f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx @@ -104,7 +104,6 @@ export const VLAN = () => { containerProps={{ maxWidth: 335 }} disabled={disabled} errorText={fieldState.error?.message} - inputRef={field.ref} label="IPAM Address" onBlur={field.onBlur} onChange={field.onChange} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx index 6938cf80562..9da60ed6c84 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx @@ -92,7 +92,6 @@ export const VPC = () => { : undefined } textFieldProps={{ - inputRef: field.ref, sx: (theme) => ({ [theme.breakpoints.up('sm')]: { minWidth: inputMaxWidth }, }), @@ -103,6 +102,7 @@ export const VPC = () => { filter={{ region: regionId }} label="Assign VPC" noMarginTop + onBlur={field.onBlur} onChange={(e, vpc) => field.onChange(vpc?.id ?? null)} placeholder="None" value={field.value ?? null} @@ -126,9 +126,6 @@ export const VPC = () => { getOptionLabel={(subnet) => `${subnet.label} (${subnet.ipv4})` } - textFieldProps={{ - inputRef: field.ref, - }} value={ selectedVPC?.subnets.find( (subnet) => subnet.id === field.value @@ -189,7 +186,6 @@ export const VPC = () => { { { const { params, setParams } = useLinodeCreateQueryParams(); - const methods = useForm({ + const form = useForm({ defaultValues, mode: 'onBlur', resolver: linodeCreateResolvers[params.type ?? 'Distributions'], + shouldFocusError: false, // We handle this ourselves with `scrollErrorIntoView` }); const history = useHistory(); - + const queryClient = useQueryClient(); const { mutateAsync: createLinode } = useCreateLinodeMutation(); const { mutateAsync: cloneLinode } = useCloneLinodeMutation(); + const { enqueueSnackbar } = useSnackbar(); const currentTabIndex = getTabIndex(params.type); @@ -67,12 +74,12 @@ export const LinodeCreatev2 = () => { // Update tab "type" query param. (This changes the selected tab) setParams({ type: newTab }); // Reset the form values - methods.reset(defaultValuesMap[newTab]); + form.reset(defaultValuesMap[newTab]); }; const onSubmit: SubmitHandler = async (values) => { const payload = getLinodeCreatePayload(values); - alert(JSON.stringify(payload, null, 2)); + try { const linode = params.type === 'Clone Linode' @@ -83,26 +90,48 @@ export const LinodeCreatev2 = () => { : await createLinode(payload); history.push(`/linodes/${linode.id}`); + + enqueueSnackbar(`Your Linode ${linode.label} is being created.`, { + variant: 'success', + }); + + captureLinodeCreateAnalyticsEvent({ + queryClient, + type: params.type ?? 'Distributions', + values, + }); } catch (errors) { for (const error of errors) { if (error.field) { - methods.setError(error.field, { message: error.reason }); + form.setError(error.field, { message: error.reason }); } else { - methods.setError('root', { message: error.reason }); + form.setError('root', { message: error.reason }); } } } }; + const previousSubmitCount = useRef(0); + + useEffect(() => { + if ( + !isEmpty(form.formState.errors) && + form.formState.submitCount > previousSubmitCount.current + ) { + scrollErrorIntoView(undefined, { behavior: 'smooth' }); + } + previousSubmitCount.current = form.formState.submitCount; + }, [form.formState]); + return ( - + -
    + @@ -138,7 +167,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/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts index 601e7ac2104..b5c93accfd4 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts @@ -4,12 +4,13 @@ import { CreateLinodeSchema } from '@linode/validation'; import { CreateLinodeByCloningSchema, CreateLinodeFromBackupSchema, + CreateLinodeFromMarketplaceAppSchema, CreateLinodeFromStackScriptSchema, } from './schemas'; import { getLinodeCreatePayload } from './utilities'; -import { LinodeCreateFormValues } from './utilities'; import type { LinodeCreateType } from '../LinodesCreate/types'; +import type { LinodeCreateFormValues } from './utilities'; import type { Resolver } from 'react-hook-form'; export const resolver: Resolver = async ( @@ -52,6 +53,26 @@ export const stackscriptResolver: Resolver = async ( return { errors: {}, values }; }; +export const marketplaceResolver: Resolver = async ( + values, + context, + options +) => { + const transformedValues = getLinodeCreatePayload(structuredClone(values)); + + const { errors } = await yupResolver( + CreateLinodeFromMarketplaceAppSchema, + {}, + { mode: 'async', rawValues: true } + )(transformedValues, context, options); + + if (errors) { + return { errors, values }; + } + + return { errors: {}, values }; +}; + export const cloneResolver: Resolver = async ( values, context, @@ -107,6 +128,6 @@ export const linodeCreateResolvers: Record< 'Clone Linode': cloneResolver, Distributions: resolver, Images: resolver, - 'One-Click': stackscriptResolver, + 'One-Click': marketplaceResolver, StackScripts: stackscriptResolver, }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts index d7e826c1a52..b97945a9868 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts @@ -27,3 +27,12 @@ export const CreateLinodeFromStackScriptSchema = CreateLinodeSchema.concat( stackscript_id: number().required('You must select a StackScript.'), }) ); + +/** + * Extends the Linode Create schema to make stackscript_id required for the Marketplace tab + */ +export const CreateLinodeFromMarketplaceAppSchema = CreateLinodeSchema.concat( + object({ + stackscript_id: number().required('You must select a Marketplace App.'), + }) +); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx index 004fcab3f6b..509ef9c7c34 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx @@ -1,10 +1,11 @@ import Grid from '@mui/material/Unstable_Grid2'; import useMediaQuery from '@mui/material/useMediaQuery'; import React, { useState } from 'react'; -import { useFormContext, useWatch } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; import { Box } from 'src/components/Box'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Notice } from 'src/components/Notice/Notice'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Stack } from 'src/components/Stack'; import { Table } from 'src/components/Table'; @@ -22,6 +23,7 @@ import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDra import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useLinodesQuery } from 'src/queries/linodes/linodes'; +import { sendLinodePowerOffEvent } from 'src/utilities/analytics/customEventAnalytics'; import { privateIPRegex } from 'src/utilities/ipUtils'; import { isNumeric } from 'src/utilities/stringUtils'; @@ -36,7 +38,8 @@ import type { Theme } from '@mui/material'; interface Props { /** - * Adds an extra column that will dispay a "power off" option when the row is selected + * In desktop view, adds an extra column that will display a "power off" option when the row is selected. + * In mobile view, allows the "power off" button to display when the card is selected. */ enablePowerOff?: boolean; } @@ -50,10 +53,12 @@ export const LinodeSelectTable = (props: Props) => { const { control, reset } = useFormContext(); - const selectedLinode = useWatch({ - control, - name: 'linode', - }); + const { field, fieldState } = useController( + { + control, + name: 'linode', + } + ); const { params } = useLinodeCreateQueryParams(); @@ -61,7 +66,7 @@ export const LinodeSelectTable = (props: Props) => { params.linodeID ); - const [query, setQuery] = useState(selectedLinode?.label ?? ''); + const [query, setQuery] = useState(field.value?.label ?? ''); const [linodeToPowerOff, setLinodeToPowerOff] = useState(); const pagination = usePagination(); @@ -99,10 +104,18 @@ export const LinodeSelectTable = (props: Props) => { })); }; + const handlePowerOff = (linode: Linode) => { + setLinodeToPowerOff(linode); + sendLinodePowerOffEvent('Clone Linode'); + }; + const columns = enablePowerOff ? 6 : 5; return ( + {fieldState.error?.message && ( + + )} { @@ -111,7 +124,7 @@ export const LinodeSelectTable = (props: Props) => { } setQuery(value ?? ''); }, - value: preselectedLinodeId ? selectedLinode?.label ?? '' : query, + value: preselectedLinodeId ? field.value?.label ?? '' : query, }} clearable hideLabel @@ -156,13 +169,16 @@ export const LinodeSelectTable = (props: Props) => { setLinodeToPowerOff(linode) + ? () => { + setLinodeToPowerOff(linode); + sendLinodePowerOffEvent('Clone Linode'); + } : undefined } key={linode.id} linode={linode} onSelect={() => handleSelect(linode)} - selected={linode.id === selectedLinode?.id} + selected={linode.id === field.value?.id} /> ))} @@ -171,10 +187,12 @@ export const LinodeSelectTable = (props: Props) => { {data?.data.map((linode) => ( handlePowerOff(linode)} handleSelection={() => handleSelect(linode)} key={linode.id} linode={linode} - selected={linode.id === selectedLinode?.id} + selected={linode.id === field.value?.id} + showPowerActions={Boolean(enablePowerOff)} /> ))} {data?.results === 0 && ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 8824590eaf7..f19c71ab0e9 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -2,6 +2,8 @@ import { getLinode, getStackScript } from '@linode/api-v4'; import { omit } from 'lodash'; import { useHistory } from 'react-router-dom'; +import { stackscriptQueries } from 'src/queries/stackscripts'; +import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalytics'; import { privateIPRegex } from 'src/utilities/ipUtils'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; @@ -15,6 +17,7 @@ import type { InterfacePayload, Linode, } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; /** * This is the ID of the Image of the default distribution. @@ -209,23 +212,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 +271,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,35 +304,23 @@ 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: null, + stackscript_id: undefined, type: '', }; @@ -348,3 +335,53 @@ export const defaultValuesMap: Record = { 'One-Click': defaultValuesForStackScripts, StackScripts: defaultValuesForStackScripts, }; + +interface LinodeCreateAnalyticsEventOptions { + queryClient: QueryClient; + type: LinodeCreateType; + values: LinodeCreateFormValues; +} + +/** + * Captures a custom analytics event when a Linode is created. + */ +export const captureLinodeCreateAnalyticsEvent = async ( + options: LinodeCreateAnalyticsEventOptions +) => { + const { queryClient, type, values } = options; + + if (type === 'Backups' && values.backup_id) { + sendCreateLinodeEvent('backup', String(values.backup_id)); + } + + if (type === 'Clone Linode' && values.linode) { + const linodeId = values.linode.id; + // @todo use Linode query key factory when it is implemented + const linode = await queryClient.ensureQueryData({ + queryFn: () => getLinode(linodeId), + queryKey: ['linodes', 'linode', linodeId, 'details'], + }); + + sendCreateLinodeEvent('clone', values.type, { + isLinodePoweredOff: linode.status === 'offline', + }); + } + + if (type === 'Distributions' || type === 'Images') { + sendCreateLinodeEvent('image', values.image ?? undefined); + } + + if (type === 'StackScripts' && values.stackscript_id) { + const stackscript = await queryClient.ensureQueryData( + stackscriptQueries.stackscript(values.stackscript_id) + ); + sendCreateLinodeEvent('stackscript', stackscript.label); + } + + if (type === 'One-Click' && values.stackscript_id) { + const stackscript = await queryClient.ensureQueryData( + stackscriptQueries.stackscript(values.stackscript_id) + ); + sendCreateLinodeEvent('one-click', stackscript.label); + } +}; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts index 47af5c1e3da..f59d4fb895c 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts @@ -122,7 +122,8 @@ export const StyledTable = styled(Table, { label: 'StyledTable' })( whiteSpace: 'nowrap', }, '& th': { - backgroundColor: theme.bg.app, + backgroundColor: + theme.name === 'light' ? theme.color.grey10 : theme.bg.app, borderBottom: `1px solid ${theme.bg.bgPaper}`, color: theme.textColors.textAccessTable, fontFamily: theme.font.bold, @@ -136,6 +137,7 @@ export const StyledTable = styled(Table, { label: 'StyledTable' })( '& tr': { height: 32, }, + border: 'none', tableLayout: 'fixed', }) ); diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx index 54c368bab8d..981cca15e03 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx @@ -13,6 +13,7 @@ import { HttpResponse, http, server } from 'src/mocks/testServer'; import { queryClientFactory } from 'src/queries/base'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; +import { encryptionStatusTestId } from '../Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable'; import { LinodeEntityDetail } from './LinodeEntityDetail'; import { getSubnetsString } from './LinodeEntityDetailBody'; import { LinodeHandlers } from './LinodesLanding/LinodesLanding'; @@ -34,6 +35,29 @@ describe('Linode Entity Detail', () => { const vpcSectionTestId = 'vpc-section-title'; const assignedVPCLabelTestId = 'assigned-vpc-label'; + const mocks = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; + }); + + vi.mock('src/components/DiskEncryption/utils.ts', async () => { + const actual = await vi.importActual( + 'src/components/DiskEncryption/utils.ts' + ); + return { + ...actual, + __esModule: true, + useIsDiskEncryptionFeatureEnabled: mocks.useIsDiskEncryptionFeatureEnabled.mockImplementation( + () => { + return { + isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent + }; + } + ), + }; + }); + it('should not display the VPC section if the linode is not assigned to a VPC', async () => { const account = accountFactory.build({ capabilities: [...accountCapabilitiesWithoutVPC, 'VPCs'], @@ -104,6 +128,41 @@ describe('Linode Entity Detail', () => { expect(getByTestId(assignedVPCLabelTestId).innerHTML).toEqual('test-vpc'); }); }); + + it('should not display the encryption status of the linode if the account lacks the capability or the feature flag is off', () => { + // situation where isDiskEncryptionFeatureEnabled === false + const { queryByTestId } = renderWithTheme( + + ); + const encryptionStatusFragment = queryByTestId(encryptionStatusTestId); + + expect(encryptionStatusFragment).not.toBeInTheDocument(); + }); + + it('should display the encryption status of the linode when Disk Encryption is enabled and the user has the account capability', () => { + mocks.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce(() => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + }); + + const { queryByTestId } = renderWithTheme( + + ); + const encryptionStatusFragment = queryByTestId(encryptionStatusTestId); + + expect(encryptionStatusFragment).toBeInTheDocument(); + }); }); describe('getSubnetsString function', () => { diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx index b61b2862a35..bdef9ccca51 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { Notice } from 'src/components/Notice/Notice'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { notificationContext as _notificationContext } from 'src/features/NotificationCenter/NotificationContext'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; @@ -81,7 +81,10 @@ export const LinodeEntityDetail = (props: Props) => { const linodeRegionDisplay = regions?.find((r) => r.id === linode.region)?.label ?? linode.region; - const linodeIsInEdgeRegion = getIsEdgeRegion(regions ?? [], linode.region); + const linodeIsInDistributedRegion = getIsDistributedRegion( + regions ?? [], + linode.region + ); let progress; let transitionText; @@ -108,13 +111,15 @@ export const LinodeEntityDetail = (props: Props) => { body={ { const { configInterfaceWithVPC, + encryptionStatus, gbRAM, gbStorage, ipv4, ipv6, + isLKELinode, isVPCOnlyLinode, linodeId, - linodeIsInEdgeRegion, + linodeIsInDistributedRegion, linodeLabel, numCPUs, numVolumes, @@ -77,6 +93,14 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { const theme = useTheme(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + // @ TODO LDE: Remove usages of this variable once LDE is fully rolled out (being used to determine formatting adjustments currently) + const isDisplayingEncryptedStatus = + isDiskEncryptionFeatureEnabled && Boolean(encryptionStatus); + // Filter and retrieve subnets associated with a specific Linode ID const linodeAssociatedSubnets = vpcLinodeIsAssignedTo?.subnets.filter( (subnet) => subnet.linodes.some((linode) => linode.id === linodeId) @@ -97,11 +121,14 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { - + Summary @@ -121,9 +148,28 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { {pluralize('Volume', 'Volumes', numVolumes)} + {isDiskEncryptionFeatureEnabled && encryptionStatus && ( + + + + + + )} - + { { heading: 'SSH Access', text: sshLink(ipv4[0]) }, { heading: 'LISH Console via SSH', - text: linodeIsInEdgeRegion + text: linodeIsInDistributedRegion ? 'N/A' : lishLink(username, region, linodeLabel), }, diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index 02ca88438f7..db1b6ce6be0 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -4,8 +4,9 @@ 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'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; @@ -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/LinodeEntityDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx index 398a5284d51..09ceb219071 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx @@ -134,10 +134,21 @@ export const LinodeEntityDetailHeader = ( formattedTransitionText !== formattedStatus; const sxActionItem = { + '&:focus': { + color: theme.color.white, + }, '&:hover': { - backgroundColor: theme.color.blue, - color: '#fff', + '&[aria-disabled="true"]': { + color: theme.color.disabledText, + }, + + color: theme.color.white, + }, + '&[aria-disabled="true"]': { + background: 'transparent', + color: theme.color.disabledText, }, + background: 'transparent', color: theme.textColors.linkActiveLight, fontFamily: theme.font.normal, fontSize: '0.875rem', @@ -197,14 +208,14 @@ export const LinodeEntityDetailHeader = ( onClick={() => handlers.onOpenPowerDialog(isRunning ? 'Power Off' : 'Power On') } - buttonType="secondary" + buttonType="primary" disabled={!(isRunning || isOffline) || isLinodesGrantReadOnly} sx={sxActionItem} > {isRunning ? 'Power Off' : 'Power On'} + )} + + + ); + return ( ); }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx index 75a75589bcc..689764c6bdf 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx @@ -8,7 +8,9 @@ import { RenderLinodeProps } from './SelectLinodePanel'; export const SelectLinodeCards = ({ disabled, + handlePowerOff, handleSelection, + showPowerActions, orderBy: { data: linodes }, selectedLinodeId, }: RenderLinodeProps) => ( @@ -20,9 +22,11 @@ export const SelectLinodeCards = ({ handleSelection(linode.id, linode.type, linode.specs.disk) } disabled={disabled} + handlePowerOff={() => handlePowerOff(linode.id)} key={linode.id} linode={linode} selected={linode.id == selectedLinodeId} + showPowerActions={showPowerActions} /> )) ) : ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx index ffb91bec104..653a1f3980d 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx @@ -72,7 +72,7 @@ describe('SelectLinodePanel (table, desktop)', () => { fireEvent.click(radioInput); expect(mockOnSelect).toHaveBeenCalledWith( - 0, + 1, defaultProps.linodes[0].type, defaultProps.linodes[0].specs.disk ); @@ -115,7 +115,7 @@ describe('SelectLinodePanel (table, desktop)', () => { setupMocks(); const { container, findAllByRole } = renderWithTheme( - + ); expect( @@ -171,7 +171,7 @@ describe('SelectLinodePanel (cards, mobile)', () => { fireEvent.click(selectionCard); expect(mockOnSelect).toHaveBeenCalledWith( - 0, + 1, defaultProps.linodes[0].type, defaultProps.linodes[0].specs.disk ); @@ -197,26 +197,11 @@ describe('SelectLinodePanel (cards, mobile)', () => { ).toHaveTextContent(defaultProps.linodes[0].label); }); - it('displays the heading, notices and error', () => { - const { getByText } = renderWithTheme( - - ); - - expect(getByText('Example error')).toBeInTheDocument(); - expect(getByText('Example header')).toBeInTheDocument(); - expect(getByText('Example notice')).toBeInTheDocument(); - }); - it('prefills the search box when mounted with a selected linode', async () => { setupMocks(); const { container } = renderWithTheme( - + ); expect( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx index b252fd196aa..7fc4a1a78d0 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx @@ -15,6 +15,7 @@ import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; +import { sendLinodePowerOffEvent } from 'src/utilities/analytics/customEventAnalytics'; import { PowerActionsDialog } from '../../PowerActionsDialogOrDrawer'; import { SelectLinodeCards } from './SelectLinodeCards'; @@ -26,7 +27,7 @@ interface Props { handleSelection: (id: number, type: null | string, diskSize?: number) => void; header?: string; linodes: Linode[]; - notices?: string[]; + notices?: (JSX.Element | string)[]; selectedLinodeID?: number; showPowerActions?: boolean; } @@ -155,9 +156,10 @@ export const SelectLinodePanel = (props: Props) => { /> - setPowerOffLinode({ linodeId }) - } + handlePowerOff={(linodeId) => { + setPowerOffLinode({ linodeId }); + sendLinodePowerOffEvent('Clone Linode'); + }} orderBy={{ data: linodesData, handleOrderChange, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.test.tsx index 3c2b7922b93..0570941b93f 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeRow.test.tsx @@ -1,12 +1,22 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { imageFactory } from 'src/factories'; -import { linodeFactory } from 'src/factories/linodes'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; + +import { imageFactory, linodeFactory, typeFactory } from 'src/factories'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; import { SelectLinodeRow } from './SelectLinodeRow'; +vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useTypeQuery: vi.fn().mockReturnValue({ + data: typeFactory.build({ label: 'Linode 1 GB' }), + }), + }; +}); + describe('SelectLinodeRow', () => { const handlePowerOff = vi.fn(); const handleSelection = vi.fn(); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx index 24e57f1de05..2cdd81c488c 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -189,7 +189,7 @@ export class FromAppsContent extends React.Component { return ( - + Select an App diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx index d7e8d1b96c0..f228398dc1b 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx @@ -8,7 +8,7 @@ import * as React from 'react'; import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; import { Paper } from 'src/components/Paper'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { reportException } from 'src/exceptionReporting'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -82,7 +82,8 @@ export class FromBackupsContent extends React.Component { const filterLinodesWithBackups = (linodes: Linode[]) => linodes.filter( (linode) => - linode.backups.enabled && !getIsEdgeRegion(regionsData, linode.region) // Hide linodes that are in an edge region + linode.backups.enabled && + !getIsDistributedRegion(regionsData, linode.region) // Hide linodes that are in a distributed region ); return ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx index 19d393ab7ea..433a7c6e2c8 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.tsx @@ -14,6 +14,8 @@ import { WithTypesRegionsAndImages, } from '../types'; import { StyledGrid } from './CommonTabbedContent.styles'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import type { Image } from '@linode/api-v4'; interface Props extends BasicFromContentProps { error?: string; @@ -37,6 +39,8 @@ export const FromImageContent = (props: CombinedProps) => { const privateImages = filterImagesByType(imagesData, 'private'); + const { data: regions } = useRegionsQuery(); + if (variant === 'private' && Object.keys(privateImages).length === 0) { return ( @@ -53,13 +57,31 @@ export const FromImageContent = (props: CombinedProps) => { ); } + const onChange = (image: Image | null) => { + props.updateImageID(image?.id ?? ''); + + const selectedRegion = regions?.find( + (r) => r.id === props.selectedRegionID + ); + + // Non-"distributed compatible" Images must only be deployed to core sites. + // Clear the region field if the currently selected region is a distributed site and the Image is only core compatible. + if ( + image && + !image.capabilities.includes('distributed-images') && + selectedRegion?.site_type === 'distributed' + ) { + props.updateRegionID(''); + } + }; + return ( onChange(image ?? null)} images={Object.keys(imagesData).map((eachKey) => imagesData[eachKey])} selectedImageID={props.selectedImageID} title={imagePanelTitle || 'Choose an Image'} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index 0e739645ff3..d756d80d76d 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -5,7 +5,7 @@ import { useHistory } from 'react-router-dom'; import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; import { Paper } from 'src/components/Paper'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { buildQueryStringForLinodeClone } from 'src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils'; import { useFlags } from 'src/hooks/useFlags'; import { extendType } from 'src/utilities/extendType'; @@ -75,13 +75,13 @@ export const FromLinodeContent = (props: CombinedProps) => { } }; - const filterEdgeLinodes = (linodes: Linode[]) => + const filterDistributedRegionsLinodes = (linodes: Linode[]) => linodes.filter( - (linode) => !getIsEdgeRegion(regionsData, linode.region) // Hide linodes that are in an edge region + (linode) => !getIsDistributedRegion(regionsData, linode.region) // Hide linodes that are in a distributed region ); const filteredLinodes = flags.gecko2?.enabled - ? filterEdgeLinodes(linodesData) + ? filterDistributedRegionsLinodes(linodesData) : linodesData; return ( @@ -106,8 +106,12 @@ export const FromLinodeContent = (props: CombinedProps) => { + To help avoid data corruption during the + cloning process, we recommend powering off your Compute Instance + prior to cloning. + , 'This newly created Linode will be created with the same password and SSH Keys (if any) as the original Linode.', - 'To help avoid data corruption during the cloning process, we recommend powering off your Compute Instance prior to cloning.', ]} data-qa-linode-panel disabled={userCannotCreateLinode} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx index eda08d52851..24ae144710c 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx @@ -38,7 +38,6 @@ export const UserDataAccordionHeading = (props: Props) => { } - interactive status="help" sxTooltipIcon={{ alignItems: 'baseline', padding: '0 8px' }} /> diff --git a/packages/manager/src/features/Linodes/LinodesCreate/types.ts b/packages/manager/src/features/Linodes/LinodesCreate/types.ts index c59f5dc799e..337069893e4 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/types.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/types.ts @@ -91,18 +91,12 @@ export interface BaseFormStateAndHandlers { selectedImageID?: string; selectedRegionID?: string; selectedTypeID?: string; - selectedVlanIDs: number[]; setAuthorizedUsers: (usernames: string[]) => void; - setVlanID: (ids: number[]) => void; tags?: Tag[]; toggleBackupsEnabled: () => void; togglePrivateIPEnabled: () => void; updateImageID: (id: string) => void; - updateLabel: ( - event: React.ChangeEvent< - HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement - > - ) => void; + updateLabel: (label: string) => void; updatePassword: (password: string) => void; updateRegionID: (id: string) => void; updateTags: (tags: Tag[]) => void; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts index 0dd7619430b..150809adfdf 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts @@ -50,43 +50,36 @@ describe('trimOneClickFromLabel', () => { }); describe('filterOneClickApps', () => { - const baseApps = { - 1: 'Base App 1', - 2: 'Base App 2', - 3: 'Base App 3', - 4: 'Base App 4', - }; + const baseAppIds = [2, 3, 4, 5]; const newApps = { - 5: 'New App 1', - 6: 'New App 2', - 7: 'New App 3', - 8: 'New App 4', + 6: 'New App 1', + 7: 'New App 2', + 8: 'New App 3', + 9: 'New App 4', }; - const stackScript = stackScriptFactory.build(); - - // id: 1,2,3,4 + // id: 2,3,4,5 const queryResultsWithHelpers: StackScript[] = [ ...stackScriptFactory.buildList(3), - { ...stackScript, id: 4, label: 'StackScript Helpers' }, + stackScriptFactory.build({ label: 'StackScript Helpers' }), ]; - // id: 5,6,7,8 + // id: 6,7,8,9 const queryResultsWithoutHelpers: StackScript[] = stackScriptFactory.buildList( 4 ); it('filters OneClickApps and trims labels, excluding StackScripts with Helpers', () => { - // feeding 4 Ids (1,2,3,4) getting 3 back + // feeding 4 Ids (2,3,4,5) getting 3 back const filteredOCAsWithHelpersLabel = filterOneClickApps({ - baseApps, + baseAppIds, newApps, queryResults: queryResultsWithHelpers, }); expect(filteredOCAsWithHelpersLabel.length).toBe(3); - // feeding 4 Ids (5,6,7,8) getting 4 back + // feeding 4 Ids (6,7,8,9) getting 4 back const filteredOCAsWithoutHelpersLabel = filterOneClickApps({ - baseApps, + baseAppIds, newApps, queryResults: queryResultsWithoutHelpers, }); @@ -97,7 +90,7 @@ describe('filterOneClickApps', () => { it('handles empty queryResults', () => { const emptyQueryResults: StackScript[] = []; const filteredOCAs = filterOneClickApps({ - baseApps, + baseAppIds, newApps, queryResults: emptyQueryResults, }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx b/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx index 73b5d99dba4..68144a1aed7 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx @@ -51,7 +51,7 @@ export const trimOneClickFromLabel = (stackScript: StackScript) => { }; interface FilteredOCAs { - baseApps: Record; + baseAppIds: number[]; newApps: Record | never[]; queryResults: StackScript[]; } @@ -64,18 +64,17 @@ interface FilteredOCAs { * @returns an array of OCA StackScripts */ export const filterOneClickApps = ({ - baseApps, + baseAppIds, newApps, queryResults, }: FilteredOCAs) => { - const allowedApps = Object.keys({ ...baseApps, ...newApps }); + const allowedAppIds = [...baseAppIds, ...Object.keys(newApps).map(Number)]; + // Don't display One-Click Helpers to the user // Filter out any apps that we don't have info for const filteredApps: StackScript[] = queryResults.filter( (app: StackScript) => { - return ( - !app.label.match(/helpers/i) && allowedApps.includes(String(app.id)) - ); + return !app.label.match(/helpers/i) && allowedAppIds.includes(app.id); } ); return filteredApps.map((app) => trimOneClickFromLabel(app)); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.test.tsx index 477f2d17c19..0e6252d7165 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.test.tsx @@ -5,12 +5,12 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { BackupsPlaceholder } from './BackupsPlaceholder'; describe('BackupsPlaceholder', () => { - it('should disable the enable backups button if linodeIsInEdgeRegion is true', () => { + it('should disable the enable backups button if linodeIsInDistributedRegion is true', () => { const { getByTestId } = renderWithTheme( ); expect(getByTestId('placeholder-button')).toHaveAttribute( @@ -18,12 +18,12 @@ describe('BackupsPlaceholder', () => { 'true' ); }); - it('should not disable the enable backups button if linodeIsInEdgeRegion is false', () => { + it('should not disable the enable backups button if linodeIsInDistributedRegion is false', () => { const { getByTestId } = renderWithTheme( ); expect(getByTestId('placeholder-button')).toHaveAttribute( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx index c897376e0db..f75c567dfda 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx @@ -13,7 +13,7 @@ interface Props { backupsMonthlyPrice?: PriceObject['monthly']; disabled: boolean; linodeId: number; - linodeIsInEdgeRegion?: boolean; + linodeIsInDistributedRegion?: boolean; } export const BackupsPlaceholder = React.memo((props: Props) => { @@ -21,7 +21,7 @@ export const BackupsPlaceholder = React.memo((props: Props) => { backupsMonthlyPrice, disabled, linodeId, - linodeIsInEdgeRegion, + linodeIsInDistributedRegion, } = props; const [dialogOpen, setDialogOpen] = React.useState(false); @@ -50,10 +50,10 @@ export const BackupsPlaceholder = React.memo((props: Props) => { buttonProps={[ { children: 'Enable Backups', - disabled: disabled || linodeIsInEdgeRegion, + disabled: disabled || linodeIsInDistributedRegion, onClick: () => setDialogOpen(true), - tooltipText: linodeIsInEdgeRegion - ? 'Backups are currently not available for Edge regions.' + tooltipText: linodeIsInDistributedRegion + ? 'Backups are currently not available for distributed regions.' : undefined, }, ]} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx index c08be4e4079..c8bdcc501a2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.test.tsx @@ -1,9 +1,12 @@ import * as React from 'react'; + +import { DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY } from 'src/components/DiskEncryption/constants'; +import { linodeFactory } from 'src/factories'; +import { typeFactory } from 'src/factories/types'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; + import { EnableBackupsDialog } from './EnableBackupsDialog'; -import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; -import { typeFactory } from 'src/factories/types'; -import { linodeFactory } from 'src/factories'; const queryMocks = vi.hoisted(() => ({ useLinodeQuery: vi.fn().mockReturnValue({ @@ -30,12 +33,16 @@ vi.mock('src/queries/types', async () => { }; }); +const diskEncryptionEnabledMock = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; +}); + describe('EnableBackupsDialog component', () => { beforeEach(() => { queryMocks.useTypeQuery.mockReturnValue({ data: typeFactory.build({ - id: 'mock-linode-type', - label: 'Mock Linode Type', addons: { backups: { price: { @@ -51,17 +58,36 @@ describe('EnableBackupsDialog component', () => { ], }, }, + id: 'mock-linode-type', + label: 'Mock Linode Type', }), }); }); + 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('Displays the monthly backup price', async () => { queryMocks.useLinodeQuery.mockReturnValue({ data: linodeFactory.build({ id: 1, label: 'Mock Linode', - type: 'mock-linode-type', region: 'us-east', + type: 'mock-linode-type', }), }); @@ -84,12 +110,12 @@ describe('EnableBackupsDialog component', () => { data: linodeFactory.build({ id: 1, label: 'Mock Linode', - type: 'mock-linode-type', region: 'es-mad', + type: 'mock-linode-type', }), }); - const { getByTestId, findByText, queryByText } = renderWithTheme( + const { findByText, getByTestId, queryByText } = renderWithTheme( ); @@ -118,12 +144,12 @@ describe('EnableBackupsDialog component', () => { data: linodeFactory.build({ id: 1, label: 'Mock Linode', - type: 'mock-linode-type', region: 'es-mad', + type: 'mock-linode-type', }), }); - const { getByTestId, findByText } = renderWithTheme( + const { findByText, getByTestId } = renderWithTheme( ); @@ -133,4 +159,38 @@ describe('EnableBackupsDialog component', () => { // Confirm that "Enable Backups" button is disabled. expect(getByTestId('confirm-enable-backups')).toBeDisabled(); }); + + it('does not display a notice regarding Backups not being encrypted if the Disk Encryption feature is disabled', () => { + const { queryByText } = renderWithTheme( + + ); + + const encryptionBackupsCaveatNotice = queryByText( + DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY + ); + + expect(encryptionBackupsCaveatNotice).not.toBeInTheDocument(); + }); + + it('displays a notice regarding Backups not being encrypted if the Disk Encryption feature is enabled', () => { + diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce( + () => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + } + ); + + const { queryByText } = renderWithTheme( + + ); + + const encryptionBackupsCaveatNotice = queryByText( + DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY + ); + + expect(encryptionBackupsCaveatNotice).toBeInTheDocument(); + + diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockRestore(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx index 9c28b43666f..ce79d0d47c1 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx @@ -5,6 +5,8 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Currency } from 'src/components/Currency'; +import { DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY } from 'src/components/DiskEncryption/constants'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useEventsPollingActions } from 'src/queries/events/events'; @@ -40,6 +42,10 @@ export const EnableBackupsDialog = (props: Props) => { Boolean(linode?.type) ); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + const backupsMonthlyPrice: | PriceObject['monthly'] | undefined = getMonthlyBackupsPrice({ @@ -95,6 +101,13 @@ export const EnableBackupsDialog = (props: Props) => { open={open} title="Enable backups?" > + {isDiskEncryptionFeatureEnabled && ( + + )} {!hasBackupsMonthlyPriceError ? ( Are you sure you want to enable backups on this Linode?{` `} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx index d43152f6cf6..0ed0f699554 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx @@ -8,7 +8,7 @@ import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Paper } from 'src/components/Paper'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -18,7 +18,7 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { Typography } from 'src/components/Typography'; import { useLinodeBackupsQuery } from 'src/queries/linodes/backups'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; @@ -64,7 +64,7 @@ export const LinodeBackups = () => { const [selectedBackup, setSelectedBackup] = React.useState(); - const linodeIsInEdgeRegion = getIsEdgeRegion( + const linodeIsInDistributedRegion = getIsDistributedRegion( regions ?? [], linode?.region ?? '' ); @@ -94,7 +94,7 @@ export const LinodeBackups = () => { backupsMonthlyPrice={backupsMonthlyPrice} disabled={doesNotHavePermission} linodeId={id} - linodeIsInEdgeRegion={linodeIsInEdgeRegion} + linodeIsInDistributedRegion={linodeIsInDistributedRegion} /> ); } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx index 46c447e94e1..ca78e553f1a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx @@ -14,7 +14,7 @@ import { useLinodeQuery, useLinodeUpdateMutation, } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getUserTimezone } from 'src/utilities/getUserTimezone'; import { initWindows } from 'src/utilities/initWindows'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 800f43a202e..b72943e99c6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -62,7 +62,7 @@ import { } from 'src/utilities/formikErrorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; import { ExtendedIP } from 'src/utilities/ipUtils'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { ExtendedInterface, @@ -245,6 +245,7 @@ const deviceCounterDefault = 1; const finnixDiskID = 25669; export const LinodeConfigDialog = (props: Props) => { + const formContainerRef = React.useRef(null); const { config, isReadOnly, linodeId, onClose, open } = props; const { data: linode } = useLinodeQuery(linodeId, open); @@ -304,7 +305,10 @@ export const LinodeConfigDialog = (props: Props) => { const { resetForm, setFieldValue, values, ...formik } = useFormik({ initialValues: defaultFieldsValues, onSubmit: (values) => onSubmit(values), - validate: (values) => onValidate(values), + validate: (values) => { + onValidate(values); + scrollErrorIntoViewV2(formContainerRef); + }, validateOnChange: false, validateOnMount: false, }); @@ -449,7 +453,6 @@ export const LinodeConfigDialog = (props: Props) => { error, 'An unexpected error occurred.' ); - scrollErrorIntoView('linode-config-dialog'); }; /** Editing */ @@ -687,7 +690,7 @@ export const LinodeConfigDialog = (props: Props) => { open={open} title={`${config ? 'Edit' : 'Add'} Configuration`} > - + {generalError && ( @@ -956,7 +959,6 @@ export const LinodeConfigDialog = (props: Props) => { paddingBottom: 0, paddingTop: 0, }} - interactive status="help" sx={{ tooltip: { maxWidth: 350 } }} text={networkInterfacesHelperText} @@ -1123,7 +1125,6 @@ export const LinodeConfigDialog = (props: Props) => { } checked={values.helpers.network} disabled={isReadOnly} - interactive={true} onChange={formik.handleChange} /> } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx index 603b6bc411c..1b1f25cbe3a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx @@ -16,7 +16,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { useAllLinodeConfigsQuery } from 'src/queries/linodes/configs'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { sendLinodeConfigurationDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; import { BootConfigDialog } from './BootConfigDialog'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.test.tsx index c5ae8502b86..87715c2920e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.test.tsx @@ -5,11 +5,11 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { AddIPDrawer } from './AddIPDrawer'; describe('AddIPDrawer', () => { - it('should display a warning notice if linodeIsInEdgeRegion is true', () => { + it('should display a warning notice if linodeIsInDistributedRegion is true', () => { const { getByTestId } = renderWithTheme( null} open={true} readOnly={false} @@ -17,11 +17,11 @@ describe('AddIPDrawer', () => { ); expect(getByTestId('notice-warning')).toBeInTheDocument(); }); - it('should not display a warning notice if linodeIsInEdgeRegion is false', () => { + it('should not display a warning notice if linodeIsInDistributedRegion is false', () => { const { queryByTestId } = renderWithTheme( null} open={true} readOnly={false} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index 0ea5b71cc17..6d8688f9b04 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -76,14 +76,20 @@ const tooltipCopy: Record = { interface Props { linodeId: number; - linodeIsInEdgeRegion?: boolean; + linodeIsInDistributedRegion?: boolean; onClose: () => void; open: boolean; readOnly: boolean; } export const AddIPDrawer = (props: Props) => { - const { linodeId, linodeIsInEdgeRegion, onClose, open, readOnly } = props; + const { + linodeId, + linodeIsInDistributedRegion, + onClose, + open, + readOnly, + } = props; const { error: ipv4Error, @@ -179,10 +185,10 @@ export const AddIPDrawer = (props: Props) => { onChange={handleIPv4Change} value={selectedIPv4} > - {linodeIsInEdgeRegion && ( + {linodeIsInDistributedRegion && ( )} @@ -192,7 +198,9 @@ export const AddIPDrawer = (props: Props) => { } data-qa-radio={option.label} - disabled={option.value === 'v4Private' && linodeIsInEdgeRegion} + disabled={ + option.value === 'v4Private' && linodeIsInDistributedRegion + } key={idx} label={option.label} value={option.value} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx index cea0386e54e..872c66cb5be 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx @@ -503,7 +503,7 @@ export const IPTransfer = (props: Props) => { ) : null} {(isLoading || ipv6RangesLoading) && searchText === '' ? (
    - +
    ) : ( <> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsActionMenu.tsx index 04e0c74e5cb..35b15fc5219 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsActionMenu.tsx @@ -4,7 +4,7 @@ import { Action } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { noPermissionTooltipText } from 'src/features/Firewalls/FirewallLanding/FirewallActionMenu'; import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; interface LinodeFirewallsActionMenuProps { firewallID: number; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx index 301a71df7ea..ea130e2c723 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx @@ -37,11 +37,7 @@ export const LinodeFirewallsRow = (props: LinodeFirewallsRowProps) => { const count = getCountOfRules(rules); return ( - + {label} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx index 9f517a993b5..903580bcbb9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx @@ -151,7 +151,7 @@ const RangeRDNSCell = (props: { const ipsWithRDNS = listIPv6InRange(range.range, range.prefix, ipsInRegion); if (ipv6Loading) { - return ; + return ; } // We don't show anything if there are no addresses. diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx index 2e6acad4c81..21139e99edd 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx @@ -10,7 +10,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; import OrderBy from 'src/components/OrderBy'; import { Paper } from 'src/components/Paper'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -54,7 +54,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { const { data: linode } = useLinodeQuery(linodeID); const { data: regions } = useRegionsQuery(); - const linodeIsInEdgeRegion = getIsEdgeRegion( + const linodeIsInDistributedRegion = getIsDistributedRegion( regions ?? [], linode?.region ?? '' ); @@ -257,7 +257,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { /> setIsAddDrawerOpen(false)} open={isAddDrawerOpen} readOnly={isLinodesGrantReadOnly} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx index 9a6cc88ef49..c0b65afd2e6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx @@ -75,7 +75,7 @@ export const TransferContent = (props: ContentProps) => { return ( - + ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx index 3fbbb76e73e..8f2a972affa 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx @@ -23,7 +23,7 @@ import { useLinodeStatsByDate, useLinodeTransferByDate, } from 'src/queries/linodes/stats'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { readableBytes } from 'src/utilities/unitConversions'; @@ -119,7 +119,7 @@ export const TransferHistory = React.memo((props: Props) => { if (statsLoading) { return ( - + ); } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx index 1a85ffba943..2e10b041038 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx @@ -2,17 +2,21 @@ import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Dialog } from 'src/components/Dialog/Dialog'; -import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; +import EnhancedSelect from 'src/components/EnhancedSelect/Select'; import { Notice } from 'src/components/Notice/Notice'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; +import { useRegionsQuery } from 'src/queries/regions/regions'; import { HostMaintenanceError } from '../HostMaintenanceError'; import { LinodePermissionsError } from '../LinodePermissionsError'; import { RebuildFromImage } from './RebuildFromImage'; import { RebuildFromStackScript } from './RebuildFromStackScript'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + interface Props { linodeId: number | undefined; onClose: () => 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 9ac76f6f83d..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'; @@ -21,13 +22,14 @@ import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utili import { useFlags } from 'src/hooks/useFlags'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllImagesQuery } from 'src/queries/images'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; 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 a557df4d25a..5639ab211c7 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx @@ -25,7 +25,7 @@ import { useStackScript } from 'src/hooks/useStackScript'; import { listToItemsByID } from 'src/queries/base'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllImagesQuery } from 'src/queries/images'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { filterImagesByType } from 'src/store/image/image.helpers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { @@ -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/LinodeRescue/StandardRescueDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx index cb09d9ca5f8..ec80ceab472 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx @@ -17,7 +17,7 @@ import { useLinodeQuery, useLinodeRescueMutation, } from 'src/queries/linodes/linodes'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; import { DevicesAsStrings, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index 3fde980bb26..877137b03ff 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -1,7 +1,3 @@ -import { - MigrationTypes, - ResizeLinodePayload, -} from '@linode/api-v4/lib/linodes'; import { useTheme } from '@mui/material/styles'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; @@ -10,6 +6,7 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Checkbox } from 'src/components/Checkbox'; +import { CircleProgress } from 'src/components/CircleProgress/CircleProgress'; import { Dialog } from 'src/components/Dialog/Dialog'; import { Divider } from 'src/components/Divider'; import { Link } from 'src/components/Link'; @@ -26,11 +23,11 @@ import { useLinodeQuery, useLinodeResizeMutation, } from 'src/queries/linodes/linodes'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useAllTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { HostMaintenanceError } from '../HostMaintenanceError'; import { LinodePermissionsError } from '../LinodePermissionsError'; @@ -41,6 +38,11 @@ import { } from './LinodeResize.utils'; import { UnifiedMigrationPanel } from './LinodeResizeUnifiedMigrationPanel'; +import type { + MigrationTypes, + ResizeLinodePayload, +} from '@linode/api-v4/lib/linodes'; + interface Props { linodeId?: number; linodeLabel?: string; @@ -57,7 +59,7 @@ export const LinodeResize = (props: Props) => { const { linodeId, onClose, open } = props; const theme = useTheme(); - const { data: linode } = useLinodeQuery( + const { data: linode, isLoading: isLinodeDataLoading } = useLinodeQuery( linodeId ?? -1, linodeId !== undefined && open ); @@ -77,6 +79,8 @@ export const LinodeResize = (props: Props) => { const [hasResizeError, setHasResizeError] = React.useState(false); + const formRef = React.useRef(null); + const { error: resizeError, isLoading, @@ -125,6 +129,7 @@ export const LinodeResize = (props: Props) => { }); onClose(); }, + validate: () => scrollErrorIntoViewV2(formRef), }); React.useEffect(() => { @@ -154,8 +159,6 @@ export const LinodeResize = (props: Props) => { React.useEffect(() => { if (resizeError) { setHasResizeError(true); - // Set to "block: end" since the sticky header would otherwise interfere. - scrollErrorIntoView(undefined, { block: 'end' }); } }, [resizeError]); @@ -190,148 +193,152 @@ export const LinodeResize = (props: Props) => { maxWidth="md" onClose={onClose} open={open} - title={`Resize Linode ${linode?.label}`} + title={`Resize Linode ${linode?.label ?? ''}`} > - - {isLinodesGrantReadOnly && } - {hostMaintenance && } - {disksError && ( - - )} - {hasResizeError && {error}} - - If you’re expecting a temporary burst of traffic to your - website, or if you’re not using your Linode as much as you - thought, you can temporarily or permanently resize your Linode to a - different plan.{' '} - - Learn more. - - - - div': { - padding: 0, - }, - marginBottom: theme.spacing(3), - marginTop: theme.spacing(5), - }} - > - formik.setFieldValue('type', type)} - regionsData={regionsData} - selectedId={formik.values.type} - selectedRegionID={linode?.region} - types={currentTypes.map(extendType)} - /> - - - - Auto Resize Disk - {disksError ? ( - + ) : ( + + {isLinodesGrantReadOnly && } + {hostMaintenance && } + {disksError && ( + - ) : isSmaller ? ( - {error}} + + If you’re expecting a temporary burst of traffic to your + website, or if you’re not using your Linode as much as you + thought, you can temporarily or permanently resize your Linode to a + different plan.{' '} + + Learn more. + + + + div': { + padding: 0, + }, + marginBottom: theme.spacing(3), + marginTop: theme.spacing(5), + }} + > + formik.setFieldValue('type', type)} + regionsData={regionsData} + selectedId={formik.values.type} + selectedRegionID={linode?.region} + types={currentTypes.map(extendType)} /> - ) : !_shouldEnableAutoResizeDiskOption ? ( - + + + Auto Resize Disk + {disksError ? ( + + ) : isSmaller ? ( + + ) : !_shouldEnableAutoResizeDiskOption ? ( + - ) : null} - - - formik.setFieldValue('allow_auto_disk_resize', checked) - } - text={ - - Would you like{' '} - {_shouldEnableAutoResizeDiskOption ? ( - {diskToResize} - ) : ( - 'your disk' - )}{' '} - to be automatically scaled with this Linode’s new size?{' '} -
    - We recommend you keep this option enabled when available. -
    - } - disabled={!_shouldEnableAutoResizeDiskOption || isSmaller} - /> - - - - To confirm these changes, type the label of the Linode ( - {linode?.label}) in the field below: - + status="help" + /> + ) : null} +
    + - - - - - + text={ + + Would you like{' '} + {_shouldEnableAutoResizeDiskOption ? ( + {diskToResize} + ) : ( + 'your disk' + )}{' '} + to be automatically scaled with this Linode’s new size?{' '} +
    + We recommend you keep this option enabled when available. +
    + } + disabled={!_shouldEnableAutoResizeDiskOption || isSmaller} + /> + + + + To confirm these changes, type the label of the Linode ( + {linode?.label}) in the field below: + + } + hideLabel + label="Linode Label" + onChange={setConfirmationText} + textFieldStyle={{ marginBottom: 16 }} + title="Confirm" + typographyStyle={{ marginBottom: 8 }} + value={confirmationText} + visible={preferences?.type_to_confirm} + /> + + + + + + )}
    ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx index a5ccf0ccbf3..a1cbda31ec4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx @@ -81,7 +81,6 @@ export const UnifiedMigrationPanel = (props: Props) => { )} } - interactive status="help" tooltipPosition="right" width={[theme.breakpoints.up('sm')] ? 375 : 300} @@ -111,7 +110,6 @@ export const UnifiedMigrationPanel = (props: Props) => { } - interactive status="help" tooltipPosition="right" width={[theme.breakpoints.up('sm')] ? 450 : 300} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx index bffcc435beb..29004901f82 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; - import { profileFactory } from 'src/factories'; import { accountUserFactory } from 'src/factories/accountUsers'; import { grantsFactory } from 'src/factories/grants'; @@ -26,12 +25,15 @@ describe('ImageAndPassword', () => { expect(getByLabelText('Image')).toBeEnabled(); }); it('should render a password error if defined', async () => { - const passwordError = 'Unable to set password.'; + const errorMessage = 'Unable to set password.'; const { findByText } = renderWithTheme( - + ); - expect(await findByText(passwordError)).toBeVisible(); + const passwordError = await findByText(errorMessage, undefined, { + timeout: 2500, + }); + expect(passwordError).toBeVisible(); }); it('should render an SSH Keys section', async () => { const { getByText } = renderWithTheme(); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx index cbf0a15c7b5..ccd8d36357b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx @@ -5,7 +5,7 @@ import { AccessPanel } from 'src/components/AccessPanel/AccessPanel'; import { Item } from 'src/components/EnhancedSelect/Select'; import { ImageSelect } from 'src/features/Images/ImageSelect'; import { useAllImagesQuery } from 'src/queries/images'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { LinodePermissionsError } from '../LinodePermissionsError'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 524eda85802..e4f9ccdbdb4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { Divider } from 'src/components/Divider'; import Select from 'src/components/EnhancedSelect/Select'; +import { Notice } from 'src/components/Notice/Notice'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; @@ -384,26 +385,33 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { return ( {fromAddonsPanel ? null : ( - - 0 + ? purposeOptions + : purposeOptions.filter( + (thisPurposeOption) => thisPurposeOption.value !== 'none' + ) + } + value={purposeOptions.find( + (thisOption) => thisOption.value === purpose + )} + disabled={readOnly} + isClearable={false} + label={`eth${slotNumber}`} + onChange={handlePurposeChange} + /> + {unavailableInRegionHelperTextJSX} + + )} {purpose === 'vlan' && regionHasVLANs !== false && diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx index 892dec45064..fa5ea84dd97 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { useParams } from 'react-router-dom'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { LinodeSettingsAlertsPanel } from './LinodeSettingsAlertsPanel'; import { LinodeSettingsDeletePanel } from './LinodeSettingsDeletePanel'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx index e1a22966b17..e7eafa6cad4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx @@ -1,8 +1,9 @@ -import { Box, CircularProgress, Stack } from '@mui/material'; +import { Box, Stack } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; +import { CircleProgress } from 'src/components/CircleProgress'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Notice } from 'src/components/Notice/Notice'; import { Toggle } from 'src/components/Toggle/Toggle'; @@ -58,7 +59,7 @@ export const LinodeWatchdogPanel = (props: Props) => { label={ {linode?.watchdog_enabled ? 'Enabled' : 'Disabled'} - {isLoading && } + {isLoading && } } disabled={isReadOnly} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx deleted file mode 100644 index f14d1a48988..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Disk } from '@linode/api-v4'; -import { Typography } from '@mui/material'; -import { useSnackbar } from 'notistack'; -import React from 'react'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { SupportLink } from 'src/components/SupportLink/SupportLink'; -import { useCreateImageMutation } from 'src/queries/images'; - -interface Props { - disk: Disk | undefined; - linodeId: number; - onClose: () => void; - open: boolean; -} - -export const CreateImageFromDiskDialog = (props: Props) => { - const { disk, linodeId, onClose, open } = props; - const { enqueueSnackbar } = useSnackbar(); - - const { - error, - isLoading, - mutateAsync: createImage, - reset, - } = useCreateImageMutation(); - - React.useEffect(() => { - if (open) { - reset(); - } - }, [open]); - - const onCreate = async () => { - await createImage({ - disk_id: disk?.id ?? -1, - }); - enqueueSnackbar('Image scheduled for creation.', { - variant: 'info', - }); - onClose(); - }; - - const ticketDescription = error - ? `I see a notice saying "${error?.[0].reason}" when trying to create an Image from my disk ${disk?.label} (${disk?.id}).` - : `I would like to create an Image from my disk ${disk?.label} (${disk?.id}).`; - - return ( - - } - error={error?.[0].reason} - onClose={onClose} - open={open} - title={`Create Image from ${disk?.label}?`} - > - - Linode Images are limited to 6144 MB of data per disk by default. Please - ensure that your disk content does not exceed this size limit, or{' '} - {' '} - to request a higher limit. Additionally, Linode Images cannot be created - if you are using raw disks or disks that have been formatted using - custom filesystems. - - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.test.tsx new file mode 100644 index 00000000000..e591146c959 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.test.tsx @@ -0,0 +1,176 @@ +import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { linodeDiskFactory } from 'src/factories'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { LinodeDiskActionMenu } from './LinodeDiskActionMenu'; + +const mockHistory = { + push: vi.fn(), +}; + +// Mock useHistory +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: vi.fn(() => mockHistory), + }; +}); + +const defaultProps = { + disk: linodeDiskFactory.build(), + linodeId: 0, + linodeStatus: 'running' as const, + onDelete: vi.fn(), + onRename: vi.fn(), + onResize: vi.fn(), +}; + +describe('LinodeActionMenu', () => { + beforeEach(() => mockMatchMedia()); + + it('should contain all basic actions when the Linode is running', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + const actions = [ + 'Rename', + 'Resize', + 'Create Disk Image', + 'Clone', + 'Delete', + ]; + + for (const action of actions) { + expect(getByText(action)).toBeVisible(); + } + }); + + it('should show inline actions for md screens', async () => { + mockMatchMedia(false); + + const { getByText } = renderWithTheme( + + ); + + ['Rename', 'Resize'].forEach((action) => + expect(getByText(action)).toBeVisible() + ); + }); + + it('should hide inline actions for sm screens', async () => { + const { queryByText } = renderWithTheme( + + ); + + ['Rename', 'Resize'].forEach((action) => + expect(queryByText(action)).toBeNull() + ); + }); + + it('should allow performing actions', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + await userEvent.click(getByText('Rename')); + expect(defaultProps.onRename).toHaveBeenCalled(); + + await userEvent.click(getByText('Resize')); + expect(defaultProps.onResize).toHaveBeenCalled(); + + await userEvent.click(getByText('Delete')); + expect(defaultProps.onDelete).toHaveBeenCalled(); + }); + + it('Create Disk Image should redirect to image create tab', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + await userEvent.click(getByText('Create Disk Image')); + + expect(mockHistory.push).toHaveBeenCalledWith( + `/images/create/disk?selectedLinode=${defaultProps.linodeId}&selectedDisk=${defaultProps.disk.id}` + ); + }); + + it('Clone should redirect to clone page', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + await userEvent.click(getByText('Clone')); + + expect(mockHistory.push).toHaveBeenCalledWith( + `/linodes/${defaultProps.linodeId}/clone/disks?selectedDisk=${defaultProps.disk.id}` + ); + }); + + it('should disable Resize and Delete when the Linode is running', async () => { + const { getAllByLabelText, getByLabelText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + expect( + getAllByLabelText( + 'Your Linode must be fully powered down in order to perform this action' + ) + ).toHaveLength(2); + }); + + it('should disable Create Disk Image when the disk is a swap image', async () => { + const disk = linodeDiskFactory.build({ filesystem: 'swap' }); + + const { getByLabelText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${disk.label}` + ); + + await userEvent.click(actionMenuButton); + + const tooltip = getByLabelText( + 'You cannot create images from Swap images.' + ); + expect(tooltip).toBeInTheDocument(); + fireEvent.click(tooltip); + expect(tooltip).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx index d3a8aa5f563..c1962d68618 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx @@ -1,21 +1,23 @@ -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { splitAt } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Box } from 'src/components/Box'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { sendEvent } from 'src/utilities/analytics/utils'; +import type { Disk, Linode } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + interface Props { - diskId?: number; - label: string; - linodeId?: number; - linodeStatus: string; + disk: Disk; + linodeId: number; + linodeStatus: Linode['status']; onDelete: () => void; - onImagize: () => void; onRename: () => void; onResize: () => void; readOnly?: boolean; @@ -27,21 +29,25 @@ export const LinodeDiskActionMenu = (props: Props) => { const history = useHistory(); const { - diskId, + disk, linodeId, linodeStatus, onDelete, - onImagize, onRename, onResize, readOnly, } = props; - const tooltip = + const poweredOnTooltip = linodeStatus !== 'offline' ? 'Your Linode must be fully powered down in order to perform this action' : undefined; + const swapTooltip = + disk.filesystem == 'swap' + ? 'You cannot create images from Swap images.' + : undefined; + const actions: Action[] = [ { disabled: readOnly, @@ -52,17 +58,23 @@ export const LinodeDiskActionMenu = (props: Props) => { disabled: linodeStatus !== 'offline' || readOnly, onClick: onResize, title: 'Resize', - tooltip, + tooltip: poweredOnTooltip, }, { - disabled: readOnly, - onClick: onImagize, - title: 'Imagize', + disabled: readOnly || !!swapTooltip, + onClick: () => + history.push( + `/images/create/disk?selectedLinode=${linodeId}&selectedDisk=${disk.id}` + ), + title: 'Create Disk Image', + tooltip: swapTooltip, }, { disabled: readOnly, onClick: () => { - history.push(`/linodes/${linodeId}/clone/disks?selectedDisk=${diskId}`); + history.push( + `/linodes/${linodeId}/clone/disks?selectedDisk=${disk.id}` + ); }, title: 'Clone', }, @@ -70,7 +82,7 @@ export const LinodeDiskActionMenu = (props: Props) => { disabled: linodeStatus !== 'offline' || readOnly, onClick: onDelete, title: 'Delete', - tooltip, + tooltip: poweredOnTooltip, }, ]; @@ -101,7 +113,7 @@ export const LinodeDiskActionMenu = (props: Props) => { ))} ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx index f68bfa0d38b..b93f6090e31 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx @@ -1,4 +1,3 @@ -import { Disk } from '@linode/api-v4/lib/linodes'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -11,12 +10,13 @@ import { useInProgressEvents } from 'src/queries/events/events'; import { LinodeDiskActionMenu } from './LinodeDiskActionMenu'; +import type { Disk, Linode } from '@linode/api-v4'; + interface Props { disk: Disk; - linodeId?: number; - linodeStatus: string; + linodeId: number; + linodeStatus: Linode['status']; onDelete: () => void; - onImagize: () => void; onRename: () => void; onResize: () => void; readOnly: boolean; @@ -30,7 +30,6 @@ export const LinodeDiskRow = React.memo((props: Props) => { linodeId, linodeStatus, onDelete, - onImagize, onRename, onResize, readOnly, @@ -86,12 +85,10 @@ export const LinodeDiskRow = React.memo((props: Props) => { { const disksHeaderRef = React.useRef(null); @@ -48,7 +48,6 @@ export const LinodeDisks = () => { const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false); const [isRenameDrawerOpen, setIsRenameDrawerOpen] = React.useState(false); const [isResizeDrawerOpen, setIsResizeDrawerOpen] = React.useState(false); - const [isImageDialogOpen, setIsImageDialogOpen] = React.useState(false); const [selectedDiskId, setSelectedDiskId] = React.useState(); const selectedDisk = disks?.find((d) => d.id === selectedDiskId); @@ -81,11 +80,6 @@ export const LinodeDisks = () => { setIsDeleteDialogOpen(true); }; - const onImagize = (disk: Disk) => { - setSelectedDiskId(disk.id); - setIsImageDialogOpen(true); - }; - const renderTableContent = (disks: Disk[] | undefined) => { if (error) { return ; @@ -106,7 +100,6 @@ export const LinodeDisks = () => { linodeId={id} linodeStatus={linode?.status ?? 'offline'} onDelete={() => onDelete(disk)} - onImagize={() => onImagize(disk)} onRename={() => onRename(disk)} onResize={() => onResize(disk)} readOnly={readOnly} @@ -247,12 +240,6 @@ export const LinodeDisks = () => { onClose={() => setIsResizeDrawerOpen(false)} open={isResizeDrawerOpen} /> - setIsImageDialogOpen(false)} - open={isImageDialogOpen} - />
    ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/ActivityRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/ActivityRow.tsx deleted file mode 100644 index 36f68f548a4..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/ActivityRow.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; -import Grid from '@mui/material/Unstable_Grid2'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import { Typography } from 'src/components/Typography'; -import { generateEventMessage } from 'src/features/Events/eventMessageGenerator'; -import { formatEventSeconds } from 'src/utilities/minute-conversion'; - -interface Props { - event: Event; -} - -export const ActivityRow = (props: Props) => { - const { event } = props; - - const message = generateEventMessage(event); - - // There is currently an API bug where host_reboot event durations are not - // reported correctly. This patch simply hides the duration. @todo remove this - // check when the API bug is fixed. - const duration = - event.action === 'host_reboot' ? '' : formatEventSeconds(event.duration); - - if (!message) { - return null; - } - - return ( - - - - {message} {duration && `(${duration})`} - - - - - - - ); -}; - -const StyledGrid = styled(Grid, { label: 'StyledGrid' })(({ theme }) => ({ - borderBottom: `1px solid ${theme.palette.divider}`, - margin: 0, - padding: theme.spacing(1), - width: '100%', -})); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index 10b9bdbd9bf..2a38f1b9474 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -24,7 +24,7 @@ import { useLinodeStats, useLinodeStatsByDate, } from 'src/queries/linodes/stats'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { setUpCharts } from 'src/utilities/charts'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/StatsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/StatsPanel.tsx index 0c3b9b3b82b..d6a3576cb9b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/StatsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/StatsPanel.tsx @@ -21,14 +21,14 @@ export const StatsPanel = (props: Props) => { {loading ? (
    - +
    ) : ( renderBody() 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/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx index af52fbcd5e8..c6ecfe0e849 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx @@ -9,7 +9,7 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useDialog } from 'src/hooks/useDialog'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/mutationDrawerState.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/mutationDrawerState.ts deleted file mode 100644 index aec1d13b02c..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/mutationDrawerState.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { withStateHandlers } from 'recompose'; - -interface State { - mutationDrawerError: string; - mutationDrawerLoading: boolean; - mutationDrawerOpen: boolean; -} - -interface Handlers { - [key: string]: any; - closeMutationDrawer: () => void; - mutationFailed: (error: string) => void; - openMutationDrawer: () => void; -} - -export interface MutationDrawerProps extends State, Handlers {} - -export default withStateHandlers( - (ownProps) => ({ - mutationDrawerError: '', - mutationDrawerLoading: false, - mutationDrawerOpen: false, - }), - { - closeMutationDrawer: (state) => () => ({ - ...state, - mutationDrawerOpen: false, - }), - - mutationFailed: (state) => (error: string) => ({ - mutationDrawerError: error, - mutationDrawerLoading: false, - mutationDrawerOpen: true, - }), - - openMutationDrawer: (state) => () => ({ - mutationDrawerError: '', - mutationDrawerLoading: false, - mutationDrawerOpen: true, - }), - } -); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx index 10253f3994b..667158a0c04 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx @@ -7,7 +7,7 @@ import { Typography } from 'src/components/Typography'; import { LinodeEntityDetail } from 'src/features/Linodes/LinodeEntityDetail'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useLinodeUpdateMutation } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { RenderLinodesProps } from './DisplayLinodes'; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx index c84d08f98ba..9b2fa108385 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx @@ -126,14 +126,14 @@ describe('LinodeActionMenu', () => { ); }); - it('should disable the clone action if the Linode is in an edge region', async () => { - const propsWithEdgeRegion = { + it('should disable the clone action if the Linode is in a distributed region', async () => { + const propsWithDistributedRegion = { ...props, - linodeRegion: 'us-edge-1', + linodeRegion: 'us-den-10', }; const { getByLabelText, getByTestId } = renderWithTheme( - + ); await userEvent.click(getByLabelText(/^Action menu for/)); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx index 61549293a1c..836555c2435 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { ActionType, getRestrictedResourceText, @@ -81,10 +81,13 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { props.onOpenPowerDialog(action); }; - const linodeIsInEdgeRegion = getIsEdgeRegion(regions, linodeRegion); + const linodeIsInDistributedRegion = getIsDistributedRegion( + regions, + linodeRegion + ); - const edgeRegionTooltipText = - 'Cloning is currently not supported for Edge instances.'; + const distributedRegionTooltipText = + 'Cloning is currently not supported for distributed region instances.'; const actionConfigs: ActionConfig[] = [ { @@ -124,7 +127,8 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { }, { condition: !isBareMetalInstance, - disabled: isLinodeReadOnly || hasHostMaintenance || linodeIsInEdgeRegion, + disabled: + isLinodeReadOnly || hasHostMaintenance || linodeIsInDistributedRegion, isReadOnly: isLinodeReadOnly, onClick: () => { sendLinodeActionMenuItemEvent('Clone'); @@ -141,8 +145,8 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { }, title: 'Clone', tooltipAction: 'clone', - tooltipText: linodeIsInEdgeRegion - ? edgeRegionTooltipText + tooltipText: linodeIsInDistributedRegion + ? distributedRegionTooltipText : maintenanceTooltipText, }, { diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx index 083885f2282..4e082b758c9 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx @@ -42,6 +42,7 @@ describe('LinodeRow', () => { ipv6={linode.ipv6 || ''} key={`linode-row-${1}`} label={linode.label} + lke_cluster_id={linode.lke_cluster_id} placement_group={linode.placement_group} region={linode.region} specs={linode.specs} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index 42dbda3b996..c1de987b624 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -93,7 +93,6 @@ export const LinodeRow = (props: Props) => { return ( {
    Maintenance Scheduled } diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index 00775ce4dce..ac887bb88d0 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { Link, RouteComponentProps, withRouter } from 'react-router-dom'; -import { compose } from 'recompose'; import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -451,10 +450,4 @@ const sendGroupByAnalytic = (value: boolean) => { sendGroupByTagEnabledEvent(eventCategory, value); }; -export const enhanced = compose( - withRouter, - withProfile, - withFeatureFlags -); - -export default enhanced(ListLinodes); +export default withRouter(withProfile(withFeatureFlags(ListLinodes))); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx index ac316735313..d8b35d2adf8 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx @@ -46,6 +46,8 @@ export const ListView = (props: RenderLinodesProps) => { ipv6={linode.ipv6 || ''} key={`linode-row-${idx}`} label={linode.label} + lke_cluster_id={linode.lke_cluster_id} + maintenance={linode.maintenance} placement_group={linode.placement_group} region={linode.region} specs={linode.specs} @@ -54,7 +56,6 @@ export const ListView = (props: RenderLinodesProps) => { type={linode.type} updated={linode.updated} watchdog_enabled={linode.watchdog_enabled} - maintenance={linode.maintenance} /> ))} 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/LinodesLanding/utils.test.ts b/packages/manager/src/features/Linodes/LinodesLanding/utils.test.ts index bf1a98e828d..043894100ed 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/utils.test.ts +++ b/packages/manager/src/features/Linodes/LinodesLanding/utils.test.ts @@ -1,10 +1,11 @@ -import { parseMaintenanceStartTime, getVPCsFromLinodeConfigs } from './utils'; import { - configFactory, LinodeConfigInterfaceFactory, LinodeConfigInterfaceFactoryWithVPC, + configFactory, } from 'src/factories'; +import { getVPCsFromLinodeConfigs, parseMaintenanceStartTime } from './utils'; + describe('Linode Landing Utilites', () => { it('should return "Maintenance Window Unknown" for invalid dates', () => { expect(parseMaintenanceStartTime('inVALid DATE')).toBe( @@ -52,18 +53,18 @@ describe('Linode Landing Utilites', () => { ...configFactory.buildList(3), config, ]); - expect(vpcIds).toEqual([2, 3]); + expect(vpcIds).toEqual([3, 4]); }); it('returns unique vpc ids (no duplicates)', () => { const vpcInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: 2, + vpc_id: 3, }); const config = configFactory.build({ interfaces: [...vpcInterfaceList, vpcInterface], }); const vpcIds = getVPCsFromLinodeConfigs([config]); - expect(vpcIds).toEqual([2, 3]); + expect(vpcIds).toEqual([3, 4]); }); }); }); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/CautionNotice.tsx b/packages/manager/src/features/Linodes/MigrateLinode/CautionNotice.tsx index 6049904ce48..6f622014818 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/CautionNotice.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/CautionNotice.tsx @@ -10,7 +10,7 @@ import { API_MAX_PAGE_SIZE } from 'src/constants'; import { useLinodeVolumesQuery } from 'src/queries/volumes/volumes'; interface Props { - edgeRegionWarning?: string; + distributedRegionWarning?: string; error?: string; hasConfirmed: boolean; linodeId: number | undefined; @@ -21,7 +21,7 @@ interface Props { export const CautionNotice = React.memo((props: Props) => { const { - edgeRegionWarning, + distributedRegionWarning, error, hasConfirmed, linodeId, @@ -105,7 +105,7 @@ export const CautionNotice = React.memo((props: Props) => { to complete. {metadataWarning &&
  • {metadataWarning}
  • } - {edgeRegionWarning &&
  • {edgeRegionWarning}
  • } + {distributedRegionWarning &&
  • {distributedRegionWarning}
  • } {error && } void; helperText?: string; linodeType: Linode['type']; - selectedRegion: null | string; + selectedRegion: string | undefined; } export type MigratePricePanelType = 'current' | 'new'; @@ -144,7 +145,11 @@ export const ConfigureForm = React.memo((props: Props) => { [backupEnabled, currentLinodeType] ); - const linodeIsInEdgeRegion = currentActualRegion?.site_type === 'edge'; + const linodeIsInDistributedRegion = + currentActualRegion?.site_type === 'distributed' || + currentActualRegion?.site_type === 'edge'; + + const { isGeckoBetaEnabled } = useIsGeckoEnabled(); return ( @@ -157,12 +162,12 @@ export const ConfigureForm = React.memo((props: Props) => { {`${getRegionCountryGroup(currentActualRegion)}: ${ currentActualRegion?.label ?? currentRegion }`} - {linodeIsInEdgeRegion && ( + {isGeckoBetaEnabled && linodeIsInDistributedRegion && ( } + icon={} status="other" - sxTooltipIcon={sxEdgeIcon} - text="This region is an edge region." + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." /> )} @@ -175,7 +180,9 @@ 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]); @@ -152,14 +152,14 @@ export const MigrateLinode = React.memo((props: Props) => { : undefined; }, [flags.metadata, linode, regionsData, selectedRegion]); - const linodeIsInEdgeRegion = getIsEdgeRegion( + const linodeIsInDistributedRegion = getIsDistributedRegion( regionsData ?? [], linode?.region ?? '' ); - const edgeRegionWarning = - flags.gecko2?.enabled && linodeIsInEdgeRegion - ? 'Edge regions may only be migrated to other edge regions.' + const distributedRegionWarning = + flags.gecko2?.enabled && linodeIsInDistributedRegion + ? 'Distributed regions may only be migrated to other distributed regions.' : undefined; if (!linode) { @@ -247,7 +247,7 @@ export const MigrateLinode = React.memo((props: Props) => { notifications={notifications} /> */} } - interactive status="help" sxTooltipIcon={sxTooltipIcon} /> diff --git a/packages/manager/src/features/Linodes/index.tsx b/packages/manager/src/features/Linodes/index.tsx index 9d8c7a7312a..db48cf3d1e3 100644 --- a/packages/manager/src/features/Linodes/index.tsx +++ b/packages/manager/src/features/Linodes/index.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React, { useState } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; @@ -25,13 +25,16 @@ const LinodesCreatev2 = React.lazy(() => const LinodesRoutes = () => { const flags = useFlags(); + + // Hold this feature flag in state so that the user's Linode creation + // isn't interupted when the flag is toggled. + const [isLinodeCreateV2Enabled] = useState(flags.linodeCreateRefactor); + return ( }> diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/EditRouteDrawer.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/EditRouteDrawer.test.tsx index 15b80bbb206..21489f1fda8 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/EditRouteDrawer.test.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/EditRouteDrawer.test.tsx @@ -70,7 +70,7 @@ describe('EditRouteDrawer', () => { expect(routeLabelFiled).toHaveDisplayValue('route-label'); - userEvent.type(routeLabelFiled, 'rote-new-label'); + await userEvent.type(routeLabelFiled, 'rote-new-label'); await userEvent.click(screen.getByRole('button', { name: 'Save Changes' })); }); 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 { - it('should be submittable when form is filled out correctly', async () => { - const onClose = vi.fn(); - - const { getByLabelText, getByTestId } = renderWithTheme( - - ); - - const labelInput = getByLabelText('Label'); - const certInput = getByLabelText('TLS Certificate'); - const keyInput = getByLabelText('Private Key'); - - await userEvent.type(labelInput, 'my-cert-0'); - await userEvent.type(certInput, 'massive cert'); - await userEvent.type(keyInput, 'massive key'); - - await userEvent.click(getByTestId('submit')); - - await waitFor(() => expect(onClose).toBeCalled()); - }); + it( + 'should be submittable when form is filled out correctly', + async () => { + const onClose = vi.fn(); + + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + const labelInput = getByLabelText('Label'); + const certInput = getByLabelText('TLS Certificate'); + const keyInput = getByLabelText('Private Key'); + + await userEvent.type(labelInput, 'my-cert-0'); + await userEvent.type(certInput, 'massive cert'); + await userEvent.type(keyInput, 'massive key'); + + await userEvent.click(getByTestId('submit')); + + await waitFor(() => expect(onClose).toBeCalled()); + }, + { timeout: 10000 } + ); }); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/EditCertificateDrawer.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/EditCertificateDrawer.test.tsx index d727d4ad7cc..9f77e663998 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/EditCertificateDrawer.test.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/EditCertificateDrawer.test.tsx @@ -1,4 +1,4 @@ -import { act, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -82,10 +82,8 @@ describe('EditCertificateDrawer', () => { expect(labelInput).toHaveDisplayValue(mockCACertificate.label); expect(certInput).toHaveDisplayValue(mockCACertificate.certificate.trim()); - await act(async () => { - await userEvent.type(labelInput, 'my-updated-cert-0'); - await userEvent.click(getByTestId('submit')); - }); + await userEvent.type(labelInput, 'my-updated-cert-0'); + await userEvent.click(getByTestId('submit')); await waitFor(() => expect(onClose).toBeCalled()); }); @@ -105,13 +103,10 @@ describe('EditCertificateDrawer', () => { const certInput = getByLabelText('TLS Certificate'); const keyInput = getByLabelText('Private Key'); - await act(async () => { - await userEvent.type(labelInput, 'my-cert-0'); - await userEvent.type(certInput, 'massive cert'); - await userEvent.type(keyInput, 'massive key'); - - await userEvent.click(getByTestId('submit')); - }); + await userEvent.type(labelInput, 'my-cert-0'); + await userEvent.type(certInput, 'massive cert'); + await userEvent.type(keyInput, 'massive key'); + await userEvent.click(getByTestId('submit')); await waitFor(() => expect(onClose).toBeCalled()); }); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/EndpointHealth.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/EndpointHealth.test.tsx index b0274db2eff..b5aca9a2eba 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/EndpointHealth.test.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/EndpointHealth.test.tsx @@ -11,6 +11,7 @@ describe('EndpointHealth', () => { expect(getByText('0 up')).toBeVisible(); expect(getByText('0 down')).toBeVisible(); }); + it('renders endpoints that are up and down', () => { const { getByLabelText, getByText } = renderWithTheme( , @@ -20,12 +21,13 @@ describe('EndpointHealth', () => { const upStatusIcon = getByLabelText('Status is active'); const downStatusIcon = getByLabelText('Status is error'); - expect(upStatusIcon).toHaveStyle({ backgroundColor: '#17cf73' }); - expect(downStatusIcon).toHaveStyle({ backgroundColor: '#ca0813' }); + expect(upStatusIcon).toHaveStyle({ backgroundColor: 'rgba(23, 207, 115)' }); + expect(downStatusIcon).toHaveStyle({ backgroundColor: 'rgbs(202, 8, 19)' }); expect(getByText('18 up')).toBeVisible(); expect(getByText('6 down')).toBeVisible(); }); + it('should render gray when the "down" number is zero', () => { const { getByLabelText, getByText } = renderWithTheme( @@ -34,9 +36,10 @@ describe('EndpointHealth', () => { const statusIcon = getByLabelText('Status is inactive'); expect(statusIcon).toBeVisible(); - expect(statusIcon).toHaveStyle({ backgroundColor: '#dbdde1' }); + expect(statusIcon).toHaveStyle({ backgroundColor: 'rgba(219, 221, 225)' }); expect(getByText('0 down')).toBeVisible(); }); + it('should render gray when the "up" number is zero', () => { const { getByLabelText, getByText } = renderWithTheme( @@ -45,7 +48,7 @@ describe('EndpointHealth', () => { const statusIcon = getByLabelText('Status is inactive'); expect(statusIcon).toBeVisible(); - expect(statusIcon).toHaveStyle({ backgroundColor: '#dbdde1' }); + expect(statusIcon).toHaveStyle({ backgroundColor: 'rgba(219, 221, 225)' }); expect(getByText('0 up')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerConfigurations.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerConfigurations.tsx index 06db352e302..5c8923bb7bf 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerConfigurations.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerConfigurations.tsx @@ -52,7 +52,7 @@ export const LoadBalancerConfigurations = () => { ))} {hasNextPage && fetchNextPage()} />} - {isFetchingNextPage && } + {isFetchingNextPage && } @@ -355,25 +356,24 @@ export const LongviewSubscriptionRow = React.memo( return ( {plan} diff --git a/packages/manager/src/features/Longview/LongviewPackageRow.tsx b/packages/manager/src/features/Longview/LongviewPackageRow.tsx index 97923ff4f06..15d96efb7ee 100644 --- a/packages/manager/src/features/Longview/LongviewPackageRow.tsx +++ b/packages/manager/src/features/Longview/LongviewPackageRow.tsx @@ -16,7 +16,7 @@ export const LongviewPackageRow = (props: Props) => { const theme = useTheme(); return ( - + {lvPackage.name}
    {lvPackage.current}
    diff --git a/packages/manager/src/features/Longview/shared/TimeRangeSelect.tsx b/packages/manager/src/features/Longview/shared/TimeRangeSelect.tsx index 4a81055c25c..680b10a42a2 100644 --- a/packages/manager/src/features/Longview/shared/TimeRangeSelect.tsx +++ b/packages/manager/src/features/Longview/shared/TimeRangeSelect.tsx @@ -6,7 +6,10 @@ import Select, { BaseSelectProps, Item, } from 'src/components/EnhancedSelect/Select'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; interface Props extends Omit< diff --git a/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx b/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx index 78ecc931b69..a92b68ffe5e 100644 --- a/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx +++ b/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx @@ -17,7 +17,7 @@ export const ContactsRow = (props: ContactsRowProps) => { const { contact, openDialog, openDrawer } = props; return ( - + {contact.name} {contact.group} diff --git a/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx b/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx index 06ebbcef2c8..87bd3ebf6a9 100644 --- a/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx +++ b/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx @@ -19,7 +19,6 @@ export const CredentialRow = (props: CredentialRowProps) => { return ( { return ( { if (isLoading) { return ( - + ); } diff --git a/packages/manager/src/features/Managed/SSHAccess/SSHAccessRow.tsx b/packages/manager/src/features/Managed/SSHAccess/SSHAccessRow.tsx index 53a686c5dfc..1bfc78f00f7 100644 --- a/packages/manager/src/features/Managed/SSHAccess/SSHAccessRow.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/SSHAccessRow.tsx @@ -19,7 +19,6 @@ export const SSHAccessRow = (props: SSHAccessRowProps) => { return ( { onChange={tagsChange} tagError={hasErrorFor('tags')} /> + + + , + helperTextPosition: 'top', + }} + currentCapability="NodeBalancers" + disableClearable + errorText={hasErrorFor('region')} + onChange={(e, region) => regionChange(region?.id ?? '')} + regions={regions ?? []} + value={nodeBalancerFields.region ?? ''} + /> + + + + + - { setNodeBalancerFields((prev) => ({ diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index 0501f634aaa..620cd6e10f9 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -42,7 +42,6 @@ import { WithQueryClientProps, withQueryClient, } from 'src/containers/withQueryClient.container'; -import { queryKey } from 'src/queries/nodebalancers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; @@ -63,6 +62,7 @@ import type { NodeBalancerConfigNodeFields, } from '../types'; import type { Grants } from '@linode/api-v4'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; const StyledPortsSpan = styled('span', { label: 'StyledPortsSpan', @@ -411,12 +411,10 @@ class NodeBalancerConfigurations extends React.Component< // actually delete a real config deleteNodeBalancerConfig(Number(nodeBalancerId), config.id) .then((_) => { - 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/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx index 506e98e0c68..886043c757e 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx @@ -20,7 +20,7 @@ import { useNodeBalancerQuery, useNodebalancerUpdateMutation, } from 'src/queries/nodebalancers'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; import NodeBalancerConfigurations from './NodeBalancerConfigurations'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx index 9ecf185236c..74507f315f6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx @@ -4,7 +4,7 @@ import { Action } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { noPermissionTooltipText } from 'src/features/Firewalls/FirewallLanding/FirewallActionMenu'; import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; interface Props { firewallID: number; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx index b66cde88106..b16129be7c1 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx @@ -39,11 +39,7 @@ export const NodeBalancerFirewallsRow = (props: Props) => { const count = getCountOfRules(rules); return ( - + {label} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index b77813c7574..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'; +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 @@ -283,6 +285,6 @@ const Loading = () => ( minHeight: 300, }} > - +
    ); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx index e67572e3fa4..e9a20f8274a 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx @@ -30,7 +30,7 @@ export const NodeBalancerTableRow = (props: Props) => { 0; return ( - + {label} 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 90eef3a557e..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'; 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 f265cc41834..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,7 +60,7 @@ export const RenderProgressEvent = (props: Props) => { return ( <> - + { - const events = removeBlocklistedEvents( - givenEvents ?? useEventsInfiniteQuery().events - ); +export const useEventNotifications = (): NotificationItem[] => { + const { events: fetchedEvents } = useEventsInfiniteQuery(); + const relevantEvents = removeBlocklistedEvents(fetchedEvents); + const { isTaxIdEnabled } = useIsTaxIdEnabled(); const notificationContext = React.useContext(_notificationContext); - const _events = events.filter( - (thisEvent) => !unwantedEvents.includes(thisEvent.action) + // TODO: TaxId - This entire function can be removed when we cleanup tax id feature flags + const unwantedEventTypes = React.useMemo(() => { + const eventTypes = [...defaultUnwantedEvents]; + if (!isTaxIdEnabled) { + eventTypes.push('tax_id_invalid'); + } + return eventTypes; + }, [isTaxIdEnabled]); + + const filteredEvents = relevantEvents.filter( + (event) => !unwantedEventTypes.includes(event.action) ); - const [inProgress, completed] = partition(isInProgressEvent, _events); + const [inProgressEvents, completedEvents] = partition( + isInProgressEvent, + filteredEvents + ); - const allEvents = [ - ...inProgress.map((thisEvent) => - formatProgressEventForDisplay(thisEvent, notificationContext.closeMenu) + const allNotificationItems = [ + ...inProgressEvents.map((event) => + formatProgressEventForDisplay(event, notificationContext.closeMenu) ), - ...completed.map((thisEvent) => - formatEventForDisplay(thisEvent, notificationContext.closeMenu) + ...completedEvents.map((event) => + formatEventForDisplay(event, notificationContext.closeMenu) ), ]; - return allEvents.filter((thisAction) => - Boolean(thisAction.body) - ) as NotificationItem[]; + return allNotificationItems.filter((notification) => + Boolean(notification.body) + ); }; const formatEventForDisplay = ( diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx index 4e5f433385b..d67fdbb5807 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx @@ -17,7 +17,7 @@ import { complianceUpdateContext } from 'src/context/complianceUpdateContext'; import { reportException } from 'src/exceptionReporting'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx index 2dc9b40673d..b32ebfbba2a 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx @@ -42,6 +42,7 @@ export interface NotificationItem { countInTotal: boolean; eventId: number; id: string; + showProgress?: boolean; } interface NotificationSectionProps { @@ -50,6 +51,7 @@ interface NotificationSectionProps { emptyMessage?: string; header: string; loading?: boolean; + onCloseNotificationCenter?: () => 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'} @@ -149,7 +156,7 @@ const ContentBody = React.memo((props: BodyProps) => { if (loading) { return ( - + ); } @@ -161,6 +168,7 @@ const ContentBody = React.memo((props: BodyProps) => { <> {_content.map((thisItem) => ( @@ -235,7 +243,6 @@ const StyledNotificationItem = styled(Box, { shouldForwardProp: omittedProps(['header']), })<{ header: string }>(({ theme, ...props }) => ({ '& p': { - color: theme.textColors.headlineStatic, lineHeight: '1.25rem', }, display: 'flex', 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 4b156face0c..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; @@ -28,17 +29,17 @@ export const HostNameTableCell = ({ const { regions } = storageKeyData; - if (!regionsLookup || !regionsData || !regions) { - return ; + 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 && ( { !createMode && objectStorageKey ? objectStorageKey.label : ''; const initialRegions = - !createMode && objectStorageKey - ? objectStorageKey.regions?.map((region) => region.id) + !createMode && objectStorageKey?.regions + ? objectStorageKey.regions.map((region) => region.id) : []; const initialValues: FormState = { @@ -184,7 +184,6 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { ), } : { ...values, bucket_access: null }; - const updatePayload = generateUpdatePayload(values, initialValues); if (mode !== 'creating') { @@ -203,7 +202,7 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { const isSaveDisabled = isRestrictedUser || (mode !== 'creating' && - objectStorageKey && + objectStorageKey?.regions && !hasLabelOrRegionsChanged(formik.values, objectStorageKey)) || (mode === 'creating' && limitedAccessChecked && diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx index d44407fdbbb..a9c7a48ec5b 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx @@ -29,6 +29,8 @@ import { queryKey, updateBucket, useObjectBucketDetailsInfiniteQuery, + useObjectStorageBuckets, + useObjectStorageClusters, } from 'src/queries/objectStorage'; import { sendDownloadObjectEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; @@ -53,6 +55,10 @@ import { import { CreateFolderDrawer } from './CreateFolderDrawer'; import { ObjectDetailsDrawer } from './ObjectDetailsDrawer'; import ObjectTableContent from './ObjectTableContent'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account/account'; +import { useRegionsQuery } from 'src/queries/regions/regions'; interface MatchParams { bucketName: string; @@ -60,6 +66,10 @@ interface MatchParams { } export const BucketDetail = () => { + /** + * @note If `Object Storage Access Key Regions` is enabled, clusterId will actually contain + * the bucket's region id + */ const match = useRouteMatch( '/object-storage/buckets/:clusterId/:bucketName' ); @@ -70,6 +80,36 @@ export const BucketDetail = () => { const clusterId = match?.params.clusterId || ''; const prefix = getQueryParamFromQueryString(location.search, 'prefix'); const queryClient = useQueryClient(); + + const flags = useFlags(); + const { data: account } = useAccount(); + + const isObjMultiClusterEnabled = isFeatureEnabled( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); + + const { data: regions } = useRegionsQuery(); + + const regionsSupportingObjectStorage = regions?.filter((region) => + region.capabilities.includes('Object Storage') + ); + + const { data: clusters } = useObjectStorageClusters(); + const { data: buckets } = useObjectStorageBuckets({ + clusters, + isObjMultiClusterEnabled, + regions: regionsSupportingObjectStorage, + }); + + const bucket = buckets?.buckets.find((bucket) => { + if (isObjMultiClusterEnabled) { + return bucket.label === bucketName && bucket.region === clusterId; + } + return bucket.label === bucketName && bucket.cluster === clusterId; + }); + const { data, error, @@ -428,9 +468,8 @@ export const BucketDetail = () => { { const { displayName, folderName, handleClickDelete } = props; return ( - + diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx index 3301589c9f5..cd9f2f31979 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx @@ -11,7 +11,7 @@ import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { truncateMiddle } from 'src/utilities/truncate'; import { readableBytes } from 'src/utilities/unitConversions'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx index ea11d2f4370..96d32373fd1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx @@ -36,7 +36,7 @@ export const ObjectTableRow = (props: Props) => { } = props; return ( - + diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 4bb73143f48..70fb01f55b8 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -15,7 +15,7 @@ import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useObjectStorageClusters } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx index ded2bc7bfaf..cc46045d6c4 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx @@ -73,7 +73,7 @@ describe('ObjectStorageLanding', () => { // Mock Buckets server.use( http.get( - '*/object-storage/buckets/cluster-0', + '*/object-storage/buckets/cluster-1', () => { return HttpResponse.json([{ reason: 'Cluster offline!' }], { status: 500, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx index af8178d9819..f3e6c7b16f7 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx @@ -26,7 +26,7 @@ import { useObjectStorageBuckets, useObjectStorageClusters, } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx index 93309cfba17..40d12acbfab 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx @@ -9,7 +9,7 @@ interface Props { onBlur: (e: any) => 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/BucketTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx index a8cf7a76563..48a52341fd7 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx @@ -63,7 +63,7 @@ export const BucketTableRow = (props: BucketTableRowProps) => { const regionsLookup = regions && getRegionsByRegionId(regions); return ( - + 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 3abd6957b22..71fb9116ef1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -15,17 +15,20 @@ import { useMutateAccountAgreements, } from 'src/queries/account/agreements'; import { useAccountSettings } from 'src/queries/account/settings'; +import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useCreateBucketMutation, useObjectStorageBuckets, useObjectStorageClusters, + useObjectStorageTypesQuery, } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; 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 ClusterSelect from './ClusterSelect'; @@ -74,6 +77,22 @@ export const CreateBucketDrawer = (props: Props) => { : undefined, }); + 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, @@ -199,9 +218,15 @@ export const CreateBucketDrawer = (props: Props) => { 'data-testid': 'create-bucket-button', disabled: !formik.values.cluster || - (showGDPRCheckbox && !hasSignedAgreement), + (showGDPRCheckbox && !hasSignedAgreement) || + isErrorTypes, label: 'Create Bucket', - loading: isLoading, + loading: + isLoading || Boolean(clusterRegion?.[0]?.id && 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/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index 748f532c4ed..ab2f8f2a12e 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -23,7 +23,7 @@ import { useDeleteBucketWithRegionMutation, useObjectStorageBuckets, } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 5143f9bff11..4340b64e5f6 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -13,16 +13,19 @@ 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'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; 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 efc2669ea5a..c7d0c2eba23 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx @@ -1,8 +1,13 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; -import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing'; +import { + distributedNetworkTransferPriceTypeFactory, + networkTransferPriceTypeFactory, + objectStorageOverageTypeFactory, + objectStorageTypeFactory, +} from 'src/factories'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { @@ -11,25 +16,66 @@ import { OveragePricing, } from './OveragePricing'; -describe('OveragePricing', () => { +const mockObjectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), +]; + +const mockNetworkTransferTypes = [ + distributedNetworkTransferPriceTypeFactory.build(), + networkTransferPriceTypeFactory.build(), +]; + +const queryMocks = vi.hoisted(() => ({ + useNetworkTransferPricesQuery: vi.fn().mockReturnValue({}), + useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/objectStorage', async () => { + const actual = await vi.importActual('src/queries/objectStorage'); + return { + ...actual, + useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery, + }; +}); + +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', () => { const { getByText } = renderWithTheme( ); - getByText(`$${OBJ_STORAGE_PRICE.storage_overage} per GB`, { exact: false }); - getByText(`$${OBJ_STORAGE_PRICE.transfer_overage} per GB`, { + getByText(`$${mockObjectStorageTypes[1].price.hourly?.toFixed(2)} per GB`, { + exact: false, + }); + getByText(`$${mockNetworkTransferTypes[1].price.hourly} per GB`, { exact: false, }); }); it('Renders DC-specific overage pricing for a region with price increases', () => { const { getByText } = renderWithTheme(); + getByText(`$${mockObjectStorageTypes[1].region_prices[1].hourly} per GB`, { + exact: false, + }); getByText( - `$${objectStoragePriceIncreaseMap['br-gru'].storage_overage} per GB`, - { exact: false } - ); - getByText( - `$${objectStoragePriceIncreaseMap['br-gru'].transfer_overage} per GB`, + `$${mockNetworkTransferTypes[1].region_prices[1].hourly} per GB`, { exact: false } ); }); @@ -59,4 +105,40 @@ describe('OveragePricing', () => { expect(tooltip).toBeInTheDocument(); expect(getByText(GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT)).toBeVisible(); }); + + it('Renders a loading state while prices are loading', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + isLoading: true, + }); + + const { getByRole } = renderWithTheme( + + ); + + expect(getByRole('progressbar')).toBeVisible(); + }); + + it('Renders placeholder unknown pricing when there is an error', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + isError: true, + }); + + const { getAllByText } = renderWithTheme( + + ); + + expect(getAllByText(`$${UNKNOWN_PRICE} per GB`)).toHaveLength(1); + }); + + it('Renders placeholder unknown pricing when prices are undefined', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: undefined, + }); + + const { getAllByText } = renderWithTheme( + + ); + + expect(getAllByText(`$${UNKNOWN_PRICE} per GB`)).toHaveLength(1); + }); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx index 25279f3efc9..fa139dac2a0 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx @@ -1,11 +1,16 @@ -import { Region } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import React from 'react'; +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 { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; -import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing'; +import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; +import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; +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']; @@ -18,30 +23,69 @@ export const GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT = export const OveragePricing = (props: Props) => { const { regionId } = props; - const isDcSpecificPricingRegion = objectStoragePriceIncreaseMap.hasOwnProperty( - regionId + + const { + data: objTypes, + isError: isErrorObjTypes, + isLoading: isLoadingObjTypes, + } = useObjectStorageTypesQuery(); + const { + data: transferTypes, + isError: isErrorTransferTypes, + isLoading: isLoadingTransferTypes, + } = useNetworkTransferPricesQuery(); + + const storageOverageType = objTypes?.find( + (type) => type.id === 'objectstorage-overage' + ); + const transferOverageType = transferTypes?.find( + (type) => type.id === 'network_transfer' ); - return ( + const storageOveragePrice = getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId, + type: storageOverageType, + }); + const transferOveragePrice = getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId, + type: transferOverageType, + }); + + const isDcSpecificPricingRegion = Boolean( + transferOverageType?.region_prices.find( + (region_price) => region_price.id === regionId + ) + ); + + return isLoadingObjTypes || isLoadingTransferTypes ? ( + + + + ) : ( <> For this region, additional storage costs{' '} $ - {isDcSpecificPricingRegion - ? objectStoragePriceIncreaseMap[regionId].storage_overage - : OBJ_STORAGE_PRICE.storage_overage}{' '} + {storageOveragePrice && !isErrorObjTypes + ? parseFloat(storageOveragePrice) + : UNKNOWN_PRICE}{' '} per GB . + 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{' '} @@ -50,6 +94,7 @@ export const OveragePricing = (props: Props) => { the{' '} @@ -58,6 +103,7 @@ export const OveragePricing = (props: Props) => { your{' '} diff --git a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx index e36ba887af3..834cd38f4ec 100644 --- a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx +++ b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx @@ -1,7 +1,11 @@ import { fireEvent, render } from '@testing-library/react'; import * as React from 'react'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; +import { + objectStorageOverageTypeFactory, + objectStorageTypeFactory, +} from 'src/factories'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { wrapWithTheme } from 'src/utilities/testHelpers'; import { @@ -23,7 +27,29 @@ const props: EnableObjectStorageProps = { open: true, }; +const queryMocks = vi.hoisted(() => ({ + useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/objectStorage', async () => { + const actual = await vi.importActual('src/queries/objectStorage'); + return { + ...actual, + useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery, + }; +}); + describe('EnableObjectStorageModal', () => { + beforeAll(() => { + const mockObjectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), + ]; + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: mockObjectStorageTypes, + }); + }); + it('includes a header', () => { const { getAllByText } = render( wrapWithTheme() @@ -37,7 +63,7 @@ describe('EnableObjectStorageModal', () => { ) ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); @@ -51,7 +77,7 @@ describe('EnableObjectStorageModal', () => { /> ) ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); @@ -60,11 +86,27 @@ describe('EnableObjectStorageModal', () => { const { getByText } = render( wrapWithTheme() ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); + it('displays placeholder unknown pricing and disables the primary action button if pricing is not available', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: undefined, + isError: true, + }); + + const { getByTestId, getByText } = render( + wrapWithTheme() + ); + + const primaryActionButton = getByTestId('enable-obj'); + + expect(getByText(`${UNKNOWN_PRICE}/month`, { exact: false })).toBeVisible(); + expect(primaryActionButton).toBeDisabled(); + }); + it('includes a link to linode.com/pricing', () => { const { getByText } = render( wrapWithTheme() diff --git a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx index 8e990d54e31..1226e9d6ef3 100644 --- a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx +++ b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx @@ -1,4 +1,3 @@ -import { Region } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -7,7 +6,14 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; +import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; +import { + PRICES_RELOAD_ERROR_NOTICE_TEXT, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; + +import type { Region } from '@linode/api-v4'; export const OBJ_STORAGE_STORAGE_AMT = '250 GB'; export const OBJ_STORAGE_NETWORK_TRANSFER_AMT = '1 TB'; @@ -21,6 +27,21 @@ export interface EnableObjectStorageProps { export const EnableObjectStorageModal = React.memo( (props: EnableObjectStorageProps) => { const { handleSubmit, onClose, open, regionId } = props; + const { data: types, isError, isLoading } = useObjectStorageTypesQuery(); + + const isInvalidPrice = Boolean(regionId) && (!types || isError); + + const objectStorageType = types?.find( + (type) => type.id === 'objectstorage' + ); + + const price = regionId + ? getDCSpecificPriceByType({ + decimalPrecision: 0, + regionId, + type: objectStorageType, + }) + : objectStorageType?.price.monthly; return ( { onClose(); handleSubmit(); }, + tooltipText: + !isLoading && isInvalidPrice + ? PRICES_RELOAD_ERROR_NOTICE_TEXT + : '', }} secondaryButtonProps={{ 'data-testid': 'cancel', @@ -51,7 +78,7 @@ export const EnableObjectStorageModal = React.memo( Object Storage costs a flat rate of{' '} - ${OBJ_STORAGE_PRICE.monthly}/month, and includes{' '} + ${price ?? UNKNOWN_PRICE}/month, and includes{' '} {OBJ_STORAGE_STORAGE_AMT} of storage. When you enable Object Storage,{' '} {OBJ_STORAGE_NETWORK_TRANSFER_AMT} of outbound data transfer will be added to your global network transfer pool. diff --git a/packages/manager/src/features/ObjectStorage/utilities.ts b/packages/manager/src/features/ObjectStorage/utilities.ts index d18bf4c553a..7cbfde45994 100644 --- a/packages/manager/src/features/ObjectStorage/utilities.ts +++ b/packages/manager/src/features/ObjectStorage/utilities.ts @@ -1,24 +1,15 @@ import { AccountSettings } from '@linode/api-v4/lib/account'; import { ACLType, - ObjectStorageClusterID, ObjectStorageObject, } from '@linode/api-v4/lib/object-storage'; import { FormikProps } from 'formik'; import { Item } from 'src/components/EnhancedSelect/Select'; -import { OBJECT_STORAGE_DELIMITER, OBJECT_STORAGE_ROOT } from 'src/constants'; +import { OBJECT_STORAGE_DELIMITER } from 'src/constants'; -export const generateObjectUrl = ( - clusterId: ObjectStorageClusterID, - bucketName: string, - objectName: string -) => { - const path = `${bucketName}.${clusterId}.${OBJECT_STORAGE_ROOT}/${objectName}`; - return { - absolute: 'https://' + path, - path, - }; +export const generateObjectUrl = (hostname: string, objectName: string) => { + return `https://${hostname}/${objectName}`; }; // Objects ending with a / and having a size of 0 are often used to represent diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index f5cb1bea442..ff23648aa79 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -1,2453 +1,8 @@ -import { oneClickAppFactory } from 'src/factories/stackscripts'; +import { oneClickApps as newOneClickApps } from './oneClickAppsv2'; import type { OCA } from './types'; -export const oneClickApps: OCA[] = [ - { - alt_description: 'Free open source control panel with a mobile app.', - alt_name: 'Free infrastructure control panel', - categories: ['Control Panels'], - colors: { - end: 'a3a3a3', - start: '20a53a', - }, - description: `Feature-rich alternative control panel for users who need critical control panel functionality but donā€™t need to pay for more niche premium features. aaPanel is open source and consistently maintained with weekly updates.`, - logo_url: 'aapanel.svg', - name: 'aaPanel', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/aapanel/', - title: 'Deploy aaPanel through the Linode Marketplace', - }, - ], - summary: - 'Popular open source free control panel with robust features and a mobile app.', - website: 'https://www.aapanel.com/reference.html', - }, - { - alt_description: - 'Free accounting software. QuickBooks alternative for freelancers and small businesses.', - alt_name: 'Open source accounting software', - categories: ['Productivity'], - colors: { - end: '55588b', - start: '6ea152', - }, - description: `Akaunting is a universal accounting software that helps small businesses run more efficiently. Track expenses, generate reports, manage your books, and get the other essential features to run your business from a single dashboard.`, - logo_url: 'akaunting.svg', - name: 'Akaunting', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/akaunting/', - title: 'Deploy Akaunting through the Linode Marketplace', - }, - ], - summary: - 'Free and open source accounting software you can use in your browser.', - website: 'https://akaunting.com', - }, - { - alt_description: - 'Free high-performance media streaming, including livestreaming.', - alt_name: 'Free media streaming app', - categories: ['Media and Entertainment'], - colors: { - end: '0a0a0a', - start: 'df0718', - }, - description: `Self-hosted free version to optimize and record video streaming for webinars, gaming, and more.`, - logo_url: 'antmediaserver.svg', - name: 'Ant Media Server: Community Edition', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/antmediaserver/', - title: 'Deploy Ant Media Server through the Linode Marketplace', - }, - ], - summary: 'A reliable, flexible and scalable video streaming solution.', - website: 'https://antmedia.io/', - }, - { - alt_description: - 'Low latency live streaming including WebRTC streaming, CMAF, and HLS.', - alt_name: 'Media streaming app', - categories: ['Media and Entertainment'], - colors: { - end: '0a0a0a', - start: 'df0718', - }, - description: `Ant Media Server makes it easy to set up a video streaming platform with ultra low latency. The Enterprise edition supports WebRTC Live Streaming in addition to CMAF and HLS streaming. Set up live restreaming to social media platforms to reach more viewers.`, - logo_url: 'antmediaserver.svg', - name: 'Ant Media Server: Enterprise Edition', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/antmediaenterpriseserver/', - title: - 'Deploy Ant Media Enterprise Edition through the Linode Marketplace', - }, - ], - summary: 'Highly scalable and feature-rich live video streaming platform.', - website: 'https://antmedia.io/', - }, - { - alt_description: - 'Open-source workflow management platform for data engineering pipelines.', - alt_name: 'Workflow management platform', - categories: ['Development'], - colors: { - end: 'E43921', - start: '00C7D4', - }, - description: `Programmatically author, schedule, and monitor workflows with a Python-based tool. Airflow provides full insight into the status and logs of your tasks, all in a modern web application.`, - logo_url: 'apacheairflow.svg', - name: 'Apache Airflow', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-airflow/', - title: 'Deploy Apache Airflow through the Linode Marketplace', - }, - ], - summary: - 'Open source workflow management platform for data engineering pipelines.', - website: 'https://airflow.apache.org/', - }, - { - alt_description: - 'A self-hosted backend-as-a-service platform that provides developers with all the core APIs required to build any application.', - alt_name: 'Self-hosted backend-as-a-service', - categories: ['Development'], - colors: { - end: 'f02e65', - start: 'f02e65', - }, - description: `A self-hosted Firebase alternative for web, mobile & Flutter developers.`, - logo_url: 'appwrite.svg', - name: 'Appwrite', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/appwrite/', - title: 'Deploy Appwrite through the Linode Marketplace', - }, - ], - summary: - 'Appwrite is an open-source, cross-platform and technology-agnostic alternative to Firebase, providing all the core APIs necessary for web, mobile and Flutter development.', - website: 'https://appwrite.io/', - }, - { - alt_description: 'Free internet radio station management and hosting.', - alt_name: 'Online radio station builder', - categories: ['Media and Entertainment'], - colors: { - end: '0b1b64', - start: '1f8df5', - }, - description: `All aspects of running a radio station in one web interface so you can start your own station. Manage media, create playlists, and interact with listeners on one free platform.`, - logo_url: 'azuracast.svg', - name: 'Azuracast', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/azuracast/', - title: 'Deploy AzuraCast through the Linode Marketplace', - }, - ], - summary: 'Open source, self-hosted web radio tool.', - website: 'https://www.azuracast.com/', - }, - { - alt_description: 'Free penetration testing tool using client-side vectors.', - alt_name: 'Penetration testing tool for security research', - categories: ['Security'], - colors: { - end: '000f21', - start: '4a80a9', - }, - description: `Test the security posture of a client or application using client-side vectors, all powered by a simple API. This project is developed solely for lawful research and penetration testing.`, - logo_url: 'beef.svg', - name: 'BeEF', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/beef/', - title: 'Deploy BeEF through the Linode Marketplace', - }, - ], - summary: - 'Browser Exploitation Framework (BeEF) is an open source web browser penetration tool.', - website: 'https://github.com/beefproject/beef', - }, - { - alt_description: - 'Application builder for forms, portals, admin panels, and more.', - alt_name: 'Low-code application builder', - categories: ['Development'], - colors: { - end: '000000', - start: '9981f5', - }, - description: - 'Budibase is a modern, open source low-code platform for building modern business applications in minutes. Build, design and automate business apps, such as: admin panels, forms, internal tools, client portals and more. Before Budibase, it could take developers weeks to build simple CRUD apps; with Budibase, building CRUD apps takes minutes. When self-hosting please follow best practices for securing, updating and backing up your server.', - logo_url: 'budibase.svg', - name: 'Budibase', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/budibase/', - title: 'Deploy Budibase through the Linode Marketplace', - }, - ], - summary: 'Low-code platform for building modern business applications.', - website: 'https://docs.budibase.com/docs', - }, - { - alt_description: - 'Image hosting and sharing alternative to Google Photos and Flickr.', - alt_name: 'Photo library and image library', - categories: ['Media and Entertainment'], - colors: { - end: '8e44ad', - start: '23a8e0', - }, - description: `Chevereto is a full-featured image sharing solution that acts as an alternative to services like Google Photos or Flickr. Optimize image hosting by using external cloud storage (like Linodeā€™s S3-compatible Object Storage) and connect to Chevereto using API keys.`, - logo_url: 'chevereto.svg', - name: 'Chevereto', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/chevereto/', - title: 'Deploy Chevereto through the Linode Marketplace', - }, - ], - summary: - 'Self-host your own open source image library to easily upload, collaborate, and share images on your terms.', - website: 'https://v3-docs.chevereto.com/', - }, - { - alt_description: - 'Host multiple apps on one server and control panel, including WordPress, GitLab, and Nextcloud.', - alt_name: 'Cloud app and website control panel', - categories: ['Website'], - colors: { - end: '212121', - start: '03a9f4', - }, - description: `Turnkey solution for running apps like WordPress, Rocket.Chat, NextCloud, GitLab, and OpenVPN.`, - logo_url: 'cloudron.svg', - name: 'Cloudron', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/cloudron/', - title: 'Deploy Cloudron through the Linode Marketplace', - }, - ], - summary: - 'End-to-end deployment and automatic updates for a range of essential applications.', - website: 'https://docs.cloudron.io', - }, - { - alt_description: - 'SQL and NoSQL database interface and monitoring for MySQL, PostgreSQL, and more.', - alt_name: 'Database monitoring', - categories: ['Databases'], - colors: { - end: '3f434c', - start: '0589de', - }, - description: `All-in-one interface for scripting and monitoring databases, including MySQL, MariaDB, Percona, PostgreSQL, Galera Cluster and more. Easily deploy database instances, manage with an included CLI, and automate performance monitoring.`, - logo_url: 'clustercontrol.svg', - name: 'ClusterControl', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/clustercontrol/', - title: 'Deploy ClusterControl through the Linode Marketplace', - }, - ], - summary: - 'All-in-one database deployment, management, and monitoring system.', - website: 'https://docs.severalnines.com/docs/clustercontrol/', - }, - { - alt_description: - 'Linux-based web hosting control panel for managing websites, servers, databases, and more.', - alt_name: 'Web server automation and control panel', - categories: ['Control Panels'], - colors: { - end: '141d25', - start: 'ff6c2c', - }, - description: `The cPanel & WHM® Marketplace App streamlines publishing and managing a website on your Linode. cPanel & WHM is a Linux® based web hosting control panel and platform that helps you create and manage websites, servers, databases and more with a suite of hosting automation and optimization tools.`, - logo_url: 'cpanel.svg', - name: 'cPanel', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/cpanel/', - title: 'Deploy cPanel through the Linode Marketplace', - }, - ], - summary: - 'The leading hosting automation platform that has simplified site and server management for 20 years.', - website: 'https://www.cpanel.net/', - }, - { - alt_description: - 'Web hosting control panel for managing websites, including WordPress.', - alt_name: 'Web hosting control panel', - categories: ['Control Panels'], - colors: { - end: '33cccc', - start: '3d596d', - }, - description: `Reduce setup time required to host websites and applications, including popular tools like OpenLiteSpeed WordPress.`, - logo_url: 'cyberpanel.svg', - name: 'CyberPanel', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/cyberpanel/', - title: 'Deploy CyberPanel through the Linode Marketplace', - }, - ], - summary: 'Next-generation hosting control panel by OpenLiteSpeed.', - website: 'https://docs.litespeedtech.com/cloud/images/cyberpanel/', - }, - { - alt_description: 'Open source community forum alternative to Reddit.', - alt_name: 'Chat forum', - categories: ['Media and Entertainment'], - colors: { - end: 'eae692', - start: '13b3ed', - }, - description: `Launch a sleek forum with robust integrations to popular tools like Slack and WordPress to start more conversations.`, - logo_url: 'discourse.svg', - name: 'Discourse', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/discourse/', - title: 'Deploy Discourse through the Linode Marketplace', - }, - ], - summary: - 'Open source community and discussion forum for customers, teams, fans, and more.', - website: 'https://www.discourse.org/', - }, - { - alt_description: 'Fast Python development with best practices.', - alt_name: 'Python framework', - categories: ['Development'], - colors: { - end: '136149', - start: '0a2e1f', - }, - description: `Django is a web development framework for the Python programming language. It enables rapid development, while favoring pragmatic and clean design.`, - logo_url: 'django.svg', - name: 'Django', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/django/', - title: 'Deploy Django through the Linode Marketplace', - }, - ], - summary: `A framework for simplifying the process of building your web applications more quickly and with less code.`, - website: 'https://www.djangoproject.com/', - }, - { - alt_description: - 'Popular container tool to build cloud-native applications.', - alt_name: 'Container builder', - categories: ['Development'], - colors: { - end: '1e65c9', - start: '2496ed', - }, - description: `Docker is a tool that enables you to create, deploy, and manage lightweight, stand-alone packages that contain everything needed to run an application (code, libraries, runtime, system settings, and dependencies).`, - logo_url: 'docker.svg', - name: 'Docker', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/docker/', - title: 'Deploy Docker through the Linode Marketplace', - }, - ], - summary: `Securely build, share and run modern applications anywhere.`, - website: 'https://www.docker.com/', - }, - { - alt_description: 'Secure website CMS.', - alt_name: 'CMS: content management system', - categories: ['Website'], - colors: { - end: '1b64a5', - start: '0678be', - }, - description: `Drupal is a content management system (CMS) designed for building custom websites for personal and business use. Built for high performance and scalability, Drupal provides the necessary tools to create rich, interactive community websites with forums, user blogs, and private messaging. Drupal also has support for personal publishing projects and can power podcasts, blogs, and knowledge-based systems, all within a single, unified platform.`, - logo_url: 'drupal.svg', - name: 'Drupal', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/drupal/', - title: 'Deploy Drupal through the Linode Marketplace', - }, - ], - summary: `Powerful content management system built on PHP and supported by a database engine.`, - website: 'https://www.drupal.org/', - }, - { - alt_description: - 'Flexible control panel to simplify SSL certificates and push code from GitHub.', - alt_name: 'Server control panel', - categories: ['Control Panels'], - colors: { - end: '000000', - start: '059669', - }, - description: `Deploy Node.js, Ruby, Python, PHP, Go, and Java applications via an intuitive control panel. Easily set up free SSL certificates, run commands with an in-browser terminal, and push your code from Github to accelerate development.`, - logo_url: 'easypanel.svg', - name: 'Easypanel', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/easypanel/', - title: 'Deploy Easypanel through the Linode Marketplace', - }, - ], - summary: 'Modern server control panel based on Docker.', - website: 'https://easypanel.io/', - }, - { - alt_description: 'File storage alternative to Dropbox and Google Drive.', - alt_name: 'File sharing', - categories: ['Productivity'], - colors: { - end: '0168ad', - start: '3e8cc1', - }, - description: `File synchronization across multiple usersā€™ computers and other devices to keep everyone working without interruption.`, - logo_url: 'filecloud.svg', - name: 'FileCloud', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/filecloud/', - title: 'Deploy FileCloud through the Linode Marketplace', - }, - ], - summary: 'Enterprise file sharing to manage and sync from any device.', - website: 'https://www.getfilecloud.com', - }, - { - alt_description: 'Fast Python development with best practices.', - alt_name: 'Python framework', - categories: ['Development'], - colors: { - end: '1e2122', - start: '363b3d', - }, - description: `Flask is a lightweight WSGI web application framework written in Python. It is designed to make getting started quick and easy, with the ability to scale up to complex applications.`, - logo_url: 'flask.svg', - name: 'Flask', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/flask/', - title: 'Deploy Flask through the Linode Marketplace', - }, - ], - summary: `A quick light-weight web framework for Python that includes several utilities and libraries you can use to create a web application.`, - website: 'https://www.palletsprojects.com/p/flask/', - }, - { - alt_description: 'Free alternative to Trello and Asana.', - alt_name: 'Kanban board project management tool', - categories: ['Productivity'], - colors: { - end: '1d52ad', - start: '2997f8', - }, - description: `Create boards, assign tasks, and keep projects moving with a free and robust alternative to tools like Trello and Asana.`, - logo_url: 'focalboard.svg', - name: 'Focalboard', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/focalboard/', - title: 'Deploy Focalboard through the Linode Marketplace', - }, - ], - summary: 'Free open source project management tool.', - website: 'https://www.focalboard.com/', - }, - { - alt_description: 'SQL database.', - alt_name: 'SQL database', - categories: ['Databases'], - colors: { - end: '000000', - start: 'EC7704', - }, - description: `Galera provides a performant multi-master/active-active database solution with synchronous replication, to achieve high availability.`, - logo_url: 'galeramarketplaceocc.svg', - name: 'Galera Cluster', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/galera-cluster/', - title: 'Deploy Galera Cluster through the Linode Marketplace', - }, - ], - summary: `Multi-master MariaDB database cluster.`, - website: 'https://galeracluster.com/', - }, - { - alt_description: 'Open source, self-hosted Git management tool.', - alt_name: 'Git repository hosting', - categories: ['Development'], - colors: { - end: '34495e', - start: '609926', - }, - description: `Self-hosted Git service built and maintained by a large developer community.`, - logo_url: 'gitea.svg', - name: 'Gitea', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/gitea/', - title: 'Deploy Gitea through the Linode Marketplace', - }, - ], - summary: 'Git with a cup of tea - A painless self-hosted Git service.', - website: 'https://gitea.io/', - }, - { - alt_description: 'Popular Git management tool.', - alt_name: 'Git repository hosting', - categories: ['Development'], - colors: { - end: '21153e', - start: '48357d', - }, - description: `GitLab is a complete solution for all aspects of your software development. At its core, GitLab serves as your centralized Git repository. GitLab also features built-in tools that represent every task in your development workflow, from planning to testing to releasing. - Self-hosting your software development with GitLab offers total control of your codebase. At the same time, its familiar interface will ease collaboration for you and your team. GitLab is the most popular self-hosted Git repository, so you'll benefit from a robust set of integrated tools and an active community.`, - logo_url: 'gitlab.svg', - name: 'GitLab', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/gitlab/', - title: 'Deploy GitLab through the Linode Marketplace', - }, - ], - summary: - 'More than a self-hosted Git repository: use GitLab to manage all the stages of your DevOps life cycle.', - website: 'https://about.gitlab.com/', - }, - { - alt_description: - 'No-code platform for Kubernetes developers and operators.', - alt_name: 'Go Paddle', - categories: ['Development'], - colors: { - end: '252930', - start: '3a5bfd', - }, - description: `Provision multicloud clusters, containerize applications, and build DevOps pipelines. Gopaddleā€™s suite of templates and integrations helps eliminate manual errors and automate Kubernetes application releases.`, - logo_url: 'gopaddle.svg', - name: 'Gopaddle', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/gopaddle/', - title: 'Deploy Gopaddle through the Linode Marketplace', - }, - ], - summary: - 'Simple low-code platform for Kubernetes developers and operators.', - website: 'https://gopaddle.io/', - }, - { - alt_description: 'Open source, highly available, shared filesystem.', - alt_name: 'GlusterFS', - categories: ['Development'], - colors: { - end: '784900', - start: 'D4AC5C', - }, - description: - 'GlusterFS is an open source, software scalable network filesystem. This app deploys three GlusterFS servers and three GlusterFS clients.', - logo_url: 'glusterfs.svg', - name: 'GlusterFS Cluster', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/glusterfs-cluster/', - title: 'Deploy GlusterFS Cluster through the Linode Marketplace', - }, - ], - summary: 'Open source network filesystem.', - website: 'https://www.gluster.org/', - }, - { - alt_description: 'Markdown-based website CMS.', - alt_name: 'CMS: content management system', - categories: ['Website'], - colors: { - end: 'b987cf', - start: '1a0629', - }, - description: `Build websites on a CMS that prioritizes speed and simplicity over customization and integration support. Create your content in Markdown and take advantage of powerful taxonomy to customize relationships between pages and other content.`, - logo_url: 'grav.svg', - name: 'Grav', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/grav/', - title: 'Deploy Grav through the Linode Marketplace', - }, - ], - summary: 'Modern and open source flat-file content management system.', - website: 'https://getgrav.org/', - }, - { - alt_description: 'Desktop cloud hosting.', - alt_name: 'Virtual desktop', - categories: ['Development'], - colors: { - end: '213121', - start: '304730', - }, - description: `Access your desktop from any device with a browser to keep your desktop hosted in the cloud.`, - logo_url: 'guacamole.svg', - name: 'Guacamole', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/guacamole/', - title: 'Deploy Apache Guacamole through the Linode Marketplace', - }, - ], - summary: 'Free open source clientless remote desktop gateway.', - website: 'https://guacamole.apache.org/', - }, - { - alt_description: 'Web Application Firewall.', - alt_name: 'Community WAF', - categories: ['Security'], - colors: { - end: '00C1A9', - start: '22324F', - }, - description: `Harden your web applications and APIs against OWASP Top 10 attacks. Haltdos makes it easy to manage WAF settings and review logs in an intuitive web-based GUI.`, - logo_url: 'haltdos.svg', - name: 'HaltDOS Community WAF', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/haltdos-community-waf/', - title: 'Deploy Haltdos Community WAF through the Linode Marketplace', - }, - ], - summary: 'User-friendly web application firewall.', - website: 'https://www.haltdos.com/', - }, - { - alt_description: 'Container registry for Kubernetes.', - alt_name: 'Container registry for Kubernetes.', - categories: ['Development'], - colors: { - end: '4495d7', - start: '60b932', - }, - description: `Open source registry for images and containers. Linode recommends using Harbor with Linode Kubernetes Engine (LKE).`, - logo_url: 'harbor.svg', - name: 'Harbor', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/harbor/', - title: 'Deploy Harbor through the Linode Marketplace', - }, - ], - summary: 'Cloud native container registry for Kubernetes and more.', - website: 'https://goharbor.io/docs', - }, - { - alt_description: - 'HashiCorp containerization tool to use instead of or with Kubernetes', - alt_name: 'Container scheduler and orchestrator', - categories: ['Development'], - colors: { - end: '545556', - start: '60dea9', - }, - description: - 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', - logo_url: 'nomad.svg', - name: 'HashiCorp Nomad Cluster', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad-cluster', - title: 'Deploy HashiCorp Nomad Cluster through the Linode Marketplace', - }, - ], - summary: 'Flexible scheduling and orchestration for diverse workloads.', - website: 'https://www.nomadproject.io/docs', - }, - { - alt_description: - 'HashiCorp Nomad clients for horizontally scaling a Nomad One-Click Cluster', - alt_name: 'Container scheduler and orchestrator', - categories: ['Development'], - colors: { - end: '545556', - start: '60dea9', - }, - description: - 'A simple deployment of multiple clients to horizontally scale an existing Nomad One-Click Cluster.', - logo_url: 'nomad.svg', - name: 'HashiCorp Nomad Clients Cluster', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad-clients-cluster', - title: - 'Deploy HashiCorp Nomad Clients Cluster through the Linode Marketplace', - }, - ], - summary: 'Flexible scheduling and orchestration for diverse workloads.', - website: 'https://www.nomadproject.io/docs', - }, - { - alt_description: - 'HashiCorp containerization tool to use instead of or with Kubernetes', - alt_name: 'Container scheduler and orchestrator', - categories: ['Development'], - colors: { - end: '545556', - start: '60dea9', - }, - description: - 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', - logo_url: 'nomad.svg', - name: 'HashiCorp Nomad', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad', - title: 'Deploy HashiCorp Nomad through the Linode Marketplace', - }, - ], - summary: 'Flexible scheduling and orchestration for diverse workloads.', - website: 'https://www.nomadproject.io/docs', - }, - { - alt_description: 'HashiCorp password and secrets management storage.', - alt_name: 'Security secrets management', - categories: ['Security'], - colors: { - end: '545556', - start: 'ffd712', - }, - description: - 'HashiCorp Vault is an open source, centralized secrets management system. It provides a secure and reliable way of storing and distributing secrets like API keys, access tokens, and passwords.', - logo_url: 'vault.svg', - name: 'HashiCorp Vault', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-vault', - title: 'Deploy HashiCorp Vault through the Linode Marketplace', - }, - ], - summary: 'An open source, centralized secrets management system.', - website: 'https://www.vaultproject.io/docs', - }, - { - alt_description: - 'Retool open-source alternative, with low-code UI components.', - alt_name: 'Low-code development platform', - categories: ['Security'], - colors: { - end: 'FF58BE', - start: '654AEC', - }, - description: - 'Illa Builder is a Retool open-source alternative, with low-code UI components for self-hosting the development of internal tools.', - logo_url: 'illabuilder.svg', - name: 'Illa Builder', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/illa-builder', - title: 'Deploy Illa Builder through the Linode Marketplace', - }, - ], - summary: 'An open-source, low-code development platform.', - website: 'https://github.com/illacloud/illa-builder', - }, - { - alt_description: 'CI/CD tool to delegate automation tasks and jobs.', - alt_name: 'Free automation tool', - categories: ['Development'], - colors: { - end: 'd24939', - start: 'd33833', - }, - description: `Jenkins is an open source automation tool which can build, test, and deploy your infrastructure.`, - logo_url: 'jenkins.svg', - name: 'Jenkins', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/jenkins/', - title: 'Deploy Jenkins through the Linode Marketplace', - }, - ], - summary: `A tool that gives you access to a massive library of plugins to support automation in your project's lifecycle.`, - website: 'https://jenkins.io/', - }, - { - alt_description: 'Enterprise-ready backups tool.', - alt_name: 'Server backups management and control panel', - categories: ['Control Panels'], - colors: { - end: '1f2c38', - start: 'ff6c2c', - }, - description: `Powerful and customizable backups for several websites and data all in the same interface. JetBackup integrates with any control panel via API, and has native support for cPanel and DirectAdmin. Easily backup your data to storage you already use, including Linodeā€™s S3-compatible Object Storage.`, - logo_url: 'jetbackup.svg', - name: 'JetBackup', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/jetbackup/', - title: 'Deploy JetBackup through the Linode Marketplace', - }, - ], - summary: - 'Advanced customizable backups to integrate with your preferred control panel.', - website: 'https://docs.jetapps.com/', - }, - { - alt_description: 'Open source video conferencing alternative to Zoom.', - alt_name: 'Video chat and video conferencing', - categories: ['Media and Entertainment'], - colors: { - end: '949699', - start: '1d76ba', - }, - description: `Secure, stable, and free alternative to popular video conferencing services. Use built-in features to limit meeting access with passwords or stream on YouTube so anyone can attend.`, - logo_url: 'jitsi.svg', - name: 'Jitsi', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi/', - title: 'Deploy Jitsi through the Linode Marketplace', - }, - ], - summary: 'Free, open source video conferencing and communication platform.', - website: 'https://jitsi.org/', - }, - { - alt_description: - 'Open source video conferencing cluster, alternative to Zoom.', - alt_name: 'Video chat and video conferencing cluster', - categories: ['Media and Entertainment'], - colors: { - end: '949699', - start: '1d76ba', - }, - description: `Secure, stable, and free alternative to popular video conferencing services. This app deploys four networked Jitsi nodes.`, - logo_url: 'jitsi.svg', - name: 'Jitsi Cluster', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi-cluster/', - title: 'Deploy Jitsi Cluster through the Linode Marketplace', - }, - ], - summary: 'Free, open source video conferencing and communication platform.', - website: 'https://jitsi.org/', - }, - { - alt_description: 'Secure website CMS.', - alt_name: 'CMS: content management system', - categories: ['Website'], - colors: { - end: '5090cd', - start: 'f2a13e', - }, - description: `Free open source CMS optimized for building custom functionality and design.`, - logo_url: 'joomla.svg', - name: 'Joomla', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/joomla/', - title: 'Deploy Joomla through the Linode Marketplace', - }, - ], - summary: 'Flexible and security-focused content management system.', - website: 'https://www.joomla.org/', - }, - { - alt_description: - 'Digital note-taking application alternative to Evernote and OneNote.', - alt_name: 'Multimedia note-taking and digital notebook', - categories: ['Website'], - colors: { - end: '509df9', - start: '043872', - }, - description: `Capture your thoughts and securely access them from any device with a highly customizable note-taking software.`, - logo_url: 'joplin.svg', - name: 'Joplin', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/joplin/', - title: 'Deploy Joplin through the Linode Marketplace', - }, - ], - summary: 'Open source multimedia note-taking app.', - website: 'https://joplinapp.org/', - }, - { - alt_description: 'Data science notebook.', - alt_name: 'Data science and machine learning development environment.', - categories: ['Productivity'], - colors: { - end: '9e9e9e', - start: 'f37626', - }, - description: - 'JupyterLab is a cutting-edge web-based, interactive development environment, geared towards data science, machine learning and other scientific computing workflows.', - logo_url: 'jupyter.svg', - name: 'JupyterLab', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/jupyterlab/', - title: 'Deploy JupyterLab through the Linode Marketplace', - }, - ], - summary: 'Data science development environment.', - website: 'https://jupyter.org', - }, - { - alt_description: - 'Security research and testing platform with hundreds of tools for reverse engineering, penetration testing, and more.', - alt_name: 'Security research', - categories: ['Security'], - colors: { - end: '2fa1bc', - start: '267ff7', - }, - description: `Kali Linux is an open source, Debian-based Linux distribution that has become an industry-standard tool for penetration testing and security audits. Kali includes hundreds of free tools for reverse engineering, penetration testing and more. Kali prioritizes simplicity, making security best practices more accessible to everyone from cybersecurity professionals to hobbyists.`, - logo_url: 'kalilinux.svg', - name: 'Kali Linux', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/kali-linux/', - title: 'Deploy Kali Linux through the Linode Marketplace', - }, - ], - summary: - 'Popular Linux distribution and tool suite for penetration testing and security research.', - website: 'https://www.kali.org/', - }, - { - alt_description: 'Drag and drop website CMS.', - alt_name: 'CMS: content management system', - categories: ['Website'], - colors: { - end: '4395ff', - start: '0166ff', - }, - description: `Use Kepler Builder to easily design and build sites in WordPress - no coding or design knowledge necessary.`, - logo_url: 'keplerbuilder.svg', - name: 'Kepler Builder', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/kepler/', - title: 'Deploy Kepler through the Linode Marketplace', - }, - ], - summary: 'Powerful drag & drop WordPress website builder.', - website: 'https://kepler.app/', - }, - { - alt_description: 'Essential software stack for Linux applications.', - alt_name: 'Web stack', - categories: ['Stacks'], - colors: { - end: 'bfa477', - start: '3c4043', - }, - description: `The LAMP stack consists of the Linux operating system, the Apache HTTP Server, the MySQL relational database management system, and the PHP programming language. This software environment is a foundation for popular PHP application - frameworks like WordPress, Drupal, and Laravel. Upload your existing PHP application code to your new app or use a PHP framework to write a new application on the Linode.`, - logo_url: 'lamp_flame.svg', - name: 'LAMP', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/lamp-stack/', - title: 'Deploy a LAMP Stack through the Linode Marketplace', - }, - ], - summary: `Build PHP-based applications with the LAMP software stack: Linux, Apache, MySQL, and PHP.`, - }, - { - alt_description: 'Essential software stack for Linux applications.', - alt_name: 'Web stack', - categories: ['Stacks'], - colors: { - end: '005138', - start: '2e7d32', - }, - description: `LEMP provides a platform for applications that is compatible with the LAMP stack for nearly all applications; however, because NGINX is able to serve more pages at once with a more predictable memory usage profile, it may be more suited to high demand situations.`, - logo_url: 'lemp.svg', - name: 'LEMP', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/lemp-stack/', - title: 'Deploy a LEMP Stack through the Linode Marketplace', - }, - ], - summary: `The LEMP stack replaces the Apache web server component with NGINX (ā€œEngine-Xā€), providing the E in the acronym: Linux, NGINX, MySQL/MariaDB, PHP.`, - }, - { - alt_description: - 'LinuxGSM is a command line utility that simplifies self-hosting multiplayer game servers.', - alt_name: 'Multiplayer Game Servers', - categories: ['Games'], - colors: { - end: 'F6BD0C', - start: '000000', - }, - description: `Self hosted multiplayer game servers.`, - logo_url: 'linuxgsm.svg', - name: 'LinuxGSM', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/linuxgsm/', - title: 'Deploy LinuxGSM through the Linode Marketplace', - }, - ], - summary: 'Simple command line multiplayer game servers.', - website: 'https://docs.linuxgsm.com', - }, - { - alt_description: 'Optimized control panel server.', - alt_name: 'Web server control panel', - categories: ['Website'], - colors: { - end: '6e92c7', - start: '353785', - }, - description: `High-performance LiteSpeed web server equipped with WHM/cPanel and WHM LiteSpeed Plugin.`, - logo_url: 'litespeedcpanel.svg', - name: 'LiteSpeed cPanel', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/litespeed-cpanel/', - title: 'Deploy LiteSpeed cPanel through the Linode Marketplace', - }, - ], - summary: 'Next-generation web server with cPanel and WHM.', - website: 'https://docs.litespeedtech.com/cp/cpanel/', - }, - { - alt_description: 'Audio and video streaming with E2E data encryption.', - alt_name: 'Live streaming', - categories: ['Media and Entertainment'], - colors: { - end: '4d8eff', - start: '346ee0', - }, - description: `Stream live audio or video while maximizing customer engagement with advanced built-in features. Liveswitch provides real-time monitoring, audience polling, and end-to-end (E2E) data encryption.`, - logo_url: 'liveswitch.svg', - name: 'LiveSwitch', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/liveswitch/', - title: 'Deploy LiveSwitch through the Linode Marketplace', - }, - ], - summary: 'High quality and reliable interactive live streaming.', - website: 'https://www.liveswitch.io/', - }, - { - alt_description: 'FFmpeg encoder plugins.', - alt_name: 'Premium video encoding', - categories: ['Media and Entertainment'], - colors: { - end: '041125', - start: '6DBA98', - }, - description: `MainConcept FFmpeg Plugins Demo is suited for both VOD and live production workflows, with advanced features such as Hybrid GPU acceleration and xHE-AAC audio format.`, - logo_url: 'mainconcept.svg', - name: 'MainConcept FFmpeg Plugins Demo', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-ffmpeg-plugins-demo/', - title: - 'Deploy MainConcept FFmpeg Plugins Demo through the Linode Marketplace', - }, - ], - summary: - 'MainConcept FFmpeg Plugins Demo contains advanced video encoding tools.', - website: 'https://www.mainconcept.com/ffmpeg', - }, - { - alt_description: 'Live video encoding engine.', - alt_name: 'Real time video encoding', - categories: ['Media and Entertainment'], - colors: { - end: '041125', - start: '6DBA98', - }, - description: `MainConcept Live Encoder Demo is a powerful all-in-one encoding engine designed to simplify common broadcast and OTT video workflows.`, - logo_url: 'mainconcept.svg', - name: 'MainConcept Live Encoder Demo', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-live-encoder-demo/', - title: - 'Deploy MainConcept Live Encoder Demo through the Linode Marketplace', - }, - ], - summary: 'MainConcept Live Encoder is a real time video encoding engine.', - website: 'https://www.mainconcept.com/live-encoder', - }, - { - alt_description: 'Panasonic camera format encoder.', - alt_name: 'Media encoding into professional file formats.', - categories: ['Media and Entertainment'], - colors: { - end: '041125', - start: '6DBA98', - }, - description: `MainConcept P2 AVC ULTRA Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Panasonic camera formats like P2 AVC-Intra, P2 AVC LongG and AVC-intra RP2027.v1 and AAC High Efficiency v2 formats into an MP4 container.`, - logo_url: 'mainconcept.svg', - name: 'MainConcept P2 AVC ULTRA Transcoder Demo', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-p2-avc-ultra-demo/', - title: - 'Deploy MainConcept P2 AVC ULTRA Transcoder Demo through the Linode Marketplace', - }, - ], - summary: - 'MainConcept P2 AVC ULTRA Transcoder is a Docker container for file-based transcoding of media files into professional Panasonic camera formats.', - website: 'https://www.mainconcept.com/transcoders', - }, - { - alt_description: 'Sony camera format encoder.', - alt_name: 'Media encoding into professional file formats.', - categories: ['Media and Entertainment'], - colors: { - end: '041125', - start: '6DBA98', - }, - description: `MainConcept XAVC Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XAVC-Intra, XAVC Long GOP and XAVC-S.`, - logo_url: 'mainconcept.svg', - name: 'MainConcept XAVC Transcoder Demo', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xavc-transcoder-demo/', - title: - 'Deploy MainConcept XAVC Transcoder Demo through the Linode Marketplace', - }, - ], - summary: - 'MainConcept XAVC Transcoder is a Docker container for file-based transcoding of media files into professional Sony camera formats.', - website: 'https://www.mainconcept.com/transcoders', - }, - { - alt_description: 'Sony XDCAM format encoder.', - alt_name: 'Media encoding into professional file formats.', - categories: ['Media and Entertainment'], - colors: { - end: '041125', - start: '6DBA98', - }, - description: `MainConcept XDCAM Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XDCAM HD, XDCAM EX, XDCAM IMX and DVCAM (XDCAM DV).`, - logo_url: 'mainconcept.svg', - name: 'MainConcept XDCAM Transcoder Demo', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xdcam-transcoder-demo/', - title: - 'Deploy MainConcept XDCAM Transcoder Demo through the Linode Marketplace', - }, - ], - summary: - 'MainConcept XDCAM Transcoder is a Docker container for file-based transcoding of media files into professional Sony camera formats.', - website: 'https://www.mainconcept.com/transcoders', - }, - { - alt_description: 'Open source Twitter alternative.', - alt_name: 'Open source social media', - categories: ['Media and Entertainment'], - colors: { - end: '563ACC', - start: '6364FF', - }, - description: `Mastodon is an open-source and decentralized micro-blogging platform, supporting federation and public access to the server.`, - logo_url: 'mastodon.svg', - name: 'Mastodon', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mastodon/', - title: 'Deploy Mastodon through the Linode Marketplace', - }, - ], - summary: - 'Mastodon is an open-source and decentralized micro-blogging platform.', - website: 'https://docs.joinmastodon.org/', - }, - { - alt_description: 'Angular and Node.js stack.', - alt_name: 'Web framework', - categories: ['Development'], - colors: { - end: '686868', - start: '323232', - }, - description: `MEAN is a full-stack JavaScript-based framework which accelerates web application development much faster than other frameworks. All involved technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications.`, - logo_url: 'mean.svg', - name: 'MEAN', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mean-stack/', - title: 'Deploy a MEAN Stack through the Linode Marketplace', - }, - ], - summary: `A MEAN (MongoDB, Express, Angular, Node.js) stack is a free and open-source web software bundle used to build modern web applications.`, - website: 'http://meanjs.org/', - }, - { - alt_description: 'React and Node.js stack.', - alt_name: 'Web stack', - categories: [], - colors: { - end: '256291', - start: '30383a', - }, - description: `MERN is a full stack platform that contains everything you need to build a web application: MongoDB, a document database used to persist your application's data; Express, which serves as the web application framework; React, used to build your application's user interfaces; - and Node.js, which serves as the run-time environment for your application. All of these technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications. Upload your - existing MERN website code to your new Linode, or use MERN's scaffolding tool to start writing new web applications on the Linode.`, - logo_url: 'mern.svg', - name: 'MERN', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mern-stack/', - title: 'Deploy a MERN Stack through the Linode Marketplace', - }, - ], - summary: `Build production-ready apps with the MERN stack: MongoDB, Express, React, and Node.js.`, - }, - { - alt_description: 'Drag and drop website CMS.', - alt_name: 'Website builder', - categories: ['Development'], - colors: { - end: '4592ff', - start: '4592ff', - }, - description: `Microweber is an easy Drag and Drop website builder and a powerful CMS of a new generation, based on the PHP Laravel Framework.`, - logo_url: 'microweber.svg', - name: 'Microweber', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/microweber/', - title: 'Deploy Microweber through the Linode Marketplace', - }, - ], - summary: `Drag and drop CMS and website builder.`, - website: 'https://microweber.com/', - }, - { - alt_description: 'Classic open world survival crafting game.', - alt_name: 'World building game', - categories: ['Games'], - colors: { - end: 'd0c8c4', - start: '97948f', - }, - description: `With over 100 million users around the world, Minecraft is the most popular online game of all time. Less of a game and more of a lifestyle choice, you and other players are free to build and explore in a 3D generated world made up of millions of mineable blocks. Collect resources by leveling mountains, - taming forests, and venturing out to sea. Choose a home from the varied list of biomes like ice worlds, flower plains, and jungles. Build ancient castles or modern mega cities, and fill them with redstone circuit contraptions and villagers. Fight off nightly invasions of Skeletons, Zombies, and explosive - Creepers, or adventure to the End and the Nether to summon the fabled End Dragon and the chaotic Wither. If that is not enough, Minecraft is also highly moddable and customizable. You decide the rules when hosting your own Minecraft server for you and your friends to play together in this highly addictive game.`, - logo_url: 'minecraft.svg', - name: 'Minecraft: Java Edition', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/minecraft/', - title: 'Deploy a Minecraft Server through the Linode Marketplace', - }, - ], - summary: `Build, explore, and adventure in your own 3D generated world.`, - website: 'https://www.minecraft.net/', - }, - { - alt_description: 'Open source course builder and education tool.', - alt_name: 'Online course CMS', - categories: ['Website'], - colors: { - end: '494949', - start: 'ff7800', - }, - description: `Robust open-source learning platform enabling online education for more than 200 million users around the world. Create personalized learning environments within a secure and integrated system built for all education levels with an intuitive interface, drag-and-drop features, and accessible documentation.`, - logo_url: 'moodle.svg', - name: 'Moodle', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/moodle/', - title: 'Deploy Moodle through the Linode Marketplace', - }, - ], - summary: - 'Worldā€™s most popular learning management system built and maintained by an active developer community.', - website: 'https://docs.moodle.org/', - }, - { - alt_description: 'SQL database.', - alt_name: 'SQL database', - categories: ['Databases'], - colors: { - end: '8a9177', - start: '1d758f', - }, - description: `MySQL, or MariaDB for Linux distributions, is primarily used for web and server applications, including as a component of the industry-standard LAMP and LEMP stacks.`, - logo_url: 'mysql.svg', - name: 'MySQL/MariaDB', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mysql/', - title: 'Deploy MySQL/MariaDB through the Linode Marketplace', - }, - ], - summary: `World's most popular open source database.`, - website: 'https://www.mysql.com/', - }, - { - alt_description: `Microservice centeric stream processing.`, - alt_name: 'Microservice messaging bus', - categories: ['Development'], - colors: { - end: '000000', - start: '0086FF', - }, - description: - 'NATS is a distributed PubSub technology that enables applications to securely communicate across any combination of cloud vendors, on-premise, edge, web and mobile, and devices.', - logo_url: 'nats.svg', - name: 'NATS Single Node', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/nats-single-node/', - title: 'Deploy NATS single node through the Linode Marketplace', - }, - ], - summary: 'Cloud native application messaging service.', - website: 'https://nats.io', - }, - { - alt_description: - 'File storage alternative to Dropbox and office suite alternative to Microsoft Office.', - alt_name: 'File storage management & business tool suite', - categories: ['Productivity'], - colors: { - end: '2a2a36', - start: '16a5f3', - }, - description: `Nextcloud AIO stands for Nextcloud All In One, and provides easy deployment and maintenance for popular Nextcloud tools. AIO includes Nextcloud, Nextcloud Office, OnlyOffice, and high-performance backend features.`, - logo_url: 'nextcloud.svg', - name: 'Nextcloud', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/nextcloud/', - title: 'Deploy Nextcloud through the Linode Marketplace', - }, - ], - summary: `A safe home for all your data.`, - }, - { - alt_description: - 'File storage and sharing alternative to Dropbox and Google Drive.', - alt_name: 'File sharing', - categories: ['Productivity'], - colors: { - end: '252730', - start: '1f4c8f', - }, - description: `Securely share and collaborate Linode S3 object storage files/folders with your internal or external users such as customers, partners, vendors, etc with fine access control and a simple interface. Nirvashare easily integrates with many external identity providers such as Active Directory, GSuite, AWS SSO, KeyClock, etc.`, - logo_url: 'nirvashare.svg', - name: 'NirvaShare', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/nirvashare/', - title: 'Deploy NirvaShare through the Linode Marketplace', - }, - ], - summary: - 'Secure file sharing for better collaboration with employees, partners, vendors, and more.', - website: 'https://nirvashare.com/setup-guide/', - }, - { - alt_description: - 'Versatile cross-platform JavaScript run-time (runtime) environment.', - alt_name: 'JavaScript environment', - categories: ['Development'], - colors: { - end: '333333', - start: '3d853c', - }, - description: `NodeJS is a free, open-source, and cross-platform JavaScript run-time environment that lets developers write command line tools and server-side scripts outside of a browser.`, - logo_url: 'nodejs.svg', - name: 'NodeJS', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/nodejs/', - title: 'Deploy NodeJS through the Linode Marketplace', - }, - ], - summary: - 'Popular and versatile open source JavaScript run-time environment.', - website: 'https://nodejs.org/', - }, - { - alt_description: - 'Open source marketing and business platform with a CRM and email marketing.', - alt_name: 'Marketing tool suite', - categories: ['Productivity'], - colors: { - end: '027e84', - start: '55354c', - }, - description: `Odoo is a free and comprehensive business app suite of tools that seamlessly integrate. Choose what you need to manage your business on a single platform, including a CRM, email marketing tools, essential project management functions, and more.`, - logo_url: 'odoo.svg', - name: 'Odoo', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/odoo/', - title: 'Deploy Odoo through the Linode Marketplace', - }, - ], - summary: - 'Open source, all-in-one business app suite with more than 7 million users.', - website: 'https://www.odoo.com/', - }, - { - alt_description: 'Office Suite', - alt_name: 'Office Docs', - categories: ['Productivity'], - colors: { - end: 'ff6f3d', - start: 'ffa85b', - }, - description: `Create and collaborate on text documents, spreadsheets, and presentations compatible with popular file types including .docx, .xlsx, and more. Additional features include real-time editing, paragraph locking while co-editing, and version history.`, - logo_url: 'onlyoffice.svg', - name: 'ONLYOFFICE Docs', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/onlyoffice/', - title: 'Deploy ONLYOFFICE Docs through the Linode Marketplace', - }, - ], - summary: 'Open source comprehensive office suite.', - website: 'https://www.onlyoffice.com/', - }, - { - alt_description: 'Fast Python development with best practices.', - alt_name: 'Python framework', - categories: ['Development'], - colors: { - end: '5cbf8a', - start: '318640', - }, - description: `Simple deployment for OLS web server, Python LSAPI, and CertBot.`, - logo_url: 'openlitespeeddjango.svg', - name: 'OpenLiteSpeed Django', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-django/', - title: 'Deploy OpenLiteSpeed Django through the Linode Marketplace', - }, - ], - summary: 'OLS web server with Django development framework.', - website: 'https://docs.litespeedtech.com/cloud/images/django/', - }, - { - alt_description: - 'Versatile cross-platform JavaScript run-time (runtime) environment.', - alt_name: 'JavaScript environment', - categories: ['Development'], - colors: { - end: '33cccc', - start: '3d596d', - }, - description: `High-performance open source web server with Node and CertBot, in addition to features like HTTP/3 support and easy SSL setup.`, - logo_url: 'openlitespeednodejs.svg', - name: 'OpenLiteSpeed NodeJS', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-nodejs/', - title: 'Deploy OpenLiteSpeed Node.js through the Linode Marketplace', - }, - ], - summary: 'OLS web server with NodeJS JavaScript runtime environment.', - website: 'https://docs.litespeedtech.com/cloud/images/nodejs/', - }, - { - alt_description: 'Ruby web application framework with development tools.', - alt_name: 'Ruby web application framework.', - categories: ['Development'], - colors: { - end: 'd94b7a', - start: '8e1a4a', - }, - description: `Easy setup to run Ruby apps in the cloud and take advantage of OpenLiteSpeed server features like SSL, HTTP/3 support, and RewriteRules.`, - logo_url: 'openlitespeedrails.svg', - name: 'OpenLiteSpeed Rails', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-rails/', - title: 'Deploy OpenLiteSpeed Rails through the Linode Marketplace ', - }, - ], - summary: 'OLS web server with Ruby and CertBot.', - website: 'https://docs.litespeedtech.com/cloud/images/rails/', - }, - { - alt_description: 'Popular website content management system.', - alt_name: 'CMS: content management system', - categories: ['Website'], - colors: { - end: '3d596d', - start: '33cccc', - }, - description: `Accelerated and scalable hosting for WordPress. Includes OpenLiteSpeed, PHP, MySQL Server, WordPress, and LiteSpeed Cache.`, - logo_url: 'openlitespeedwordpress.svg', - name: 'OpenLiteSpeed WordPress', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-wordpress/', - title: 'Deploy OpenLiteSpeed Wordpress through the Linode Marketplace', - }, - ], - summary: 'Blazing fast, open source alternative to LiteSpeed Web Server.', - website: 'https://openlitespeed.org/', - }, - { - alt_description: 'Popular virtual private network.', - alt_name: 'Free VPN', - categories: ['Security'], - colors: { - end: '193766', - start: 'ea7e20', - }, - description: `OpenVPN is a widely trusted, free, and open-source virtual private network application. OpenVPN creates network tunnels between groups of computers that are not on the same local network, and it uses OpenSSL to encrypt your traffic.`, - logo_url: 'openvpn.svg', - name: 'OpenVPN', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/openvpn/', - title: 'Deploy OpenVPN through the Linode Marketplace', - }, - ], - summary: `Open-source virtual private network (VPN) application. OpenVPN securely connects your computer to your servers, or to the public Internet.`, - website: 'https://openvpn.net/', - }, - { - alt_description: 'Video and audio live streaming alternative to Twitch.', - alt_name: 'Live streaming app', - categories: ['Media and Entertainment'], - colors: { - end: '2086e1', - start: '7871ff', - }, - description: `A live streaming and chat server for use with existing popular broadcasting software.`, - logo_url: 'owncast.svg', - name: 'Owncast', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/owncast/', - title: 'Deploy Owncast through the Linode Marketplace', - }, - ], - summary: - 'The standalone ā€œTwitch in a Boxā€ open source streaming and chat solution.', - website: 'https://owncast.online/', - }, - { - alt_description: 'Self-hosted file sharing and collaboration platform.', - alt_name: 'Collabrative file sharing', - categories: ['Productivity'], - colors: { - end: '041e42', - start: '041e42', - }, - description: `LAMP-stack-based server application that allows you to access your files from anywhere in a secure way.`, - logo_url: 'owncloud.svg', - name: 'ownCloud', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/owncloud/', - title: 'Deploy ownCloud through the Linode Marketplace', - }, - ], - summary: - 'Dropbox and OneDrive alternative that lets you remain in control of your files.', - website: 'https://doc.owncloud.com/docs/next/', - }, - { - alt_description: 'Password Manager', - alt_name: 'Passbolt', - categories: ['Security'], - colors: { - end: 'D40101', - start: '171717', - }, - description: `Passbolt is an open-source password manager designed for teams and businesses. It allows users to securely store, share and manage passwords.`, - logo_url: 'passbolt.svg', - name: 'Passbolt', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/passbolt/', - title: 'Deploy Passbolt through the Linode Marketplace', - }, - ], - summary: 'Open-source password manager for teams and businesses.', - website: 'https://www.passbolt.com/', - }, - { - alt_description: 'Password Manager', - alt_name: 'Pass Key', - categories: ['Security'], - colors: { - end: '3A5EFF', - start: '709cff', - }, - description: `Self-host a password manager designed to simplify and secure your digital life. Passky is a streamlined version of paid password managers designed for everyone to use.`, - logo_url: 'passky.svg', - name: 'Passky', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/passky/', - title: 'Deploy Passky through the Linode Marketplace', - }, - ], - summary: 'Simple open source password manager.', - website: 'https://passky.org/', - }, - { - alt_description: 'Open source project management tool.', - alt_name: 'Ticket management project management tool', - categories: ['Productivity'], - colors: { - end: '0a0a0a', - start: '4cff4c', - }, - description: `Open source alternative to paid ticket management solutions with essential features including a streamlined task list, project and client management, and ticket prioritization.`, - logo_url: 'peppermint.svg', - name: 'Peppermint', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/peppermint/', - title: 'Deploy Peppermint through the Linode Marketplace', - }, - ], - summary: 'Simple yet scalable open source ticket management.', - website: 'https://peppermint.sh/', - }, - { - alt_description: - 'Web interface for MySQL/MariaDB operations and server administration.', - alt_name: 'SQL database GUI', - categories: ['Databases'], - colors: { - end: '6c78af', - start: 'f89d10', - }, - description: `Intuitive web interface for MySQL and MariaDB operations, including importing/exporting data, administering multiple servers, and global database search.`, - logo_url: 'phpmyadmin.svg', - name: 'phpMyAdmin', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/phpmyadmin/', - title: 'Deploy phpMyAdmin through the Linode Marketplace', - }, - ], - summary: 'Popular free administration tool for MySQL and MariaDB.', - website: 'https://www.phpmyadmin.net/', - }, - { - alt_description: 'Popular DNS privacy sinkhole.', - alt_name: 'Network ad blocking', - categories: ['Security'], - colors: { - end: 'f60d1a', - start: '96060c', - }, - description: `Protect your network and devices from unwanted content. Avoid ads in non-browser locations with a free, lightweight, and comprehensive privacy solution you can self-host.`, - logo_url: 'pihole.svg', - name: 'Pi-hole', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/pihole/', - title: 'Deploy Pi-hole through the Linode Marketplace', - }, - ], - summary: 'Free, open source, and highly scalable DNS sinkhole.', - website: 'https://pi-hole.net/', - }, - { - alt_description: 'Popular WordPress server management.', - alt_name: 'WordPress control panel', - categories: ['Control Panels'], - colors: { - end: '4b5868', - start: '53bce6', - }, - description: `Plesk is a leading WordPress and website management platform and control panel. Plesk lets you build and manage multiple websites from a single dashboard to configure web services, email, and other applications. Plesk features hundreds of extensions, plus a complete WordPress toolkit. Use the Plesk One-Click App to manage websites hosted on your Linode.`, - logo_url: 'plesk.svg', - name: 'Plesk', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/plesk/', - title: 'Deploy Plesk through the Linode Marketplace', - }, - ], - summary: - 'A secure, scalable, and versatile website and WordPress management platform.', - website: 'https://www.plesk.com/', - }, - { - alt_description: - 'Video / media library storage and sharing across TVs, phones, computers, and more.', - alt_name: 'Media server', - categories: [], - colors: { - end: '332c37', - start: 'e5a00d', - }, - description: `Organize, stream, and share your media library with friends, in addition to free live TV in 220+ countries.`, - logo_url: 'plex.svg', - name: 'Plex', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/plex/', - title: 'Deploy Plex Media Server through the Linode Marketplace', - }, - ], - summary: - 'Media server and streaming service to stay entertained across devices.', - website: 'https://www.plex.tv/', - }, - { - alt_description: 'MySQL alternative for SQL database.', - alt_name: 'SQL database', - categories: ['Databases'], - colors: { - end: '254078', - start: '326690', - }, - description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your databaseā€™s performance in a production environment.`, - logo_url: 'postgresql.svg', - name: 'PostgreSQL', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/postgresql/', - title: 'Deploy PostgreSQL through the Linode Marketplace', - }, - ], - summary: `The PostgreSQL relational database system is a powerful, scalable, and standards-compliant open-source database platform.`, - website: 'https://www.postgresql.org/', - }, - { - alt_description: 'MySQL alternative for SQL database.', - alt_name: 'SQL database', - categories: ['Databases'], - colors: { - end: '254078', - start: '326690', - }, - description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your databaseā€™s performance in a production environment.`, - logo_url: 'postgresqlmarketplaceocc.svg', - name: 'PostgreSQL Cluster', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/postgresql-cluster/', - title: 'Deploy PostgreSQL Cluster through the Linode Marketplace', - }, - ], - summary: `The PostgreSQL relational database system is a powerful, scalable, and standards-compliant open-source database platform.`, - website: 'https://www.postgresql.org/', - }, - { - alt_description: 'Virtual private network for businesses and teams.', - alt_name: 'Enterprise VPN', - categories: ['Security'], - colors: { - end: '2e72d2', - start: '2e4153', - }, - description: `User-friendly VPN for both individual and commercial use. Choose from three pricing plans.`, - logo_url: 'pritunl.svg', - name: 'Pritunl', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/pritunl/', - title: 'Deploy Pritunl through the Linode Marketplace', - }, - ], - summary: 'Enterprise open source VPN.', - website: 'https://docs.pritunl.com/docs', - }, - { - alt_description: 'Monitoring server.', - alt_name: 'Server monitoring and visualization', - categories: ['Monitoring'], - colors: { - end: 'e6522c', - start: 'f9b716', - }, - description: `Free industry-standard monitoring tools that work better together. Prometheus is a powerful monitoring software tool that collects metrics from configurable data points at given intervals, evaluates rule expressions, and can trigger alerts if some condition is observed. Use Grafana to create visuals, monitor, store, and share metrics with your team to keep tabs on your infrastructure.`, - logo_url: 'prometheusgrafana.svg', - name: 'Prometheus & Grafana', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/prometheus-grafana/', - title: 'Deploy Prometheus & Grafana through the Linode Marketplace', - }, - ], - summary: 'Open source metrics and monitoring for real-time insights.', - website: 'https://prometheus.io/docs/visualization/grafana/', - }, - { - alt_description: 'Server work queue management.', - alt_name: 'Message broker', - categories: ['Development'], - colors: { - end: 'ff6600', - start: 'a9b5af', - }, - description: `Connect and scale applications with asynchronous messaging and highly available work queues, all controlled through an intuitive management UI.`, - logo_url: 'rabbitmq.svg', - name: 'RabbitMQ', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/rabbitmq/', - title: 'Deploy RabbitMQ through the Linode Marketplace', - }, - ], - summary: 'Most popular open source message broker.', - website: 'https://www.rabbitmq.com/', - }, - { - alt_description: 'In-memory caching database.', - alt_name: 'High performance database', - categories: ['Databases'], - colors: { - end: '722b20', - start: '222222', - }, - description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

    *Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, - logo_url: 'redis.svg', - name: 'Marketplace App for Redis®', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/redis/', - title: 'Deploy Redis® through the Linode Marketplace', - }, - ], - summary: - 'Flexible, in-memory, NoSQL database service supported in many different coding languages.', - website: 'https://redis.io/', - }, - { - alt_description: 'In-memory caching database.', - alt_name: 'High performance database', - categories: ['Databases'], - colors: { - end: '722b20', - start: '222222', - }, - description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

    *Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, - logo_url: 'redissentinelmarketplaceocc.svg', - name: 'Marketplace App for Redis® Sentinel Cluster', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/redis-cluster/', - title: - 'Deploy Redis® Sentinel Cluster through the Linode Marketplace', - }, - ], - summary: - 'Flexible, in-memory, NoSQL database service supported in many different coding languages.', - website: 'https://redis.io/', - }, - { - alt_description: 'Free alternative to Trello and Asana.', - alt_name: 'Kanban board project management tool', - categories: ['Productivity'], - colors: { - end: '555555', - start: 'f47564', - }, - description: `Restyaboard is an open-source alternative to Trello, but with additional smart features like offline sync, diff /revisions, nested comments, multiple view layouts, chat, and more.`, - logo_url: 'restyaboard.svg', - name: 'Restyaboard', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/restyaboard/', - title: 'Deploy Restyaboard through the Linode Marketplace', - }, - ], - summary: 'Free and open source project management tool.', - website: 'https://restya.com', - }, - { - alt_description: 'Free alternative to Slack, Microsoft Teams, and Skype.', - alt_name: 'Chat software', - categories: ['Productivity'], - colors: { - end: '030d1a', - start: 'f5445c', - }, - description: `Put data privacy first with an alternative to programs like Slack and Microsoft Teams.`, - logo_url: 'rocketchat.svg', - name: 'Rocket.Chat', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/rocketchat/', - title: 'Deploy Rocket.Chat through the Linode Marketplace', - }, - ], - summary: 'Feature-rich self-hosted chat and collaboration platform.', - website: 'https://docs.rocket.chat/', - }, - { - alt_description: 'Ruby web application framework with development tools.', - alt_name: 'Web application framework', - categories: ['Development'], - colors: { - end: 'fa9999', - start: '722b20', - }, - description: `Rails is a web application development framework written in the Ruby programming language. It is designed to make programming web applications easier by giving every developer a number of common tools they need to get started. Ruby on Rails empowers you to accomplish more with less code.`, - logo_url: 'rubyonrails.svg', - name: 'Ruby on Rails', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/ruby-on-rails/', - title: 'Deploy Ruby on Rails through the Linode Marketplace', - }, - ], - summary: `Ruby on Rails is a web framework that allows web designers and developers to implement dynamic, fully featured web applications.`, - website: 'https://rubyonrails.org/', - }, - { - alt_description: 'Database low-code/no-code application builder.', - alt_name: 'Low-code application builder', - categories: ['Development'], - colors: { - end: 'ff8e42', - start: '995ad9', - }, - description: `Build applications without writing a single line of code. Saltcorn is a free platform that allows you to build an app with an intuitive point-and-click, drag-and-drop UI.`, - logo_url: 'saltcorn.svg', - name: 'Saltcorn', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/saltcorn/', - title: 'Deploy Saltcorn through the Linode Marketplace', - }, - ], - summary: 'Open source, no-code database application builder.', - website: 'https://saltcorn.com/', - }, - { - alt_description: 'A safe home for all your data.', - alt_name: - 'Spreadsheet style interface with the power of a relational database.', - categories: ['Productivity'], - colors: { - end: 'FF8000', - start: 'FF8000', - }, - description: `Self-hosted database for a variety of management projects.`, - logo_url: 'seatable.svg', - name: 'Seatable', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/seatable/', - title: 'Deploy Seatable through the Linode Marketplace', - }, - ], - summary: - 'Collaborative web interface for data backed project and process management.', - website: 'https://seatable.io/docs/?lang=auto', - }, - { - alt_description: 'Limited user, hardened SSH, Fail2Ban Linode server.', - alt_name: 'Secure server tool', - categories: ['Security'], - colors: { - end: '32363b', - start: '01b058', - }, - description: `Save time on securing your Linode by deploying an instance pre-configured with some basic security best practices: limited user account access, hardened SSH, and Fail2Ban for SSH Login Protection.`, - logo_url: 'secureyourserver.svg', - name: 'Secure Your Server', - related_guides: [ - { - href: 'https://www.linode.com/docs/guides/set-up-and-secure/', - title: 'Securing your Server', - }, - ], - summary: `Harden your Linode before you deploy with the Secure Your Server One-Click App.`, - }, - { - alt_description: 'Host multiple sites on a Linode.', - alt_name: 'Website control panel', - categories: ['Control Panels'], - colors: { - end: 'a25c57', - start: '4c3148', - }, - description: `Host multiple sites on a single server while managing apps, firewall, databases, backups, system users, cron jobs, SSL and emailā€“ all in an intuitive interface.`, - logo_url: 'serverwand.svg', - name: 'ServerWand', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/serverwand/', - title: 'Deploy ServerWand through the Linode Marketplace', - }, - ], - summary: - 'Magical control panel for hosting websites and managing your servers.', - website: 'https://serverwand.com/', - }, - { - alt_description: 'Secure SOCKS5 web proxy with data encryption.', - alt_name: 'VPN proxy', - categories: ['Security'], - colors: { - end: '8d8d8d', - start: '227dc0', - }, - description: - 'Shadowsocks is a lightweight SOCKS5 web proxy tool. A full setup requires a Linode server to host the Shadowsocks daemon, and a client installed on PC, Mac, Linux, or a mobile device. Unlike other proxy software, Shadowsocks traffic is designed to be both indiscernible from other traffic to third-party monitoring tools, and also able to disguise itself as a normal direct connection. Data passing through Shadowsocks is encrypted for additional security and privacy.', - logo_url: 'shadowsocks.svg', - name: 'Shadowsocks', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/shadowsocks/', - title: 'Deploy Shadowsocks through the Linode Marketplace', - }, - ], - summary: - 'A secure socks5 proxy, designed to protect your Internet traffic.', - website: 'https://shadowsocks.org/', - }, - { - alt_description: 'Data security, data observability, data automation.', - alt_name: 'Data management', - categories: ['Development'], - colors: { - end: 'ed0181', - start: 'f89f24', - }, - description: `Popular data-to-everything platform with advanced security, observability, and automation features for machine learning and AI.`, - logo_url: 'splunk.svg', - name: 'Splunk', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/splunk/', - title: 'Deploy Splunk through the Linode Marketplace', - }, - ], - summary: - 'All-in-one database deployment, management, and monitoring system.', - website: 'https://docs.splunk.com/Documentation/Splunk', - }, - { - alt_description: 'A private by design messaging platform.', - alt_name: 'Anonymous messaging platform.', - categories: ['Productivity'], - colors: { - end: '70f0f9', - start: '11182f', - }, - description: `SimpleX Chat - The first messaging platform that has no user identifiers of any kind - 100% private by design. SMP server is the relay server used to pass messages in SimpleX network. XFTP is a new file transfer protocol focussed on meta-data protection. This One-Click APP will deploy both SMP and XFTP servers.`, - logo_url: 'simplexchat.svg', - name: 'SimpleX Chat', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/simplex/', - title: 'Deploy SimpleX chat through the Linode Marketplace', - }, - ], - summary: 'Private by design messaging server.', - website: 'https://simplex.chat', - }, - { - alt_description: - 'A simple SQL interface to store and search unstructured data.', - alt_name: 'SuperinsightDB', - categories: ['Databases'], - colors: { - end: 'C54349', - start: 'E6645F', - }, - description: `Superinsight provides a simple SQL interface to store and search unstructured data. Superinsight is built on top of PostgreSQL to take advantage of powerful extensions and features, plus the ability to run machine learning operations using SQL statements.`, - logo_url: 'superinsight.svg', - name: 'Superinsight', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/superinsight/', - title: 'Deploy Superinsight through the Linode Marketplace', - }, - ], - summary: 'Relational database for unstructured data.', - website: 'https://www.superinsight.ai/', - }, - { - alt_description: - 'Infrastructure monitoring and aler alternative to Uptime Robot.', - alt_name: 'Infrastructure monitoring', - categories: ['Monitoring'], - colors: { - end: 'baecca', - start: '67de92', - }, - description: `Uptime Kuma is self-hosted alternative to Uptime Robot. Get real-time performance insights for HTTP(s), TCP/ HTTP(s) Keyword, Ping, DNS Record, and more. Monitor everything you need in one UI dashboard, or customize how you receive alerts with a wide range of supported integrations.`, - logo_url: 'uptimekuma.svg', - name: 'Uptime Kuma', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/uptime-kuma/', - title: 'Deploy Uptime Kuma through the Linode Marketplace', - }, - ], - summary: 'Free, comprehensive, and ā€œfancyā€ monitoring solution.', - website: 'https://github.com/louislam/uptime-kuma', - }, - { - alt_description: 'Virtual private network.', - alt_name: 'VPN', - categories: ['Security'], - colors: { - end: '1a32b1', - start: '2ec1cf', - }, - description: `UTunnel VPN is a robust cloud-based VPN server software solution. With UTunnel VPN, businesses could easily set up secure remote access to their business network. UTunnel comes with a host of business-centric features including site-to-site connectivity, single sign-on integration, 2-factor authentication, etc.`, - logo_url: 'utunnel.svg', - name: 'UTunnel VPN', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/utunnel/', - title: 'Deploy UTunnel VPN through the Linode Marketplace', - }, - ], - summary: - 'A powerful, user-friendly Virtual Private Network (VPN) server application that supports multiple VPN protocols.', - website: 'https://www.utunnel.io/linode-vpn-server.html', - }, - { - alt_description: 'Time series database and database monitoring/metrics.', - alt_name: 'Database monitoring', - categories: ['Databases'], - colors: { - end: 'af3e56', - start: '6a1e6e', - }, - description: `VictoriaMetrics is designed to collect, store, and process real-time metrics.`, - logo_url: 'victoriametricssingle.svg', - name: 'VictoriaMetrics Single', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/victoriametrics-single/', - title: 'Deploy VictoriaMetrics Single through the Linode Marketplace', - }, - ], - summary: - 'Free and open source time series database (TSDB) and monitoring solution.', - website: 'https://victoriametrics.com/', - }, - { - alt_description: 'Fancy development text editor.', - alt_name: 'Text editor', - categories: ['Development'], - colors: { - end: '0066b8', - start: '23a9f2', - }, - description: `Launch a portable development environment to speed up tests, downloads, and more.`, - logo_url: 'vscodeserver.svg', - name: 'VS Code Server', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/vscode/', - title: 'Deploy VS Code through the Linode Marketplace', - }, - ], - summary: 'Run VS code in the cloud, right from your browser.', - website: 'https://github.com/cdr/code-server', - }, - { - alt_description: 'Virtual private network.', - alt_name: 'WireGuard VPN', - categories: ['Security'], - colors: { - end: '333333', - start: '1f76b7', - }, - description: `Feature-rich, self-hosted VPN based on WireGuardĀ® protocol, plus convenient features like single sign-on, real-time bandwidth monitoring, and unlimited users/devices.`, - logo_url: 'warpspeed.svg', - name: 'WarpSpeed', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/warpspeed/', - title: 'Deploy WarpSpeed VPN through the Linode Marketplace', - }, - ], - summary: 'Secure low-latency VPN powered by WireGuardĀ® protocol.', - website: 'https://bunker.services/products/warpspeed', - }, - { - alt_description: - 'Security analytics for intrusion attempts and user action monitoring.', - alt_name: 'Security monitoring', - categories: ['Security'], - colors: { - end: 'ffb600', - start: '00a9e5', - }, - description: `Infrastructure monitoring solution to detect threats, intrusion attempts, unauthorized user actions, and provide security analytics.`, - logo_url: 'wazuh.svg', - name: 'Wazuh', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/wazuh/', - title: 'Deploy Wazuh through the Linode Marketplace', - }, - ], - summary: 'Free open source security monitoring solution.', - website: 'https://documentation.wazuh.com/current/index.html', - }, - { - alt_description: - 'Control panel to deploy and manage LAMP stack applications.', - alt_name: 'Single user control panel', - categories: ['Control Panels'], - colors: { - end: '445289', - start: 'f1b55d', - }, - description: `Lightweight control panel with a suite of features to streamline app management.`, - logo_url: 'webuzo.svg', - name: 'Webuzo', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/webuzo/', - title: 'Deploy Webuzo through the Linode Marketplace', - }, - ], - summary: - 'LAMP stack and single user control panel to simplify app deployment in the cloud.', - website: 'http://www.webuzo.com/', - }, - { - alt_description: 'Virtual private network.', - alt_name: 'Free VPN', - categories: ['Security'], - colors: { - end: '51171a', - start: '88171a', - }, - description: `Configuring WireGuard® is as simple as configuring SSH. A connection is established by an exchange of public keys between server and client, and only a client whose public key is present in the server's configuration file is considered authorized. WireGuard sets up - standard network interfaces which behave similarly to other common network interfaces, like eth0. This makes it possible to configure and manage WireGuard interfaces using standard networking tools such as ifconfig and ip. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, - logo_url: 'wireguard.svg', - name: 'WireGuard®', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/wireguard/', - title: 'Deploy WireGuard through the Linode Marketplace', - }, - ], - summary: `Modern VPN which utilizes state-of-the-art cryptography. It aims to be faster and leaner than other VPN protocols and has a smaller source code footprint.`, - website: 'https://www.wireguard.com/', - }, - { - alt_description: 'Popular secure WordPress ecommerce online store plugin.', - alt_name: 'Ecommerce site', - categories: ['Website'], - colors: { - end: '743b8a', - start: '96588a', - }, - description: `With WooCommerce, you can securely sell both digital and physical goods, and take payments via major credit cards, bank transfers, PayPal, and other providers like Stripe. With more than 300 extensions to choose from, WooCommerce is extremely flexible.`, - logo_url: 'woocommerce.svg', - name: 'WooCommerce', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/woocommerce/', - title: 'Deploy WooCommerce through the Linode Marketplace', - }, - ], - summary: `Highly customizable, secure, open source eCommerce platform built to integrate with Wordpress.`, - website: 'https://woocommerce.com/features/', - }, - { - alt_description: 'Popular website content management system.', - alt_name: 'CMS: content management system', - categories: ['Website'], - colors: { - end: '135478', - start: '176086', - }, - description: `With 60 million users around the globe, WordPress is the industry standard for custom websites such as blogs, news sites, personal websites, and anything in-between. With a focus on best in class usability and flexibility, you can have a customized website up and running in minutes.`, - logo_url: 'wordpress.svg', - name: 'WordPress', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/wordpress/', - title: 'Deploy WordPress through the Linode Marketplace', - }, - ], - summary: - 'Flexible, open source content management system (CMS) for content-focused websites of any kind.', - website: 'https://wordpress.org/', - }, - { - alt_description: 'Web interface for managing Docker containers.', - alt_name: 'Docker GUI', - categories: ['Development'], - colors: { - end: 'c4c4c4', - start: '41b883', - }, - description: `Simplify Docker deployments and make containerization easy for anyone to use. Please note: Yacht is still in alpha and is not recommended for production use.`, - logo_url: 'yacht.svg', - name: 'Yacht', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/yacht/', - title: 'Deploy Yacht through the Linode Marketplace', - }, - ], - summary: 'Intuitive web interface for managing Docker containers.', - website: 'https://github.com/SelfhostedPro/Yacht/', - }, - { - alt_description: 'Enterprise infrastructure and IT resource montioring.', - alt_name: 'Infrastructure monitoring', - categories: ['Monitoring'], - colors: { - end: '252730', - start: 'd40000', - }, - description: `Monitor, track performance and maintain availability for network servers, devices, services and other IT resourcesā€“ all in one tool.`, - logo_url: 'zabbix.svg', - name: 'Zabbix', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/zabbix/', - title: 'Deploy Zabbix through the Linode Marketplace', - }, - ], - summary: 'Enterprise-class open source distributed monitoring solution.', - website: 'https://www.zabbix.com', - }, - { - ...oneClickAppFactory.build({ - name: 'E2E Test App', - }), - }, -]; +/** + * @deprecated See oneClickAppsv2.ts + */ +export const oneClickApps: OCA[] = Object.values(newOneClickApps); diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts new file mode 100644 index 00000000000..2999f25419f --- /dev/null +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -0,0 +1,2505 @@ +import { oneClickAppFactory } from 'src/factories/stackscripts'; + +import type { OCA } from './types'; + +/** + * This object maps a StackScript ID to additional information. + * + * A marketplace app must be listed here with the correct ID + * for it to be visible to users. + */ +export const oneClickApps: Record = { + 0: { + ...oneClickAppFactory.build({ + name: 'E2E Test App', + }), + }, + 401697: { + alt_description: 'Popular website content management system.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '135478', + start: '176086', + }, + description: `With 60 million users around the globe, WordPress is the industry standard for custom websites such as blogs, news sites, personal websites, and anything in-between. With a focus on best in class usability and flexibility, you can have a customized website up and running in minutes.`, + logo_url: 'wordpress.svg', + name: 'WordPress', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wordpress/', + title: 'Deploy WordPress through the Linode Marketplace', + }, + ], + summary: + 'Flexible, open source content management system (CMS) for content-focused websites of any kind.', + website: 'https://wordpress.org/', + }, + 401698: { + alt_description: 'Secure website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '1b64a5', + start: '0678be', + }, + description: `Drupal is a content management system (CMS) designed for building custom websites for personal and business use. Built for high performance and scalability, Drupal provides the necessary tools to create rich, interactive community websites with forums, user blogs, and private messaging. Drupal also has support for personal publishing projects and can power podcasts, blogs, and knowledge-based systems, all within a single, unified platform.`, + logo_url: 'drupal.svg', + name: 'Drupal', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/drupal/', + title: 'Deploy Drupal through the Linode Marketplace', + }, + ], + summary: `Powerful content management system built on PHP and supported by a database engine.`, + website: 'https://www.drupal.org/', + }, + 401701: { + alt_description: 'Essential software stack for Linux applications.', + alt_name: 'Web stack', + categories: ['Stacks'], + colors: { + end: 'bfa477', + start: '3c4043', + }, + description: `The LAMP stack consists of the Linux operating system, the Apache HTTP Server, the MySQL relational database management system, and the PHP programming language. This software environment is a foundation for popular PHP application + frameworks like WordPress, Drupal, and Laravel. Upload your existing PHP application code to your new app or use a PHP framework to write a new application on the Linode.`, + logo_url: 'lamp.svg', + name: 'LAMP', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/lamp-stack/', + title: 'Deploy a LAMP Stack through the Linode Marketplace', + }, + ], + summary: `Build PHP-based applications with the LAMP software stack: Linux, Apache, MySQL, and PHP.`, + }, + 401702: { + alt_description: 'React and Node.js stack.', + alt_name: 'Web stack', + categories: [], + colors: { + end: '256291', + start: '30383a', + }, + description: `MERN is a full stack platform that contains everything you need to build a web application: MongoDB, a document database used to persist your application's data; Express, which serves as the web application framework; React, used to build your application's user interfaces; + and Node.js, which serves as the run-time environment for your application. All of these technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications. Upload your + existing MERN website code to your new Linode, or use MERN's scaffolding tool to start writing new web applications on the Linode.`, + logo_url: 'mern.svg', + name: 'MERN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mern-stack/', + title: 'Deploy a MERN Stack through the Linode Marketplace', + }, + ], + summary: `Build production-ready apps with the MERN stack: MongoDB, Express, React, and Node.js.`, + }, + 401706: { + alt_description: 'Virtual private network.', + alt_name: 'Free VPN', + categories: ['Security'], + colors: { + end: '51171a', + start: '88171a', + }, + description: `Configuring WireGuard® is as simple as configuring SSH. A connection is established by an exchange of public keys between server and client, and only a client whose public key is present in the server's configuration file is considered authorized. WireGuard sets up + standard network interfaces which behave similarly to other common network interfaces, like eth0. This makes it possible to configure and manage WireGuard interfaces using standard networking tools such as ifconfig and ip. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, + logo_url: 'wireguard.svg', + name: 'WireGuard®', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wireguard/', + title: 'Deploy WireGuard through the Linode Marketplace', + }, + ], + summary: `Modern VPN which utilizes state-of-the-art cryptography. It aims to be faster and leaner than other VPN protocols and has a smaller source code footprint.`, + website: 'https://www.wireguard.com/', + }, + 401707: { + alt_description: 'Popular Git management tool.', + alt_name: 'Git repository hosting', + categories: ['Development'], + colors: { + end: '21153e', + start: '48357d', + }, + description: `GitLab is a complete solution for all aspects of your software development. At its core, GitLab serves as your centralized Git repository. GitLab also features built-in tools that represent every task in your development workflow, from planning to testing to releasing. + Self-hosting your software development with GitLab offers total control of your codebase. At the same time, its familiar interface will ease collaboration for you and your team. GitLab is the most popular self-hosted Git repository, so you'll benefit from a robust set of integrated tools and an active community.`, + logo_url: 'gitlab.svg', + name: 'GitLab', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gitlab/', + title: 'Deploy GitLab through the Linode Marketplace', + }, + ], + summary: + 'More than a self-hosted Git repository: use GitLab to manage all the stages of your DevOps life cycle.', + website: 'https://about.gitlab.com/', + }, + 401708: { + alt_description: 'Popular secure WordPress ecommerce online store plugin.', + alt_name: 'Ecommerce site', + categories: ['Website'], + colors: { + end: '743b8a', + start: '96588a', + }, + description: `With WooCommerce, you can securely sell both digital and physical goods, and take payments via major credit cards, bank transfers, PayPal, and other providers like Stripe. With more than 300 extensions to choose from, WooCommerce is extremely flexible.`, + logo_url: 'woocommerce.svg', + name: 'WooCommerce', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/woocommerce/', + title: 'Deploy WooCommerce through the Linode Marketplace', + }, + ], + summary: `Highly customizable, secure, open source eCommerce platform built to integrate with Wordpress.`, + website: 'https://woocommerce.com/features/', + }, + 401709: { + alt_description: 'Classic open world survival crafting game.', + alt_name: 'World building game', + categories: ['Games'], + colors: { + end: 'd0c8c4', + start: '97948f', + }, + description: `With over 100 million users around the world, Minecraft is the most popular online game of all time. Less of a game and more of a lifestyle choice, you and other players are free to build and explore in a 3D generated world made up of millions of mineable blocks. Collect resources by leveling mountains, + taming forests, and venturing out to sea. Choose a home from the varied list of biomes like ice worlds, flower plains, and jungles. Build ancient castles or modern mega cities, and fill them with redstone circuit contraptions and villagers. Fight off nightly invasions of Skeletons, Zombies, and explosive + Creepers, or adventure to the End and the Nether to summon the fabled End Dragon and the chaotic Wither. If that is not enough, Minecraft is also highly moddable and customizable. You decide the rules when hosting your own Minecraft server for you and your friends to play together in this highly addictive game.`, + logo_url: 'minecraft.svg', + name: 'Minecraft: Java Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/minecraft/', + title: 'Deploy a Minecraft Server through the Linode Marketplace', + }, + ], + summary: `Build, explore, and adventure in your own 3D generated world.`, + website: 'https://www.minecraft.net/', + }, + 401719: { + alt_description: 'Popular virtual private network.', + alt_name: 'Free VPN', + categories: ['Security'], + colors: { + end: '193766', + start: 'ea7e20', + }, + description: `OpenVPN is a widely trusted, free, and open-source virtual private network application. OpenVPN creates network tunnels between groups of computers that are not on the same local network, and it uses OpenSSL to encrypt your traffic.`, + logo_url: 'openvpn.svg', + name: 'OpenVPN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openvpn/', + title: 'Deploy OpenVPN through the Linode Marketplace', + }, + ], + summary: `Open-source virtual private network (VPN) application. OpenVPN securely connects your computer to your servers, or to the public Internet.`, + website: 'https://openvpn.net/', + }, + 593835: { + alt_description: 'Popular WordPress server management.', + alt_name: 'WordPress control panel', + categories: ['Control Panels'], + colors: { + end: '4b5868', + start: '53bce6', + }, + description: `Plesk is a leading WordPress and website management platform and control panel. Plesk lets you build and manage multiple websites from a single dashboard to configure web services, email, and other applications. Plesk features hundreds of extensions, plus a complete WordPress toolkit. Use the Plesk One-Click App to manage websites hosted on your Linode.`, + logo_url: 'plesk.svg', + name: 'Plesk', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/plesk/', + title: 'Deploy Plesk through the Linode Marketplace', + }, + ], + summary: + 'A secure, scalable, and versatile website and WordPress management platform.', + website: 'https://www.plesk.com/', + }, + 595742: { + alt_description: + 'Linux-based web hosting control panel for managing websites, servers, databases, and more.', + alt_name: 'Web server automation and control panel', + categories: ['Control Panels'], + colors: { + end: '141d25', + start: 'ff6c2c', + }, + description: `The cPanel & WHM® Marketplace App streamlines publishing and managing a website on your Linode. cPanel & WHM is a Linux® based web hosting control panel and platform that helps you create and manage websites, servers, databases and more with a suite of hosting automation and optimization tools.`, + logo_url: 'cpanel.svg', + name: 'cPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cpanel/', + title: 'Deploy cPanel through the Linode Marketplace', + }, + ], + summary: + 'The leading hosting automation platform that has simplified site and server management for 20 years.', + website: 'https://www.cpanel.net/', + }, + 604068: { + alt_description: 'Secure SOCKS5 web proxy with data encryption.', + alt_name: 'VPN proxy', + categories: ['Security'], + colors: { + end: '8d8d8d', + start: '227dc0', + }, + description: + 'Shadowsocks is a lightweight SOCKS5 web proxy tool. A full setup requires a Linode server to host the Shadowsocks daemon, and a client installed on PC, Mac, Linux, or a mobile device. Unlike other proxy software, Shadowsocks traffic is designed to be both indiscernible from other traffic to third-party monitoring tools, and also able to disguise itself as a normal direct connection. Data passing through Shadowsocks is encrypted for additional security and privacy.', + logo_url: 'shadowsocks.svg', + name: 'Shadowsocks', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/shadowsocks/', + title: 'Deploy Shadowsocks through the Linode Marketplace', + }, + ], + summary: + 'A secure socks5 proxy, designed to protect your Internet traffic.', + website: 'https://shadowsocks.org/', + }, + 606691: { + alt_description: 'Essential software stack for Linux applications.', + alt_name: 'Web stack', + categories: ['Stacks'], + colors: { + end: '005138', + start: '2e7d32', + }, + description: `LEMP provides a platform for applications that is compatible with the LAMP stack for nearly all applications; however, because NGINX is able to serve more pages at once with a more predictable memory usage profile, it may be more suited to high demand situations.`, + logo_url: 'lemp.svg', + name: 'LEMP', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/lemp-stack/', + title: 'Deploy a LEMP Stack through the Linode Marketplace', + }, + ], + summary: `The LEMP stack replaces the Apache web server component with NGINX (ā€œEngine-Xā€), providing the E in the acronym: Linux, NGINX, MySQL/MariaDB, PHP.`, + }, + 607026: { + alt_description: 'SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '8a9177', + start: '1d758f', + }, + description: `MySQL, or MariaDB for Linux distributions, is primarily used for web and server applications, including as a component of the industry-standard LAMP and LEMP stacks.`, + logo_url: 'mysql.svg', + name: 'MySQL/MariaDB', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mysql/', + title: 'Deploy MySQL/MariaDB through the Linode Marketplace', + }, + ], + summary: `World's most popular open source database.`, + website: 'https://www.mysql.com/', + }, + 607401: { + alt_description: 'CI/CD tool to delegate automation tasks and jobs.', + alt_name: 'Free automation tool', + categories: ['Development'], + colors: { + end: 'd24939', + start: 'd33833', + }, + description: `Jenkins is an open source automation tool which can build, test, and deploy your infrastructure.`, + logo_url: 'jenkins.svg', + name: 'Jenkins', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jenkins/', + title: 'Deploy Jenkins through the Linode Marketplace', + }, + ], + summary: `A tool that gives you access to a massive library of plugins to support automation in your project's lifecycle.`, + website: 'https://jenkins.io/', + }, + 607433: { + alt_description: + 'Popular container tool to build cloud-native applications.', + alt_name: 'Container builder', + categories: ['Development'], + colors: { + end: '1e65c9', + start: '2496ed', + }, + description: `Docker is a tool that enables you to create, deploy, and manage lightweight, stand-alone packages that contain everything needed to run an application (code, libraries, runtime, system settings, and dependencies).`, + logo_url: 'docker.svg', + name: 'Docker', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/docker/', + title: 'Deploy Docker through the Linode Marketplace', + }, + ], + summary: `Securely build, share and run modern applications anywhere.`, + website: 'https://www.docker.com/', + }, + 607488: { + alt_description: 'In-memory caching database.', + alt_name: 'High performance database', + categories: ['Databases'], + colors: { + end: '722b20', + start: '222222', + }, + description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

    *Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, + logo_url: 'redis.svg', + name: 'Marketplace App for Redis®', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/redis/', + title: 'Deploy Redis® through the Linode Marketplace', + }, + ], + summary: + 'Flexible, in-memory, NoSQL database service supported in many different coding languages.', + website: 'https://redis.io/', + }, + 609018: { + alt_description: + 'Web interface for MySQL/MariaDB operations and server administration.', + alt_name: 'SQL database GUI', + categories: ['Databases'], + colors: { + end: '6c78af', + start: 'f89d10', + }, + description: `Intuitive web interface for MySQL and MariaDB operations, including importing/exporting data, administering multiple servers, and global database search.`, + logo_url: 'phpmyadmin.svg', + name: 'phpMyAdmin', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/phpmyadmin/', + title: 'Deploy phpMyAdmin through the Linode Marketplace', + }, + ], + summary: 'Popular free administration tool for MySQL and MariaDB.', + website: 'https://www.phpmyadmin.net/', + }, + 609048: { + alt_description: 'Ruby web application framework with development tools.', + alt_name: 'Web application framework', + categories: ['Development'], + colors: { + end: 'fa9999', + start: '722b20', + }, + description: `Rails is a web application development framework written in the Ruby programming language. It is designed to make programming web applications easier by giving every developer a number of common tools they need to get started. Ruby on Rails empowers you to accomplish more with less code.`, + logo_url: 'rubyonrails.svg', + name: 'Ruby on Rails', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/ruby-on-rails/', + title: 'Deploy Ruby on Rails through the Linode Marketplace', + }, + ], + summary: `Ruby on Rails is a web framework that allows web designers and developers to implement dynamic, fully featured web applications.`, + website: 'https://rubyonrails.org/', + }, + 609175: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '136149', + start: '0a2e1f', + }, + description: `Django is a web development framework for the Python programming language. It enables rapid development, while favoring pragmatic and clean design.`, + logo_url: 'django.svg', + name: 'Django', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/django/', + title: 'Deploy Django through the Linode Marketplace', + }, + ], + summary: `A framework for simplifying the process of building your web applications more quickly and with less code.`, + website: 'https://www.djangoproject.com/', + }, + 609392: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '1e2122', + start: '363b3d', + }, + description: `Flask is a lightweight WSGI web application framework written in Python. It is designed to make getting started quick and easy, with the ability to scale up to complex applications.`, + logo_url: 'flask.svg', + name: 'Flask', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/flask/', + title: 'Deploy Flask through the Linode Marketplace', + }, + ], + summary: `A quick light-weight web framework for Python that includes several utilities and libraries you can use to create a web application.`, + website: 'https://www.palletsprojects.com/p/flask/', + }, + 611376: { + alt_description: 'MySQL alternative for SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '254078', + start: '326690', + }, + description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your databaseā€™s performance in a production environment.`, + logo_url: 'postgresql.svg', + name: 'PostgreSQL', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/postgresql/', + title: 'Deploy PostgreSQL through the Linode Marketplace', + }, + ], + summary: `The PostgreSQL relational database system is a powerful, scalable, and standards-compliant open-source database platform.`, + website: 'https://www.postgresql.org/', + }, + 611895: { + alt_description: 'Angular and Node.js stack.', + alt_name: 'Web framework', + categories: ['Development'], + colors: { + end: '686868', + start: '323232', + }, + description: `MEAN is a full-stack JavaScript-based framework which accelerates web application development much faster than other frameworks. All involved technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications.`, + logo_url: 'mean.svg', + name: 'MEAN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mean-stack/', + title: 'Deploy a MEAN Stack through the Linode Marketplace', + }, + ], + summary: `A MEAN (MongoDB, Express, Angular, Node.js) stack is a free and open-source web software bundle used to build modern web applications.`, + website: 'http://meanjs.org/', + }, + 632758: { + alt_description: + 'File storage alternative to Dropbox and office suite alternative to Microsoft Office.', + alt_name: 'File storage management & business tool suite', + categories: ['Productivity'], + colors: { + end: '2a2a36', + start: '16a5f3', + }, + description: `Nextcloud AIO stands for Nextcloud All In One, and provides easy deployment and maintenance for popular Nextcloud tools. AIO includes Nextcloud, Nextcloud Office, OnlyOffice, and high-performance backend features.`, + logo_url: 'nextcloud.svg', + name: 'Nextcloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nextcloud/', + title: 'Deploy Nextcloud through the Linode Marketplace', + }, + ], + summary: `A safe home for all your data.`, + }, + 662118: { + alt_description: 'Free internet radio station management and hosting.', + alt_name: 'Online radio station builder', + categories: ['Media and Entertainment'], + colors: { + end: '0b1b64', + start: '1f8df5', + }, + description: `All aspects of running a radio station in one web interface so you can start your own station. Manage media, create playlists, and interact with listeners on one free platform.`, + logo_url: 'azuracast.svg', + name: 'Azuracast', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/azuracast/', + title: 'Deploy AzuraCast through the Linode Marketplace', + }, + ], + summary: 'Open source, self-hosted web radio tool.', + website: 'https://www.azuracast.com/', + }, + 662119: { + alt_description: + 'Video / media library storage and sharing across TVs, phones, computers, and more.', + alt_name: 'Media server', + categories: [], + colors: { + end: '332c37', + start: 'e5a00d', + }, + description: `Organize, stream, and share your media library with friends, in addition to free live TV in 220+ countries.`, + logo_url: 'plex.svg', + name: 'Plex', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/plex/', + title: 'Deploy Plex Media Server through the Linode Marketplace', + }, + ], + summary: + 'Media server and streaming service to stay entertained across devices.', + website: 'https://www.plex.tv/', + }, + 662121: { + alt_description: 'Open source video conferencing alternative to Zoom.', + alt_name: 'Video chat and video conferencing', + categories: ['Media and Entertainment'], + colors: { + end: '949699', + start: '1d76ba', + }, + description: `Secure, stable, and free alternative to popular video conferencing services. Use built-in features to limit meeting access with passwords or stream on YouTube so anyone can attend.`, + logo_url: 'jitsi.svg', + name: 'Jitsi', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi/', + title: 'Deploy Jitsi through the Linode Marketplace', + }, + ], + summary: 'Free, open source video conferencing and communication platform.', + website: 'https://jitsi.org/', + }, + 688890: { + alt_description: 'Server work queue management.', + alt_name: 'Message broker', + categories: ['Development'], + colors: { + end: 'ff6600', + start: 'a9b5af', + }, + description: `Connect and scale applications with asynchronous messaging and highly available work queues, all controlled through an intuitive management UI.`, + logo_url: 'rabbitmq.svg', + name: 'RabbitMQ', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/rabbitmq/', + title: 'Deploy RabbitMQ through the Linode Marketplace', + }, + ], + summary: 'Most popular open source message broker.', + website: 'https://www.rabbitmq.com/', + }, + 688891: { + alt_description: 'Open source community forum alternative to Reddit.', + alt_name: 'Chat forum', + categories: ['Media and Entertainment'], + colors: { + end: 'eae692', + start: '13b3ed', + }, + description: `Launch a sleek forum with robust integrations to popular tools like Slack and WordPress to start more conversations.`, + logo_url: 'discourse.svg', + name: 'Discourse', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/discourse/', + title: 'Deploy Discourse through the Linode Marketplace', + }, + ], + summary: + 'Open source community and discussion forum for customers, teams, fans, and more.', + website: 'https://www.discourse.org/', + }, + 688902: { + alt_description: + 'Control panel to deploy and manage LAMP stack applications.', + alt_name: 'Single user control panel', + categories: ['Control Panels'], + colors: { + end: '445289', + start: 'f1b55d', + }, + description: `Lightweight control panel with a suite of features to streamline app management.`, + logo_url: 'webuzo.svg', + name: 'Webuzo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/webuzo/', + title: 'Deploy Webuzo through the Linode Marketplace', + }, + ], + summary: + 'LAMP stack and single user control panel to simplify app deployment in the cloud.', + website: 'http://www.webuzo.com/', + }, + 688903: { + alt_description: 'Fancy development text editor.', + alt_name: 'Text editor', + categories: ['Development'], + colors: { + end: '0066b8', + start: '23a9f2', + }, + description: `Launch a portable development environment to speed up tests, downloads, and more.`, + logo_url: 'vscodeserver.svg', + name: 'VS Code Server', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/vscode/', + title: 'Deploy VS Code through the Linode Marketplace', + }, + ], + summary: 'Run VS code in the cloud, right from your browser.', + website: 'https://github.com/cdr/code-server', + }, + 688911: { + alt_description: 'Open source, self-hosted Git management tool.', + alt_name: 'Git repository hosting', + categories: ['Development'], + colors: { + end: '34495e', + start: '609926', + }, + description: `Self-hosted Git service built and maintained by a large developer community.`, + logo_url: 'gitea.svg', + name: 'Gitea', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gitea/', + title: 'Deploy Gitea through the Linode Marketplace', + }, + ], + summary: 'Git with a cup of tea - A painless self-hosted Git service.', + website: 'https://gitea.io/', + }, + 688912: { + alt_description: 'Drag and drop website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '4395ff', + start: '0166ff', + }, + description: `Use Kepler Builder to easily design and build sites in WordPress - no coding or design knowledge necessary.`, + logo_url: 'keplerbuilder.svg', + name: 'Kepler Builder', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/kepler/', + title: 'Deploy Kepler through the Linode Marketplace', + }, + ], + summary: 'Powerful drag & drop WordPress website builder.', + website: 'https://kepler.app/', + }, + 688914: { + alt_description: 'Desktop cloud hosting.', + alt_name: 'Virtual desktop', + categories: ['Development'], + colors: { + end: '213121', + start: '304730', + }, + description: `Access your desktop from any device with a browser to keep your desktop hosted in the cloud.`, + logo_url: 'guacamole.svg', + name: 'Guacamole', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/guacamole/', + title: 'Deploy Apache Guacamole through the Linode Marketplace', + }, + ], + summary: 'Free open source clientless remote desktop gateway.', + website: 'https://guacamole.apache.org/', + }, + 691620: { + alt_description: 'File storage alternative to Dropbox and Google Drive.', + alt_name: 'File sharing', + categories: ['Productivity'], + colors: { + end: '0168ad', + start: '3e8cc1', + }, + description: `File synchronization across multiple usersā€™ computers and other devices to keep everyone working without interruption.`, + logo_url: 'filecloud.svg', + name: 'FileCloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/filecloud/', + title: 'Deploy FileCloud through the Linode Marketplace', + }, + ], + summary: 'Enterprise file sharing to manage and sync from any device.', + website: 'https://www.getfilecloud.com', + }, + 691621: { + alt_description: + 'Host multiple apps on one server and control panel, including WordPress, GitLab, and Nextcloud.', + alt_name: 'Cloud app and website control panel', + categories: ['Website'], + colors: { + end: '212121', + start: '03a9f4', + }, + description: `Turnkey solution for running apps like WordPress, Rocket.Chat, NextCloud, GitLab, and OpenVPN.`, + logo_url: 'cloudron.svg', + name: 'Cloudron', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cloudron/', + title: 'Deploy Cloudron through the Linode Marketplace', + }, + ], + summary: + 'End-to-end deployment and automatic updates for a range of essential applications.', + website: 'https://docs.cloudron.io', + }, + 691622: { + alt_description: 'Popular website content management system.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '3d596d', + start: '33cccc', + }, + description: `Accelerated and scalable hosting for WordPress. Includes OpenLiteSpeed, PHP, MySQL Server, WordPress, and LiteSpeed Cache.`, + logo_url: 'openlitespeedwordpress.svg', + name: 'OpenLiteSpeed WordPress', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-wordpress/', + title: 'Deploy OpenLiteSpeed Wordpress through the Linode Marketplace', + }, + ], + summary: 'Blazing fast, open source alternative to LiteSpeed Web Server.', + website: 'https://openlitespeed.org/', + }, + 692092: { + alt_description: 'Limited user, hardened SSH, Fail2Ban Linode server.', + alt_name: 'Secure server tool', + categories: ['Security'], + colors: { + end: '32363b', + start: '01b058', + }, + description: `Save time on securing your Linode by deploying an instance pre-configured with some basic security best practices: limited user account access, hardened SSH, and Fail2Ban for SSH Login Protection.`, + logo_url: 'secureyourserver.svg', + name: 'Secure Your Server', + related_guides: [ + { + href: 'https://www.linode.com/docs/guides/set-up-and-secure/', + title: 'Securing your Server', + }, + ], + summary: `Harden your Linode before you deploy with the Secure Your Server One-Click App.`, + }, + 741206: { + alt_description: + 'Web hosting control panel for managing websites, including WordPress.', + alt_name: 'Web hosting control panel', + categories: ['Control Panels'], + colors: { + end: '33cccc', + start: '3d596d', + }, + description: `Reduce setup time required to host websites and applications, including popular tools like OpenLiteSpeed WordPress.`, + logo_url: 'cyberpanel.svg', + name: 'CyberPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cyberpanel/', + title: 'Deploy CyberPanel through the Linode Marketplace', + }, + ], + summary: 'Next-generation hosting control panel by OpenLiteSpeed.', + website: 'https://docs.litespeedtech.com/cloud/images/cyberpanel/', + }, + 741207: { + alt_description: 'Web interface for managing Docker containers.', + alt_name: 'Docker GUI', + categories: ['Development'], + colors: { + end: 'c4c4c4', + start: '41b883', + }, + description: `Simplify Docker deployments and make containerization easy for anyone to use. Please note: Yacht is still in alpha and is not recommended for production use.`, + logo_url: 'yacht.svg', + name: 'Yacht', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/yacht/', + title: 'Deploy Yacht through the Linode Marketplace', + }, + ], + summary: 'Intuitive web interface for managing Docker containers.', + website: 'https://github.com/SelfhostedPro/Yacht/', + }, + 741208: { + alt_description: 'Enterprise infrastructure and IT resource montioring.', + alt_name: 'Infrastructure monitoring', + categories: ['Monitoring'], + colors: { + end: '252730', + start: 'd40000', + }, + description: `Monitor, track performance and maintain availability for network servers, devices, services and other IT resourcesā€“ all in one tool.`, + logo_url: 'zabbix.svg', + name: 'Zabbix', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/zabbix/', + title: 'Deploy Zabbix through the Linode Marketplace', + }, + ], + summary: 'Enterprise-class open source distributed monitoring solution.', + website: 'https://www.zabbix.com', + }, + 774829: { + alt_description: 'Host multiple sites on a Linode.', + alt_name: 'Website control panel', + categories: ['Control Panels'], + colors: { + end: 'a25c57', + start: '4c3148', + }, + description: `Host multiple sites on a single server while managing apps, firewall, databases, backups, system users, cron jobs, SSL and emailā€“ all in an intuitive interface.`, + logo_url: 'serverwand.svg', + name: 'ServerWand', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/serverwand/', + title: 'Deploy ServerWand through the Linode Marketplace', + }, + ], + summary: + 'Magical control panel for hosting websites and managing your servers.', + website: 'https://serverwand.com/', + }, + 804143: { + alt_description: 'Open source project management tool.', + alt_name: 'Ticket management project management tool', + categories: ['Productivity'], + colors: { + end: '0a0a0a', + start: '4cff4c', + }, + description: `Open source alternative to paid ticket management solutions with essential features including a streamlined task list, project and client management, and ticket prioritization.`, + logo_url: 'peppermint.svg', + name: 'Peppermint', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/peppermint/', + title: 'Deploy Peppermint through the Linode Marketplace', + }, + ], + summary: 'Simple yet scalable open source ticket management.', + website: 'https://peppermint.sh/', + }, + 804144: { + alt_description: + 'Free high-performance media streaming, including livestreaming.', + alt_name: 'Free media streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '0a0a0a', + start: 'df0718', + }, + description: `Self-hosted free version to optimize and record video streaming for webinars, gaming, and more.`, + logo_url: 'antmediaserver.svg', + name: 'Ant Media Server: Community Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/antmediaserver/', + title: 'Deploy Ant Media Server through the Linode Marketplace', + }, + ], + summary: 'A reliable, flexible and scalable video streaming solution.', + website: 'https://antmedia.io/', + }, + 804172: { + alt_description: 'Video and audio live streaming alternative to Twitch.', + alt_name: 'Live streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '2086e1', + start: '7871ff', + }, + description: `A live streaming and chat server for use with existing popular broadcasting software.`, + logo_url: 'owncast.svg', + name: 'Owncast', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/owncast/', + title: 'Deploy Owncast through the Linode Marketplace', + }, + ], + summary: + 'The standalone ā€œTwitch in a Boxā€ open source streaming and chat solution.', + website: 'https://owncast.online/', + }, + 869127: { + alt_description: 'Open source course builder and education tool.', + alt_name: 'Online course CMS', + categories: ['Website'], + colors: { + end: '494949', + start: 'ff7800', + }, + description: `Robust open-source learning platform enabling online education for more than 200 million users around the world. Create personalized learning environments within a secure and integrated system built for all education levels with an intuitive interface, drag-and-drop features, and accessible documentation.`, + logo_url: 'moodle.svg', + name: 'Moodle', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/moodle/', + title: 'Deploy Moodle through the Linode Marketplace', + }, + ], + summary: + 'Worldā€™s most popular learning management system built and maintained by an active developer community.', + website: 'https://docs.moodle.org/', + }, + 869129: { + alt_description: 'Free open source control panel with a mobile app.', + alt_name: 'Free infrastructure control panel', + categories: ['Control Panels'], + colors: { + end: 'a3a3a3', + start: '20a53a', + }, + description: `Feature-rich alternative control panel for users who need critical control panel functionality but donā€™t need to pay for more niche premium features. aaPanel is open source and consistently maintained with weekly updates.`, + logo_url: 'aapanel.svg', + name: 'aaPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/aapanel/', + title: 'Deploy aaPanel through the Linode Marketplace', + }, + ], + summary: + 'Popular open source free control panel with robust features and a mobile app.', + website: 'https://www.aapanel.com/reference.html', + }, + 869153: { + alt_description: 'Data security, data observability, data automation.', + alt_name: 'Data management', + categories: ['Development'], + colors: { + end: 'ed0181', + start: 'f89f24', + }, + description: `Popular data-to-everything platform with advanced security, observability, and automation features for machine learning and AI.`, + logo_url: 'splunk.svg', + name: 'Splunk', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/splunk/', + title: 'Deploy Splunk through the Linode Marketplace', + }, + ], + summary: + 'All-in-one database deployment, management, and monitoring system.', + website: 'https://docs.splunk.com/Documentation/Splunk', + }, + 869155: { + alt_description: + 'Image hosting and sharing alternative to Google Photos and Flickr.', + alt_name: 'Photo library and image library', + categories: ['Media and Entertainment'], + colors: { + end: '8e44ad', + start: '23a8e0', + }, + description: `Chevereto is a full-featured image sharing solution that acts as an alternative to services like Google Photos or Flickr. Optimize image hosting by using external cloud storage (like Linodeā€™s S3-compatible Object Storage) and connect to Chevereto using API keys.`, + logo_url: 'chevereto.svg', + name: 'Chevereto', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/chevereto/', + title: 'Deploy Chevereto through the Linode Marketplace', + }, + ], + summary: + 'Self-host your own open source image library to easily upload, collaborate, and share images on your terms.', + website: 'https://v3-docs.chevereto.com/', + }, + 869156: { + alt_description: + 'File storage and sharing alternative to Dropbox and Google Drive.', + alt_name: 'File sharing', + categories: ['Productivity'], + colors: { + end: '252730', + start: '1f4c8f', + }, + description: `Securely share and collaborate Linode S3 object storage files/folders with your internal or external users such as customers, partners, vendors, etc with fine access control and a simple interface. Nirvashare easily integrates with many external identity providers such as Active Directory, GSuite, AWS SSO, KeyClock, etc.`, + logo_url: 'nirvashare.svg', + name: 'NirvaShare', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nirvashare/', + title: 'Deploy NirvaShare through the Linode Marketplace', + }, + ], + summary: + 'Secure file sharing for better collaboration with employees, partners, vendors, and more.', + website: 'https://nirvashare.com/setup-guide/', + }, + 869158: { + alt_description: + 'SQL and NoSQL database interface and monitoring for MySQL, PostgreSQL, and more.', + alt_name: 'Database monitoring', + categories: ['Databases'], + colors: { + end: '3f434c', + start: '0589de', + }, + description: `All-in-one interface for scripting and monitoring databases, including MySQL, MariaDB, Percona, PostgreSQL, Galera Cluster and more. Easily deploy database instances, manage with an included CLI, and automate performance monitoring.`, + logo_url: 'clustercontrol.svg', + name: 'ClusterControl', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/clustercontrol/', + title: 'Deploy ClusterControl through the Linode Marketplace', + }, + ], + summary: + 'All-in-one database deployment, management, and monitoring system.', + website: 'https://docs.severalnines.com/docs/clustercontrol/', + }, + 869623: { + alt_description: 'Enterprise-ready backups tool.', + alt_name: 'Server backups management and control panel', + categories: ['Control Panels'], + colors: { + end: '1f2c38', + start: 'ff6c2c', + }, + description: `Powerful and customizable backups for several websites and data all in the same interface. JetBackup integrates with any control panel via API, and has native support for cPanel and DirectAdmin. Easily backup your data to storage you already use, including Linodeā€™s S3-compatible Object Storage.`, + logo_url: 'jetbackup.svg', + name: 'JetBackup', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jetbackup/', + title: 'Deploy JetBackup through the Linode Marketplace', + }, + ], + summary: + 'Advanced customizable backups to integrate with your preferred control panel.', + website: 'https://docs.jetapps.com/', + }, + 912262: { + alt_description: 'Container registry for Kubernetes.', + alt_name: 'Container registry for Kubernetes.', + categories: ['Development'], + colors: { + end: '4495d7', + start: '60b932', + }, + description: `Open source registry for images and containers. Linode recommends using Harbor with Linode Kubernetes Engine (LKE).`, + logo_url: 'harbor.svg', + name: 'Harbor', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/harbor/', + title: 'Deploy Harbor through the Linode Marketplace', + }, + ], + summary: 'Cloud native container registry for Kubernetes and more.', + website: 'https://goharbor.io/docs', + }, + 912264: { + alt_description: 'Free alternative to Slack, Microsoft Teams, and Skype.', + alt_name: 'Chat software', + categories: ['Productivity'], + colors: { + end: '030d1a', + start: 'f5445c', + }, + description: `Put data privacy first with an alternative to programs like Slack and Microsoft Teams.`, + logo_url: 'rocketchat.svg', + name: 'Rocket.Chat', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/rocketchat/', + title: 'Deploy Rocket.Chat through the Linode Marketplace', + }, + ], + summary: 'Feature-rich self-hosted chat and collaboration platform.', + website: 'https://docs.rocket.chat/', + }, + 913276: { + alt_description: + 'Security analytics for intrusion attempts and user action monitoring.', + alt_name: 'Security monitoring', + categories: ['Security'], + colors: { + end: 'ffb600', + start: '00a9e5', + }, + description: `Infrastructure monitoring solution to detect threats, intrusion attempts, unauthorized user actions, and provide security analytics.`, + logo_url: 'wazuh.svg', + name: 'Wazuh', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wazuh/', + title: 'Deploy Wazuh through the Linode Marketplace', + }, + ], + summary: 'Free open source security monitoring solution.', + website: 'https://documentation.wazuh.com/current/index.html', + }, + 913277: { + alt_description: 'Free penetration testing tool using client-side vectors.', + alt_name: 'Penetration testing tool for security research', + categories: ['Security'], + colors: { + end: '000f21', + start: '4a80a9', + }, + description: `Test the security posture of a client or application using client-side vectors, all powered by a simple API. This project is developed solely for lawful research and penetration testing.`, + logo_url: 'beef.svg', + name: 'BeEF', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/beef/', + title: 'Deploy BeEF through the Linode Marketplace', + }, + ], + summary: + 'Browser Exploitation Framework (BeEF) is an open source web browser penetration tool.', + website: 'https://github.com/beefproject/beef', + }, + 923029: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '5cbf8a', + start: '318640', + }, + description: `Simple deployment for OLS web server, Python LSAPI, and CertBot.`, + logo_url: 'openlitespeeddjango.svg', + name: 'OpenLiteSpeed Django', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-django/', + title: 'Deploy OpenLiteSpeed Django through the Linode Marketplace', + }, + ], + summary: 'OLS web server with Django development framework.', + website: 'https://docs.litespeedtech.com/cloud/images/django/', + }, + 923030: { + alt_description: 'Ruby web application framework with development tools.', + alt_name: 'Ruby web application framework.', + categories: ['Development'], + colors: { + end: 'd94b7a', + start: '8e1a4a', + }, + description: `Easy setup to run Ruby apps in the cloud and take advantage of OpenLiteSpeed server features like SSL, HTTP/3 support, and RewriteRules.`, + logo_url: 'openlitespeedrails.svg', + name: 'OpenLiteSpeed Rails', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-rails/', + title: 'Deploy OpenLiteSpeed Rails through the Linode Marketplace ', + }, + ], + summary: 'OLS web server with Ruby and CertBot.', + website: 'https://docs.litespeedtech.com/cloud/images/rails/', + }, + 923031: { + alt_description: + 'Versatile cross-platform JavaScript run-time (runtime) environment.', + alt_name: 'JavaScript environment', + categories: ['Development'], + colors: { + end: '33cccc', + start: '3d596d', + }, + description: `High-performance open source web server with Node and CertBot, in addition to features like HTTP/3 support and easy SSL setup.`, + logo_url: 'openlitespeednodejs.svg', + name: 'OpenLiteSpeed NodeJS', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-nodejs/', + title: 'Deploy OpenLiteSpeed Node.js through the Linode Marketplace', + }, + ], + summary: 'OLS web server with NodeJS JavaScript runtime environment.', + website: 'https://docs.litespeedtech.com/cloud/images/nodejs/', + }, + 923032: { + alt_description: 'Optimized control panel server.', + alt_name: 'Web server control panel', + categories: ['Website'], + colors: { + end: '6e92c7', + start: '353785', + }, + description: `High-performance LiteSpeed web server equipped with WHM/cPanel and WHM LiteSpeed Plugin.`, + logo_url: 'litespeedcpanel.svg', + name: 'LiteSpeed cPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/litespeed-cpanel/', + title: 'Deploy LiteSpeed cPanel through the Linode Marketplace', + }, + ], + summary: 'Next-generation web server with cPanel and WHM.', + website: 'https://docs.litespeedtech.com/cp/cpanel/', + }, + 923033: { + alt_description: + 'Free accounting software. QuickBooks alternative for freelancers and small businesses.', + alt_name: 'Open source accounting software', + categories: ['Productivity'], + colors: { + end: '55588b', + start: '6ea152', + }, + description: `Akaunting is a universal accounting software that helps small businesses run more efficiently. Track expenses, generate reports, manage your books, and get the other essential features to run your business from a single dashboard.`, + logo_url: 'akaunting.svg', + name: 'Akaunting', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/akaunting/', + title: 'Deploy Akaunting through the Linode Marketplace', + }, + ], + summary: + 'Free and open source accounting software you can use in your browser.', + website: 'https://akaunting.com', + }, + 923036: { + alt_description: 'Free alternative to Trello and Asana.', + alt_name: 'Kanban board project management tool', + categories: ['Productivity'], + colors: { + end: '555555', + start: 'f47564', + }, + description: `Restyaboard is an open-source alternative to Trello, but with additional smart features like offline sync, diff /revisions, nested comments, multiple view layouts, chat, and more.`, + logo_url: 'restyaboard.svg', + name: 'Restyaboard', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/restyaboard/', + title: 'Deploy Restyaboard through the Linode Marketplace', + }, + ], + summary: 'Free and open source project management tool.', + website: 'https://restya.com', + }, + 923037: { + alt_description: 'Virtual private network.', + alt_name: 'WireGuard VPN', + categories: ['Security'], + colors: { + end: '333333', + start: '1f76b7', + }, + description: `Feature-rich, self-hosted VPN based on WireGuardĀ® protocol, plus convenient features like single sign-on, real-time bandwidth monitoring, and unlimited users/devices.`, + logo_url: 'warpspeed.svg', + name: 'WarpSpeed', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/warpspeed/', + title: 'Deploy WarpSpeed VPN through the Linode Marketplace', + }, + ], + summary: 'Secure low-latency VPN powered by WireGuardĀ® protocol.', + website: 'https://bunker.services/products/warpspeed', + }, + 925530: { + alt_description: 'Virtual private network.', + alt_name: 'VPN', + categories: ['Security'], + colors: { + end: '1a32b1', + start: '2ec1cf', + }, + description: `UTunnel VPN is a robust cloud-based VPN server software solution. With UTunnel VPN, businesses could easily set up secure remote access to their business network. UTunnel comes with a host of business-centric features including site-to-site connectivity, single sign-on integration, 2-factor authentication, etc.`, + logo_url: 'utunnel.svg', + name: 'UTunnel VPN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/utunnel/', + title: 'Deploy UTunnel VPN through the Linode Marketplace', + }, + ], + summary: + 'A powerful, user-friendly Virtual Private Network (VPN) server application that supports multiple VPN protocols.', + website: 'https://www.utunnel.io/linode-vpn-server.html', + }, + 925722: { + alt_description: 'Virtual private network for businesses and teams.', + alt_name: 'Enterprise VPN', + categories: ['Security'], + colors: { + end: '2e72d2', + start: '2e4153', + }, + description: `User-friendly VPN for both individual and commercial use. Choose from three pricing plans.`, + logo_url: 'pritunl.svg', + name: 'Pritunl', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/pritunl/', + title: 'Deploy Pritunl through the Linode Marketplace', + }, + ], + summary: 'Enterprise open source VPN.', + website: 'https://docs.pritunl.com/docs', + }, + 954759: { + alt_description: 'Time series database and database monitoring/metrics.', + alt_name: 'Database monitoring', + categories: ['Databases'], + colors: { + end: 'af3e56', + start: '6a1e6e', + }, + description: `VictoriaMetrics is designed to collect, store, and process real-time metrics.`, + logo_url: 'victoriametricssingle.svg', + name: 'VictoriaMetrics Single', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/victoriametrics-single/', + title: 'Deploy VictoriaMetrics Single through the Linode Marketplace', + }, + ], + summary: + 'Free and open source time series database (TSDB) and monitoring solution.', + website: 'https://victoriametrics.com/', + }, + 970522: { + alt_description: 'Popular DNS privacy sinkhole.', + alt_name: 'Network ad blocking', + categories: ['Security'], + colors: { + end: 'f60d1a', + start: '96060c', + }, + description: `Protect your network and devices from unwanted content. Avoid ads in non-browser locations with a free, lightweight, and comprehensive privacy solution you can self-host.`, + logo_url: 'pihole.svg', + name: 'Pi-hole', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/pihole/', + title: 'Deploy Pi-hole through the Linode Marketplace', + }, + ], + summary: 'Free, open source, and highly scalable DNS sinkhole.', + website: 'https://pi-hole.net/', + }, + 970523: { + alt_description: + 'Infrastructure monitoring and aler alternative to Uptime Robot.', + alt_name: 'Infrastructure monitoring', + categories: ['Monitoring'], + colors: { + end: 'baecca', + start: '67de92', + }, + description: `Uptime Kuma is self-hosted alternative to Uptime Robot. Get real-time performance insights for HTTP(s), TCP/ HTTP(s) Keyword, Ping, DNS Record, and more. Monitor everything you need in one UI dashboard, or customize how you receive alerts with a wide range of supported integrations.`, + logo_url: 'uptimekuma.svg', + name: 'Uptime Kuma', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/uptime-kuma/', + title: 'Deploy Uptime Kuma through the Linode Marketplace', + }, + ], + summary: 'Free, comprehensive, and ā€œfancyā€ monitoring solution.', + website: 'https://github.com/louislam/uptime-kuma', + }, + 970559: { + alt_description: 'Markdown-based website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: 'b987cf', + start: '1a0629', + }, + description: `Build websites on a CMS that prioritizes speed and simplicity over customization and integration support. Create your content in Markdown and take advantage of powerful taxonomy to customize relationships between pages and other content.`, + logo_url: 'grav.svg', + name: 'Grav', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/grav/', + title: 'Deploy Grav through the Linode Marketplace', + }, + ], + summary: 'Modern and open source flat-file content management system.', + website: 'https://getgrav.org/', + }, + 970561: { + alt_description: + 'Versatile cross-platform JavaScript run-time (runtime) environment.', + alt_name: 'JavaScript environment', + categories: ['Development'], + colors: { + end: '333333', + start: '3d853c', + }, + description: `NodeJS is a free, open-source, and cross-platform JavaScript run-time environment that lets developers write command line tools and server-side scripts outside of a browser.`, + logo_url: 'nodejs.svg', + name: 'NodeJS', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nodejs/', + title: 'Deploy NodeJS through the Linode Marketplace', + }, + ], + summary: + 'Popular and versatile open source JavaScript run-time environment.', + website: 'https://nodejs.org/', + }, + 971042: { + alt_description: 'Database low-code/no-code application builder.', + alt_name: 'Low-code application builder', + categories: ['Development'], + colors: { + end: 'ff8e42', + start: '995ad9', + }, + description: `Build applications without writing a single line of code. Saltcorn is a free platform that allows you to build an app with an intuitive point-and-click, drag-and-drop UI.`, + logo_url: 'saltcorn.svg', + name: 'Saltcorn', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/saltcorn/', + title: 'Deploy Saltcorn through the Linode Marketplace', + }, + ], + summary: 'Open source, no-code database application builder.', + website: 'https://saltcorn.com/', + }, + 971043: { + alt_description: + 'Open source marketing and business platform with a CRM and email marketing.', + alt_name: 'Marketing tool suite', + categories: ['Productivity'], + colors: { + end: '027e84', + start: '55354c', + }, + description: `Odoo is a free and comprehensive business app suite of tools that seamlessly integrate. Choose what you need to manage your business on a single platform, including a CRM, email marketing tools, essential project management functions, and more.`, + logo_url: 'odoo.svg', + name: 'Odoo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/odoo/', + title: 'Deploy Odoo through the Linode Marketplace', + }, + ], + summary: + 'Open source, all-in-one business app suite with more than 7 million users.', + website: 'https://www.odoo.com/', + }, + 971045: { + alt_description: 'Free alternative to Trello and Asana.', + alt_name: 'Kanban board project management tool', + categories: ['Productivity'], + colors: { + end: '1d52ad', + start: '2997f8', + }, + description: `Create boards, assign tasks, and keep projects moving with a free and robust alternative to tools like Trello and Asana.`, + logo_url: 'focalboard.svg', + name: 'Focalboard', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/focalboard/', + title: 'Deploy Focalboard through the Linode Marketplace', + }, + ], + summary: 'Free open source project management tool.', + website: 'https://www.focalboard.com/', + }, + 985364: { + alt_description: 'Monitoring server.', + alt_name: 'Server monitoring and visualization', + categories: ['Monitoring'], + colors: { + end: 'e6522c', + start: 'f9b716', + }, + description: `Free industry-standard monitoring tools that work better together. Prometheus is a powerful monitoring software tool that collects metrics from configurable data points at given intervals, evaluates rule expressions, and can trigger alerts if some condition is observed. Use Grafana to create visuals, monitor, store, and share metrics with your team to keep tabs on your infrastructure.`, + logo_url: 'prometheusgrafana.svg', + name: 'Prometheus & Grafana', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/prometheus-grafana/', + title: 'Deploy Prometheus & Grafana through the Linode Marketplace', + }, + ], + summary: 'Open source metrics and monitoring for real-time insights.', + website: 'https://prometheus.io/docs/visualization/grafana/', + }, + 985372: { + alt_description: 'Secure website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '5090cd', + start: 'f2a13e', + }, + description: `Free open source CMS optimized for building custom functionality and design.`, + logo_url: 'joomla.svg', + name: 'Joomla', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/joomla/', + title: 'Deploy Joomla through the Linode Marketplace', + }, + ], + summary: 'Flexible and security-focused content management system.', + website: 'https://www.joomla.org/', + }, + 985374: { + alt_description: + 'Low latency live streaming including WebRTC streaming, CMAF, and HLS.', + alt_name: 'Media streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '0a0a0a', + start: 'df0718', + }, + description: `Ant Media Server makes it easy to set up a video streaming platform with ultra low latency. The Enterprise edition supports WebRTC Live Streaming in addition to CMAF and HLS streaming. Set up live restreaming to social media platforms to reach more viewers.`, + logo_url: 'antmediaserver.svg', + name: 'Ant Media Server: Enterprise Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/antmediaenterpriseserver/', + title: + 'Deploy Ant Media Enterprise Edition through the Linode Marketplace', + }, + ], + summary: 'Highly scalable and feature-rich live video streaming platform.', + website: 'https://antmedia.io/', + }, + 985380: { + alt_description: + 'Digital note-taking application alternative to Evernote and OneNote.', + alt_name: 'Multimedia note-taking and digital notebook', + categories: ['Website'], + colors: { + end: '509df9', + start: '043872', + }, + description: `Capture your thoughts and securely access them from any device with a highly customizable note-taking software.`, + logo_url: 'joplin.svg', + name: 'Joplin', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/joplin/', + title: 'Deploy Joplin through the Linode Marketplace', + }, + ], + summary: 'Open source multimedia note-taking app.', + website: 'https://joplinapp.org/', + }, + 1008123: { + alt_description: 'Audio and video streaming with E2E data encryption.', + alt_name: 'Live streaming', + categories: ['Media and Entertainment'], + colors: { + end: '4d8eff', + start: '346ee0', + }, + description: `Stream live audio or video while maximizing customer engagement with advanced built-in features. Liveswitch provides real-time monitoring, audience polling, and end-to-end (E2E) data encryption.`, + logo_url: 'liveswitch.svg', + name: 'LiveSwitch', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/liveswitch/', + title: 'Deploy LiveSwitch through the Linode Marketplace', + }, + ], + summary: 'High quality and reliable interactive live streaming.', + website: 'https://www.liveswitch.io/', + }, + 1008125: { + alt_description: + 'Flexible control panel to simplify SSL certificates and push code from GitHub.', + alt_name: 'Server control panel', + categories: ['Control Panels'], + colors: { + end: '000000', + start: '059669', + }, + description: `Deploy Node.js, Ruby, Python, PHP, Go, and Java applications via an intuitive control panel. Easily set up free SSL certificates, run commands with an in-browser terminal, and push your code from Github to accelerate development.`, + logo_url: 'easypanel.svg', + name: 'Easypanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/easypanel/', + title: 'Deploy Easypanel through the Linode Marketplace', + }, + ], + summary: 'Modern server control panel based on Docker.', + website: 'https://easypanel.io/', + }, + 1017300: { + alt_description: + 'Security research and testing platform with hundreds of tools for reverse engineering, penetration testing, and more.', + alt_name: 'Security research', + categories: ['Security'], + colors: { + end: '2fa1bc', + start: '267ff7', + }, + description: `Kali Linux is an open source, Debian-based Linux distribution that has become an industry-standard tool for penetration testing and security audits. Kali includes hundreds of free tools for reverse engineering, penetration testing and more. Kali prioritizes simplicity, making security best practices more accessible to everyone from cybersecurity professionals to hobbyists.`, + logo_url: 'kalilinux.svg', + name: 'Kali Linux', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/kali-linux/', + title: 'Deploy Kali Linux through the Linode Marketplace', + }, + ], + summary: + 'Popular Linux distribution and tool suite for penetration testing and security research.', + website: 'https://www.kali.org/', + }, + 1037036: { + alt_description: + 'Application builder for forms, portals, admin panels, and more.', + alt_name: 'Low-code application builder', + categories: ['Development'], + colors: { + end: '000000', + start: '9981f5', + }, + description: + 'Budibase is a modern, open source low-code platform for building modern business applications in minutes. Build, design and automate business apps, such as: admin panels, forms, internal tools, client portals and more. Before Budibase, it could take developers weeks to build simple CRUD apps; with Budibase, building CRUD apps takes minutes. When self-hosting please follow best practices for securing, updating and backing up your server.', + logo_url: 'budibase.svg', + name: 'Budibase', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/budibase/', + title: 'Deploy Budibase through the Linode Marketplace', + }, + ], + summary: 'Low-code platform for building modern business applications.', + website: 'https://docs.budibase.com/docs', + }, + 1037037: { + alt_description: + 'HashiCorp containerization tool to use instead of or with Kubernetes', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad', + title: 'Deploy HashiCorp Nomad through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1037038: { + alt_description: 'HashiCorp password and secrets management storage.', + alt_name: 'Security secrets management', + categories: ['Security'], + colors: { + end: '545556', + start: 'ffd712', + }, + description: + 'HashiCorp Vault is an open source, centralized secrets management system. It provides a secure and reliable way of storing and distributing secrets like API keys, access tokens, and passwords.', + logo_url: 'vault.svg', + name: 'HashiCorp Vault', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-vault', + title: 'Deploy HashiCorp Vault through the Linode Marketplace', + }, + ], + summary: 'An open source, centralized secrets management system.', + website: 'https://www.vaultproject.io/docs', + }, + 1051714: { + alt_description: 'Drag and drop website CMS.', + alt_name: 'Website builder', + categories: ['Development'], + colors: { + end: '4592ff', + start: '4592ff', + }, + description: `Microweber is an easy Drag and Drop website builder and a powerful CMS of a new generation, based on the PHP Laravel Framework.`, + logo_url: 'microweber.svg', + name: 'Microweber', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/microweber/', + title: 'Deploy Microweber through the Linode Marketplace', + }, + ], + summary: `Drag and drop CMS and website builder.`, + website: 'https://microweber.com/', + }, + 1068726: { + alt_description: 'MySQL alternative for SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '254078', + start: '326690', + }, + description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your databaseā€™s performance in a production environment.`, + logo_url: 'postgresqlmarketplaceocc.svg', + name: 'PostgreSQL Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/postgresql-cluster/', + title: 'Deploy PostgreSQL Cluster through the Linode Marketplace', + }, + ], + summary: `The PostgreSQL relational database system is a powerful, scalable, and standards-compliant open-source database platform.`, + website: 'https://www.postgresql.org/', + }, + 1088136: { + alt_description: 'SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '000000', + start: 'EC7704', + }, + description: `Galera provides a performant multi-master/active-active database solution with synchronous replication, to achieve high availability.`, + logo_url: 'galeramarketplaceocc.svg', + name: 'Galera Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/galera-cluster/', + title: 'Deploy Galera Cluster through the Linode Marketplace', + }, + ], + summary: `Multi-master MariaDB database cluster.`, + website: 'https://galeracluster.com/', + }, + 1096122: { + alt_description: 'Open source Twitter alternative.', + alt_name: 'Open source social media', + categories: ['Media and Entertainment'], + colors: { + end: '563ACC', + start: '6364FF', + }, + description: `Mastodon is an open-source and decentralized micro-blogging platform, supporting federation and public access to the server.`, + logo_url: 'mastodon.svg', + name: 'Mastodon', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mastodon/', + title: 'Deploy Mastodon through the Linode Marketplace', + }, + ], + summary: + 'Mastodon is an open-source and decentralized micro-blogging platform.', + website: 'https://docs.joinmastodon.org/', + }, + 1102900: { + alt_description: + 'Open-source workflow management platform for data engineering pipelines.', + alt_name: 'Workflow management platform', + categories: ['Development'], + colors: { + end: 'E43921', + start: '00C7D4', + }, + description: `Programmatically author, schedule, and monitor workflows with a Python-based tool. Airflow provides full insight into the status and logs of your tasks, all in a modern web application.`, + logo_url: 'apacheairflow.svg', + name: 'Apache Airflow', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-airflow/', + title: 'Deploy Apache Airflow through the Linode Marketplace', + }, + ], + summary: + 'Open source workflow management platform for data engineering pipelines.', + website: 'https://airflow.apache.org/', + }, + 1102902: { + alt_description: 'Web Application Firewall.', + alt_name: 'Community WAF', + categories: ['Security'], + colors: { + end: '00C1A9', + start: '22324F', + }, + description: `Harden your web applications and APIs against OWASP Top 10 attacks. Haltdos makes it easy to manage WAF settings and review logs in an intuitive web-based GUI.`, + logo_url: 'haltdos.svg', + name: 'HaltDOS Community WAF', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/haltdos-community-waf/', + title: 'Deploy Haltdos Community WAF through the Linode Marketplace', + }, + ], + summary: 'User-friendly web application firewall.', + website: 'https://www.haltdos.com/', + }, + 1102904: { + alt_description: + 'A simple SQL interface to store and search unstructured data.', + alt_name: 'SuperinsightDB', + categories: ['Databases'], + colors: { + end: 'C54349', + start: 'E6645F', + }, + description: `Superinsight provides a simple SQL interface to store and search unstructured data. Superinsight is built on top of PostgreSQL to take advantage of powerful extensions and features, plus the ability to run machine learning operations using SQL statements.`, + logo_url: 'superinsight.svg', + name: 'Superinsight', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/superinsight/', + title: 'Deploy Superinsight through the Linode Marketplace', + }, + ], + summary: 'Relational database for unstructured data.', + website: 'https://www.superinsight.ai/', + }, + 1102905: { + alt_description: + 'No-code platform for Kubernetes developers and operators.', + alt_name: 'Go Paddle', + categories: ['Development'], + colors: { + end: '252930', + start: '3a5bfd', + }, + description: `Provision multicloud clusters, containerize applications, and build DevOps pipelines. Gopaddleā€™s suite of templates and integrations helps eliminate manual errors and automate Kubernetes application releases.`, + logo_url: 'gopaddle.svg', + name: 'Gopaddle', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gopaddle/', + title: 'Deploy Gopaddle through the Linode Marketplace', + }, + ], + summary: + 'Simple low-code platform for Kubernetes developers and operators.', + website: 'https://gopaddle.io/', + }, + 1102906: { + alt_description: 'Password Manager', + alt_name: 'Pass Key', + categories: ['Security'], + colors: { + end: '3A5EFF', + start: '709cff', + }, + description: `Self-host a password manager designed to simplify and secure your digital life. Passky is a streamlined version of paid password managers designed for everyone to use.`, + logo_url: 'passky.svg', + name: 'Passky', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/passky/', + title: 'Deploy Passky through the Linode Marketplace', + }, + ], + summary: 'Simple open source password manager.', + website: 'https://passky.org/', + }, + 1102907: { + alt_description: 'Office Suite', + alt_name: 'Office Docs', + categories: ['Productivity'], + colors: { + end: 'ff6f3d', + start: 'ffa85b', + }, + description: `Create and collaborate on text documents, spreadsheets, and presentations compatible with popular file types including .docx, .xlsx, and more. Additional features include real-time editing, paragraph locking while co-editing, and version history.`, + logo_url: 'onlyoffice.svg', + name: 'ONLYOFFICE Docs', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/onlyoffice/', + title: 'Deploy ONLYOFFICE Docs through the Linode Marketplace', + }, + ], + summary: 'Open source comprehensive office suite.', + website: 'https://www.onlyoffice.com/', + }, + 1132204: { + alt_description: 'In-memory caching database.', + alt_name: 'High performance database', + categories: ['Databases'], + colors: { + end: '722b20', + start: '222222', + }, + description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

    *Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, + logo_url: 'redissentinelmarketplaceocc.svg', + name: 'Marketplace App for Redis® Sentinel Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/redis-cluster/', + title: + 'Deploy Redis® Sentinel Cluster through the Linode Marketplace', + }, + ], + summary: + 'Flexible, in-memory, NoSQL database service supported in many different coding languages.', + website: 'https://redis.io/', + }, + 1160816: { + alt_description: 'Self-hosted file sharing and collaboration platform.', + alt_name: 'Collabrative file sharing', + categories: ['Productivity'], + colors: { + end: '041e42', + start: '041e42', + }, + description: `LAMP-stack-based server application that allows you to access your files from anywhere in a secure way.`, + logo_url: 'owncloud.svg', + name: 'ownCloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/owncloud/', + title: 'Deploy ownCloud through the Linode Marketplace', + }, + ], + summary: + 'Dropbox and OneDrive alternative that lets you remain in control of your files.', + website: 'https://doc.owncloud.com/docs/next/', + }, + 1160820: { + alt_description: + 'A self-hosted backend-as-a-service platform that provides developers with all the core APIs required to build any application.', + alt_name: 'Self-hosted backend-as-a-service', + categories: ['Development'], + colors: { + end: 'f02e65', + start: 'f02e65', + }, + description: `A self-hosted Firebase alternative for web, mobile & Flutter developers.`, + logo_url: 'appwrite.svg', + name: 'Appwrite', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/appwrite/', + title: 'Deploy Appwrite through the Linode Marketplace', + }, + ], + summary: + 'Appwrite is an open-source, cross-platform and technology-agnostic alternative to Firebase, providing all the core APIs necessary for web, mobile and Flutter development.', + website: 'https://appwrite.io/', + }, + 1177225: { + alt_description: 'A safe home for all your data.', + alt_name: + 'Spreadsheet style interface with the power of a relational database.', + categories: ['Productivity'], + colors: { + end: 'FF8000', + start: 'FF8000', + }, + description: `Self-hosted database for a variety of management projects.`, + logo_url: 'seatable.svg', + name: 'Seatable', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/seatable/', + title: 'Deploy Seatable through the Linode Marketplace', + }, + ], + summary: + 'Collaborative web interface for data backed project and process management.', + website: 'https://seatable.io/docs/?lang=auto', + }, + 1177605: { + alt_description: + 'Retool open-source alternative, with low-code UI components.', + alt_name: 'Low-code development platform', + categories: ['Security'], + colors: { + end: 'FF58BE', + start: '654AEC', + }, + description: + 'Illa Builder is a Retool open-source alternative, with low-code UI components for self-hosting the development of internal tools.', + logo_url: 'illabuilder.svg', + name: 'Illa Builder', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/illa-builder', + title: 'Deploy Illa Builder through the Linode Marketplace', + }, + ], + summary: 'An open-source, low-code development platform.', + website: 'https://github.com/illacloud/illa-builder', + }, + 1226544: { + alt_description: + 'HashiCorp containerization tool to use instead of or with Kubernetes', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad-cluster', + title: 'Deploy HashiCorp Nomad Cluster through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1226545: { + alt_description: + 'HashiCorp Nomad clients for horizontally scaling a Nomad One-Click Cluster', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple deployment of multiple clients to horizontally scale an existing Nomad One-Click Cluster.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad Clients Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad-clients-cluster', + title: + 'Deploy HashiCorp Nomad Clients Cluster through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1243759: { + alt_description: 'FFmpeg encoder plugins.', + alt_name: 'Premium video encoding', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept FFmpeg Plugins Demo is suited for both VOD and live production workflows, with advanced features such as Hybrid GPU acceleration and xHE-AAC audio format.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept FFmpeg Plugins Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-ffmpeg-plugins-demo/', + title: + 'Deploy MainConcept FFmpeg Plugins Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept FFmpeg Plugins Demo contains advanced video encoding tools.', + website: 'https://www.mainconcept.com/ffmpeg', + }, + 1243760: { + alt_description: 'Live video encoding engine.', + alt_name: 'Real time video encoding', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept Live Encoder Demo is a powerful all-in-one encoding engine designed to simplify common broadcast and OTT video workflows.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept Live Encoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-live-encoder-demo/', + title: + 'Deploy MainConcept Live Encoder Demo through the Linode Marketplace', + }, + ], + summary: 'MainConcept Live Encoder is a real time video encoding engine.', + website: 'https://www.mainconcept.com/live-encoder', + }, + 1243762: { + alt_description: 'Panasonic camera format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept P2 AVC ULTRA Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Panasonic camera formats like P2 AVC-Intra, P2 AVC LongG and AVC-intra RP2027.v1 and AAC High Efficiency v2 formats into an MP4 container.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept P2 AVC ULTRA Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-p2-avc-ultra-demo/', + title: + 'Deploy MainConcept P2 AVC ULTRA Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept P2 AVC ULTRA Transcoder is a Docker container for file-based transcoding of media files into professional Panasonic camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243763: { + alt_description: 'Sony camera format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept XAVC Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XAVC-Intra, XAVC Long GOP and XAVC-S.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept XAVC Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xavc-transcoder-demo/', + title: + 'Deploy MainConcept XAVC Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept XAVC Transcoder is a Docker container for file-based transcoding of media files into professional Sony camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243764: { + alt_description: 'Sony XDCAM format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept XDCAM Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XDCAM HD, XDCAM EX, XDCAM IMX and DVCAM (XDCAM DV).`, + logo_url: 'mainconcept.svg', + name: 'MainConcept XDCAM Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xdcam-transcoder-demo/', + title: + 'Deploy MainConcept XDCAM Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept XDCAM Transcoder is a Docker container for file-based transcoding of media files into professional Sony camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243780: { + alt_description: 'A private by design messaging platform.', + alt_name: 'Anonymous messaging platform.', + categories: ['Productivity'], + colors: { + end: '70f0f9', + start: '11182f', + }, + description: `SimpleX Chat - The first messaging platform that has no user identifiers of any kind - 100% private by design. SMP server is the relay server used to pass messages in SimpleX network. XFTP is a new file transfer protocol focussed on meta-data protection. This One-Click APP will deploy both SMP and XFTP servers.`, + logo_url: 'simplexchat.svg', + name: 'SimpleX Chat', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/simplex/', + title: 'Deploy SimpleX chat through the Linode Marketplace', + }, + ], + summary: 'Private by design messaging server.', + website: 'https://simplex.chat', + }, + 1298017: { + alt_description: 'Data science notebook.', + alt_name: 'Data science and machine learning development environment.', + categories: ['Productivity'], + colors: { + end: '9e9e9e', + start: 'f37626', + }, + description: + 'JupyterLab is a cutting-edge web-based, interactive development environment, geared towards data science, machine learning and other scientific computing workflows.', + logo_url: 'jupyter.svg', + name: 'JupyterLab', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jupyterlab/', + title: 'Deploy JupyterLab through the Linode Marketplace', + }, + ], + summary: 'Data science development environment.', + website: 'https://jupyter.org', + }, + 1308539: { + alt_description: `Microservice centeric stream processing.`, + alt_name: 'Microservice messaging bus', + categories: ['Development'], + colors: { + end: '000000', + start: '0086FF', + }, + description: + 'NATS is a distributed PubSub technology that enables applications to securely communicate across any combination of cloud vendors, on-premise, edge, web and mobile, and devices.', + logo_url: 'nats.svg', + name: 'NATS Single Node', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nats-single-node/', + title: 'Deploy NATS single node through the Linode Marketplace', + }, + ], + summary: 'Cloud native application messaging service.', + website: 'https://nats.io', + }, + 1329430: { + alt_description: 'Password Manager', + alt_name: 'Passbolt', + categories: ['Security'], + colors: { + end: 'D40101', + start: '171717', + }, + description: `Passbolt is an open-source password manager designed for teams and businesses. It allows users to securely store, share and manage passwords.`, + logo_url: 'passbolt.svg', + name: 'Passbolt', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/passbolt/', + title: 'Deploy Passbolt through the Linode Marketplace', + }, + ], + summary: 'Open-source password manager for teams and businesses.', + website: 'https://www.passbolt.com/', + }, + 1329462: { + alt_description: + 'LinuxGSM is a command line utility that simplifies self-hosting multiplayer game servers.', + alt_name: 'Multiplayer Game Servers', + categories: ['Games'], + colors: { + end: 'F6BD0C', + start: '000000', + }, + description: `Self hosted multiplayer game servers.`, + logo_url: 'linuxgsm.svg', + name: 'LinuxGSM', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/linuxgsm/', + title: 'Deploy LinuxGSM through the Linode Marketplace', + }, + ], + summary: 'Simple command line multiplayer game servers.', + website: 'https://docs.linuxgsm.com', + }, + 1350733: { + alt_description: + 'Open source video conferencing cluster, alternative to Zoom.', + alt_name: 'Video chat and video conferencing cluster', + categories: ['Media and Entertainment'], + colors: { + end: '949699', + start: '1d76ba', + }, + description: `Secure, stable, and free alternative to popular video conferencing services. This app deploys four networked Jitsi nodes.`, + logo_url: 'jitsi.svg', + name: 'Jitsi Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi-cluster/', + title: 'Deploy Jitsi Cluster through the Linode Marketplace', + }, + ], + summary: 'Free, open source video conferencing and communication platform.', + website: 'https://jitsi.org/', + }, + 1350783: { + alt_description: 'Open source, highly available, shared filesystem.', + alt_name: 'GlusterFS', + categories: ['Development'], + colors: { + end: '784900', + start: 'D4AC5C', + }, + description: + 'GlusterFS is an open source, software scalable network filesystem. This app deploys three GlusterFS servers and three GlusterFS clients.', + logo_url: 'glusterfs.svg', + name: 'GlusterFS Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/glusterfs-cluster/', + title: 'Deploy GlusterFS Cluster through the Linode Marketplace', + }, + ], + summary: 'Open source network filesystem.', + website: 'https://www.gluster.org/', + }, + 1366191: { + alt_description: + 'Highly available, five-node enterprise NoSQL database cluster.', + alt_name: 'Couchbase Enterprise Server Cluster', + categories: ['Databases'], + colors: { + end: 'EC1018', + start: '333333', + }, + description: `Couchbase Enterprise Server is a high-performance NoSQL database, built for scale. Couchbase Server is designed with memory-first architecture, built-in cache and workload isolation.`, + isNew: true, + logo_url: 'couchbase.svg', + name: 'Couchbase Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/couchbase-cluster/', + title: + 'Deploy a Couchbase Enterprise Server cluster through the Linode Marketplace', + }, + ], + summary: 'NoSQL production database cluster.', + website: 'https://www.couchbase.com/', + }, + 1377657: { + alt_description: 'Open source real-time data stream management cluster.', + alt_name: 'Data stream publisher-subscriber cluster', + categories: ['Development'], + colors: { + end: '5CA2A2', + start: '00C7D4', + }, + description: `Apache Kafka supports a wide range of applications from log aggregation to real-time analytics. Kafka provides a foundation for building data pipelines, event-driven architectures, or stream processing applications.`, + isNew: true, + logo_url: 'apachekafka.svg', + name: 'Apache Kafka Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-kafka-cluster/', + title: 'Deploy an Apache Kafka cluster through the Linode Marketplace', + }, + ], + summary: 'Open source data streaming.', + website: 'https://kafka.apache.org/', + }, +}; diff --git a/packages/manager/src/features/OneClickApps/types.ts b/packages/manager/src/features/OneClickApps/types.ts index 5e5bf88d501..47110fcd0e5 100644 --- a/packages/manager/src/features/OneClickApps/types.ts +++ b/packages/manager/src/features/OneClickApps/types.ts @@ -5,6 +5,13 @@ export interface OCA { colors: Colors; description: string; href?: string; + /** + * Set isNew to `true` if you want the app to show up in the "New apps" + * section on the Linode Create flow. + * + * @note this value only affects Linode Create v2 + */ + isNew?: boolean; logo_url: string; name: string; related_guides?: Doc[]; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx index 6a25fe2747b..a171803e064 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx @@ -8,6 +8,8 @@ import { Radio } from 'src/components/Radio/Radio'; import { RadioGroup } from 'src/components/RadioGroup'; import { Typography } from 'src/components/Typography'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from './constants'; + import type { FormikHelpers } from 'formik'; interface Props { @@ -29,7 +31,7 @@ export const PlacementGroupsAffinityTypeEnforcementRadioGroup = ( return ( diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index ab4e75fcf48..167f6fc107a 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -10,6 +10,8 @@ import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; @@ -34,6 +36,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 +139,36 @@ export const PlacementGroupsCreateDrawer = ( selectedRegion )}`; + const { isGeckoGAEnabled } = useIsGeckoEnabled(); + + 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} /> )} ({ useDeletePlacementGroup: vi.fn().mockReturnValue({ mutateAsync: vi.fn().mockResolvedValue({}), @@ -31,36 +29,31 @@ const props = { describe('PlacementGroupsDeleteModal', () => { it('should render the right form elements', async () => { - let renderResult: RenderResult; - await act(async () => { - renderResult = renderWithTheme( - - ); - }); - - const { getByRole, getByTestId, getByText } = renderResult!; + }), + ]} + selectedPlacementGroup={placementGroupFactory.build({ + affinity_type: 'anti_affinity:local', + id: 1, + label: 'PG-to-delete', + members: [ + { + is_compliant: true, + linode_id: 1, + }, + ], + region: 'us-east', + })} + disableUnassignButton={false} + /> + ); expect( getByRole('heading', { @@ -80,30 +73,25 @@ describe('PlacementGroupsDeleteModal', () => { }); it("should be enabled when there's no assigned linodes", async () => { - let renderResult: RenderResult; - await act(async () => { - renderResult = renderWithTheme( - - ); - }); - - const { getByRole, getByTestId } = renderResult!; + label: 'test-linode', + region: 'us-east', + }), + ]} + selectedPlacementGroup={placementGroupFactory.build({ + affinity_type: 'anti_affinity:local', + id: 1, + label: 'PG-to-delete', + members: [], + })} + disableUnassignButton={false} + /> + ); const textField = getByTestId('textfield-input'); const deleteButton = getByRole('button', { name: 'Delete' }); @@ -111,10 +99,10 @@ describe('PlacementGroupsDeleteModal', () => { expect(textField).toBeEnabled(); expect(deleteButton).toBeDisabled(); - fireEvent.change(textField, { target: { value: 'PG-to-delete' } }); + await userEvent.type(textField, 'PG-to-delete'); expect(deleteButton).toBeEnabled(); - fireEvent.click(deleteButton); + await userEvent.click(deleteButton); expect(queryMocks.useDeletePlacementGroup).toHaveBeenCalled(); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx index ac370b557d3..43b451c6cca 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx @@ -45,10 +45,12 @@ export const PlacementGroupsDeleteModal = (props: Props) => { error: deletePlacementError, isLoading: deletePlacementLoading, mutateAsync: deletePlacementGroup, + reset: resetDeletePlacementGroup, } = useDeletePlacementGroup(selectedPlacementGroup?.id ?? -1); const { error: unassignLinodeError, mutateAsync: unassignLinodes, + reset: resetUnassignLinodes, } = useUnassignLinodesFromPlacementGroup(selectedPlacementGroup?.id ?? -1); const [assignedLinodes, setAssignedLinodes] = React.useState< Linode[] | undefined @@ -85,6 +87,12 @@ export const PlacementGroupsDeleteModal = (props: Props) => { variant: 'success', } ); + handleClose(); + }; + + const handleClose = () => { + resetDeletePlacementGroup(); + resetUnassignLinodes(); onClose(); }; @@ -108,7 +116,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { width: 500, }, }} - onClose={onClose} + onClose={handleClose} open={open} title="Delete Placement Group" > @@ -130,7 +138,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { label="Placement Group" loading={deletePlacementLoading} onClick={onDelete} - onClose={onClose} + onClose={handleClose} open={open} title={`Delete Placement Group ${selectedPlacementGroup.label}`} > diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx index 196308c156d..9c8b4d1cbe3 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx @@ -28,10 +28,7 @@ export const PlacementGroupsLinodesTableRow = React.memo((props: Props) => { }); return ( - + {label} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx index b533dd93ab4..a214a2399e6 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx @@ -6,6 +6,8 @@ import { Button } from 'src/components/Button/Button'; import { ListItem } from 'src/components/ListItem'; import { Notice } from 'src/components/Notice/Notice'; import { PlacementGroupsSelect } from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect'; +import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TextTooltip } from 'src/components/TextTooltip'; import { Typography } from 'src/components/Typography'; import { NO_PLACEMENT_GROUPS_IN_SELECTED_REGION_MESSAGE } from 'src/features/PlacementGroups/constants'; @@ -70,9 +72,17 @@ export const PlacementGroupsDetailPanel = (props: Props) => { ); const isPlacementGroupSelectDisabled = !selectedRegionId || !hasRegionPlacementGroupCapability; + const { isGeckoGAEnabled } = useIsGeckoEnabled(); const placementGroupSelectLabel = selectedRegion - ? `Placement Groups in ${selectedRegion.label} (${selectedRegion.id})` + ? `Placement Groups in ${ + isGeckoGAEnabled + ? getNewRegionLabel({ + includeSlug: true, + region: selectedRegion, + }) + : `${selectedRegion.label} (${selectedRegion.id})` + }` : 'Placement Group'; return ( @@ -106,7 +116,10 @@ export const PlacementGroupsDetailPanel = (props: Props) => { allRegionsWithPlacementGroupCapability?.length ? ( {allRegionsWithPlacementGroupCapability?.map((region) => ( - + {region.label} ({region.id}) ))} @@ -115,6 +128,7 @@ export const PlacementGroupsDetailPanel = (props: Props) => { NO_REGIONS_SUPPORT_PLACEMENT_GROUPS_MESSAGE ) } + dataQaTooltip="Regions that support placement groups" displayText="regions" minWidth={225} />{' '} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index 3c191b04a3b..154e9cc6621 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -180,7 +180,7 @@ export const PlacementGroupsLanding = React.memo(() => { InputProps={{ endAdornment: query && ( - {isFetching && } + {isFetching && } { it('renders the columns with proper data', () => { resizeScreenSize(1200); - const { getByRole, getByTestId, getByText } = renderWithTheme( wrapWithTableBody( ) ); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx index 900dc4a0906..2664c099451 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx @@ -55,10 +55,7 @@ export const PlacementGroupsRow = React.memo( ]; return ( - + { const { title, type } = props; - const flags = useFlags(); const { data: profile } = useProfile(); const { handleOrderChange, order, orderBy } = useOrder( { @@ -83,9 +81,7 @@ export const APITokenTable = (props: Props) => { { '+order': order, '+order_by': orderBy } ); - const isProxyUser = Boolean( - flags.parentChildAccountAccess && profile?.user_type === 'proxy' - ); + const isProxyUser = Boolean(profile?.user_type === 'proxy'); const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [isRevokeOpen, setIsRevokeOpen] = React.useState(false); @@ -146,11 +142,7 @@ export const APITokenTable = (props: Props) => { const renderRows = (tokens: Token[]) => { return tokens.map((token: Token) => ( - + {token.label} diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 0d9ece4ab70..124ffd92732 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { appTokenFactory } from 'src/factories'; import { grantsFactory } from 'src/factories/grants'; import { profileFactory } from 'src/factories/profile'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateAPITokenDrawer } from './CreateAPITokenDrawer'; @@ -16,22 +16,14 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useProfile: queryMocks.useProfile, }; }); -vi.mock('src/queries/grants', async () => { - const actual = await vi.importActual('src/queries/grants'); - return { - ...actual, - useGrants: queryMocks.useGrants, - }; -}); - const props = { onClose: vi.fn(), open: true, @@ -64,35 +56,39 @@ describe('Create API Token Drawer', () => { expect(cancelBtn).toBeVisible(); }); - it('Should see secret modal with secret when you type a label and submit the form successfully', async () => { - server.use( - http.post('*/profile/tokens', () => { - return HttpResponse.json( - appTokenFactory.build({ token: 'secret-value' }) - ); - }) - ); - - const { getByLabelText, getByTestId, getByText } = renderWithTheme( - - ); - - const labelField = getByTestId('textfield-input'); - await userEvent.type(labelField, 'my-test-token'); - - const selectAllNoAccessPermRadioButton = getByLabelText( - 'Select no access for all' - ); - const submitBtn = getByText('Create Token'); - - expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'); - await userEvent.click(selectAllNoAccessPermRadioButton); - await userEvent.click(submitBtn); - - await waitFor(() => - expect(props.showSecret).toBeCalledWith('secret-value') - ); - }); + it( + 'Should see secret modal with secret when you type a label and submit the form successfully', + async () => { + server.use( + http.post('*/profile/tokens', () => { + return HttpResponse.json( + appTokenFactory.build({ token: 'secret-value' }) + ); + }) + ); + + const { getByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + const labelField = getByTestId('textfield-input'); + await userEvent.type(labelField, 'my-test-token'); + + const selectAllNoAccessPermRadioButton = getByLabelText( + 'Select no access for all' + ); + const submitBtn = getByText('Create Token'); + + expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(selectAllNoAccessPermRadioButton); + await userEvent.click(submitBtn); + + await waitFor(() => + expect(props.showSecret).toBeCalledWith('secret-value') + ); + }, + { timeout: 15000 } + ); it('Should default to no selection for all scopes', () => { const { getByLabelText } = renderWithTheme( @@ -123,9 +119,7 @@ describe('Create API Token Drawer', () => { data: profileFactory.build({ user_type: 'parent' }), }); - const { getByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { getByText } = renderWithTheme(); const childScope = getByText('Child Account Access'); expect(childScope).toBeInTheDocument(); }); @@ -139,10 +133,7 @@ describe('Create API Token Drawer', () => { }); const { queryByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const childScope = queryByText('Child Account Access'); expect(childScope).not.toBeInTheDocument(); @@ -154,10 +145,7 @@ describe('Create API Token Drawer', () => { }); const { queryByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const childScope = queryByText('Child Account Access'); diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index e2eacbe7679..8ce9ba24bd7 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -17,10 +17,9 @@ import { TextField } from 'src/components/TextField'; import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; import { VPC_READ_ONLY_TOOLTIP } from 'src/features/VPCs/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useProfile } from 'src/queries/profile'; -import { useCreatePersonalAccessTokenMutation } from 'src/queries/tokens'; +import { useProfile } from 'src/queries/profile/profile'; +import { useCreatePersonalAccessTokenMutation } from 'src/queries/profile/tokens'; import { getErrorMap } from 'src/utilities/errorUtils'; import { @@ -94,8 +93,6 @@ export const CreateAPITokenDrawer = (props: Props) => { const expiryTups = genExpiryTups(); const { onClose, open, showSecret } = props; - const flags = useFlags(); - const initialValues = { expiry: expiryTups[0][1], label: '', @@ -204,9 +201,7 @@ export const CreateAPITokenDrawer = (props: Props) => { // Visually hide the "Child Account Access" permission even though it's still part of the base perms. const hideChildAccountAccessScope = - profile?.user_type !== 'parent' || - isChildAccountAccessRestricted || - !flags.parentChildAccountAccess; + profile?.user_type !== 'parent' || isChildAccountAccessRestricted; return ( diff --git a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.test.tsx index 05c526a8bf6..e102124db4c 100644 --- a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.test.tsx @@ -1,4 +1,4 @@ -import { act, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -33,44 +33,38 @@ describe('Edit API Token Drawer', () => { expect(cancelBtn).not.toHaveAttribute('aria-disabled', 'true'); expect(cancelBtn).toBeVisible(); }); + it('Save button should become enabled when label is changed', async () => { const { getByTestId } = renderWithTheme(); - const saveButton = getByTestId('save-button'); - expect(saveButton).toHaveAttribute('aria-disabled', 'true'); - - await act(async () => { - const labelField = getByTestId('textfield-input'); - - await userEvent.type(labelField, 'updated-token-label'); + expect(getByTestId('save-button')).toHaveAttribute('aria-disabled', 'true'); - const saveButton = getByTestId('save-button'); + const labelField = getByTestId('textfield-input'); - await waitFor(() => - expect(saveButton).toHaveAttribute('aria-disabled', 'false') - ); - }); + await userEvent.type(labelField, 'updated-token-label'); + await waitFor(() => + expect(getByTestId('save-button')).toHaveAttribute( + 'aria-disabled', + 'false' + ) + ); }); + it('Should close when updating a label and saving', async () => { // @note: this test uses handlers for PUT */profile/tokens/:id in serverHandlers.ts const { getByTestId } = renderWithTheme(); - await act(async () => { - const labelField = getByTestId('textfield-input'); - - await userEvent.type(labelField, 'my-token-updated'); - - const saveButton = getByTestId('save-button'); - - await waitFor(() => - expect(saveButton).toHaveAttribute('aria-disabled', 'false') - ); - - await userEvent.click(saveButton); + const labelField = getByTestId('textfield-input'); + await userEvent.type(labelField, 'my-token-updated'); - await waitFor(() => expect(props.onClose).toBeCalled()); - }); + const saveButton = getByTestId('save-button'); + await waitFor(() => + expect(saveButton).toHaveAttribute('aria-disabled', 'false') + ); + await userEvent.click(saveButton); + await waitFor(() => expect(props.onClose).toBeCalled()); }); + it('Should close when Cancel is pressed', async () => { const { getByText } = renderWithTheme(); const cancelButton = getByText(/Cancel/); diff --git a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx index 2d351114ce3..c64ae31140a 100644 --- a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx @@ -6,7 +6,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { useUpdatePersonalAccessTokenMutation } from 'src/queries/tokens'; +import { useUpdatePersonalAccessTokenMutation } from 'src/queries/profile/tokens'; import { getErrorMap } from 'src/utilities/errorUtils'; interface Props { diff --git a/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx b/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx index 5f0371fcb8d..039f2781ec4 100644 --- a/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx +++ b/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx @@ -8,7 +8,7 @@ import { Typography } from 'src/components/Typography'; import { useRevokeAppAccessTokenMutation, useRevokePersonalAccessTokenMutation, -} from 'src/queries/tokens'; +} from 'src/queries/profile/tokens'; import { APITokenType } from './APITokenTable'; diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx index d526fa0fe7d..7b1b44387e7 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx @@ -14,8 +14,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useProfile: queryMocks.useProfile, @@ -52,9 +52,7 @@ describe('View API Token Drawer', () => { data: profileFactory.build({ user_type: 'parent' }), }); - const { getByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { getByTestId } = renderWithTheme(); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( ariaLabel, @@ -70,8 +68,7 @@ describe('View API Token Drawer', () => { }); const { getByTestId } = renderWithTheme( - , - { flags: { parentChildAccountAccess: true } } + ); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( @@ -91,8 +88,7 @@ describe('View API Token Drawer', () => { , - { flags: { parentChildAccountAccess: true } } + /> ); for (const permissionName of basePerms) { // We only expect account to have read/write for this test @@ -117,8 +113,7 @@ describe('View API Token Drawer', () => { scopes: 'databases:read_only domains:read_write child_account:read_write events:read_write firewall:read_write images:read_write ips:read_write linodes:read_only lke:read_only longview:read_write nodebalancers:read_write object_storage:read_only stackscripts:read_write volumes:read_only vpc:read_write', })} - />, - { flags: { parentChildAccountAccess: true } } + /> ); const expectedScopeLevels = { @@ -150,29 +145,20 @@ describe('View API Token Drawer', () => { }); describe('Parent/Child: User Roles', () => { - const setupAndRender = (userType: UserType, enableFeatureFlag = true) => { + const setupAndRender = (userType: UserType) => { queryMocks.useProfile.mockReturnValue({ data: profileFactory.build({ user_type: userType }), }); - return renderWithTheme(, { - flags: { parentChildAccountAccess: enableFeatureFlag }, - }); + return renderWithTheme(); }; - const testChildScopeNotDisplayed = ( - userType: UserType, - enableFeatureFlag = true - ) => { - const { queryByText } = setupAndRender(userType, enableFeatureFlag); + const testChildScopeNotDisplayed = (userType: UserType) => { + const { queryByText } = setupAndRender(userType); const childScope = queryByText('Child Account Access'); expect(childScope).not.toBeInTheDocument(); }; - it('should not display the Child Account Access when feature flag is disabled', () => { - testChildScopeNotDisplayed('parent', false); - }); - it('should not display the Child Account Access scope for a user account without a parent user type', () => { testChildScopeNotDisplayed('default'); }); diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index 87aa61b8be6..1d1744d43f2 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -7,9 +7,8 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { StyledAccessCell, @@ -27,8 +26,6 @@ interface Props { export const ViewAPITokenDrawer = (props: Props) => { const { onClose, open, token } = props; - const flags = useFlags(); - const { data: profile } = useProfile(); const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ @@ -39,9 +36,7 @@ export const ViewAPITokenDrawer = (props: Props) => { // Visually hide the "Child Account Access" permission even though it's still part of the base perms. const hideChildAccountAccessScope = - profile?.user_type !== 'parent' || - isChildAccountAccessRestricted || - !flags.parentChildAccountAccess; + profile?.user_type !== 'parent' || isChildAccountAccessRestricted; return ( diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx index 36ffc36ddd4..c0c759fc0e4 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx @@ -9,7 +9,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Link } from 'src/components/Link'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { PhoneVerification } from './PhoneVerification/PhoneVerification'; import { ResetPassword } from './ResetPassword'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx index 351dc9ba7a8..4d94c22a06f 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx @@ -17,7 +17,7 @@ import { useProfile, useSendPhoneVerificationCodeMutation, useVerifyPhoneVerificationCodeMutation, -} from 'src/queries/profile'; +} from 'src/queries/profile/profile'; import { StyledButtonContainer, diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx index 0c11363f65c..7558603e818 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { useRevokeTrustedDeviceMutation } from 'src/queries/profile'; +import { useRevokeTrustedDeviceMutation } from 'src/queries/profile/profile'; interface Props { deviceId: number; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx index 77e4b7a5e4b..44c6969dcb7 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx @@ -9,8 +9,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { useSMSOptOutMutation } from 'src/queries/profile'; -import { useProfile } from 'src/queries/profile'; +import { useSMSOptOutMutation } from 'src/queries/profile/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getFormattedNumber } from './PhoneVerification/helpers'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/SecurityQuestions.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/SecurityQuestions.tsx index ad753e4c84e..d84d9002e53 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/SecurityQuestions.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/SecurityQuestions.tsx @@ -12,7 +12,7 @@ import { Typography } from 'src/components/Typography'; import { useMutateSecurityQuestions, useSecurityQuestions, -} from 'src/queries/securityQuestions'; +} from 'src/queries/profile/securityQuestions'; import { QuestionAndAnswerPair } from './QuestionAndAnswerPair'; import { getAnsweredQuestions, securityQuestionsToItems } from './utilities'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx index ef75958b641..3279aa424e5 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx @@ -17,7 +17,7 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useTrustedDevicesQuery } from 'src/queries/profile'; +import { useTrustedDevicesQuery } from 'src/queries/profile/profile'; import { RevokeTrustedDeviceDialog } from './RevokeTrustedDevicesDialog'; @@ -86,7 +86,7 @@ const TrustedDevices = () => { return data?.data.map((device) => { return ( - + {device.user_agent} {device.last_remote_addr} diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx index 95f722c9564..f60e346f1ec 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { useDisableTwoFactorMutation } from 'src/queries/profile'; +import { useDisableTwoFactorMutation } from 'src/queries/profile/profile'; interface Props { onClose: () => void; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx index e36913a0852..d21fb6961bf 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx @@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { useSecurityQuestions } from 'src/queries/securityQuestions'; +import { useSecurityQuestions } from 'src/queries/profile/securityQuestions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -19,7 +19,7 @@ import { StyledRootContainer, } from './TwoFactor.styles'; import { TwoFactorToggle } from './TwoFactorToggle'; -import { profileQueries } from 'src/queries/profile'; +import { profileQueries } from 'src/queries/profile/profile'; export interface TwoFactorProps { disabled?: boolean; diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.test.tsx index acd777fd456..2008bf9c339 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.test.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.test.tsx @@ -9,8 +9,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useProfile: queryMocks.useProfile, diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 17a523aa797..1a1ba9ae96b 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -15,12 +15,12 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; -import { ApplicationState } from 'src/store'; -import { sendManageGravatarEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; import { TimezoneForm } from './TimezoneForm'; +import type { ApplicationState } from 'src/store'; + export const DisplaySettings = () => { const theme = useTheme(); const { mutateAsync: updateProfile } = useMutateProfile(); @@ -103,7 +103,6 @@ export const DisplaySettings = () => { marginTop: '-2px', padding: 0, }} - interactive status="help" text={tooltipIconText} /> @@ -112,11 +111,7 @@ export const DisplaySettings = () => { Create, upload, and manage your globally recognized avatar from a single place with Gravatar. - sendManageGravatarEvent()} - to="https://en.gravatar.com/" - > + Manage photo diff --git a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx index 65c41cc514e..9a1cd6bb1d1 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx @@ -9,7 +9,7 @@ import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { Typography } from 'src/components/Typography'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; interface Props { loggedInAsCustomer: boolean; diff --git a/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx b/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx index 56cafc8d409..1aa5aaa06d5 100644 --- a/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx +++ b/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx @@ -14,7 +14,7 @@ import { Paper } from 'src/components/Paper'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { FormControl } from 'src/components/FormControl'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx index b82ba542499..974e8726b88 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx @@ -90,7 +90,7 @@ const OAuthClients = () => { } return data?.data.map(({ id, label, public: isPublic, redirect_uri }) => ( - + {label} {isPublic ? 'Public' : 'Private'} diff --git a/packages/manager/src/features/Profile/Referrals/Referrals.tsx b/packages/manager/src/features/Profile/Referrals/Referrals.tsx index 763b35a0a06..8bc0de474e5 100644 --- a/packages/manager/src/features/Profile/Referrals/Referrals.tsx +++ b/packages/manager/src/features/Profile/Referrals/Referrals.tsx @@ -4,14 +4,14 @@ import * as React from 'react'; import Step1 from 'src/assets/referrals/step-1.svg'; import Step2 from 'src/assets/referrals/step-2.svg'; import Step3 from 'src/assets/referrals/step-3.svg'; -import { CircularProgress } from 'src/components/CircularProgress'; +import { CircleProgress } from 'src/components/CircleProgress'; import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { @@ -46,7 +46,7 @@ export const Referrals = () => { } if (profileLoading || !profile) { - return ; + return ; } const { completed, credit, pending, total, url } = profile?.referrals; diff --git a/packages/manager/src/features/Profile/SSHKeys/CreateSSHKeyDrawer.tsx b/packages/manager/src/features/Profile/SSHKeys/CreateSSHKeyDrawer.tsx index de17e09d68e..6073e28691a 100644 --- a/packages/manager/src/features/Profile/SSHKeys/CreateSSHKeyDrawer.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/CreateSSHKeyDrawer.tsx @@ -9,7 +9,7 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { useCreateSSHKeyMutation } from 'src/queries/profile'; +import { useCreateSSHKeyMutation } from 'src/queries/profile/profile'; import { handleFormikBlur } from 'src/utilities/formikTrimUtil'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; diff --git a/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx b/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx index 7aa868f566d..7b72b3913d7 100644 --- a/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { useDeleteSSHKeyMutation } from 'src/queries/profile'; +import { useDeleteSSHKeyMutation } from 'src/queries/profile/profile'; interface Props { id: number; diff --git a/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx b/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx index 0861f2e38da..a1c09874fae 100644 --- a/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx @@ -8,7 +8,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { useUpdateSSHKeyMutation } from 'src/queries/profile'; +import { useUpdateSSHKeyMutation } from 'src/queries/profile/profile'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; interface Props { diff --git a/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx b/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx index 1bec2a5ca14..91afe803212 100644 --- a/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx @@ -18,7 +18,7 @@ import { Typography } from 'src/components/Typography'; import DeleteSSHKeyDialog from 'src/features/Profile/SSHKeys/DeleteSSHKeyDialog'; import SSHKeyActionMenu from 'src/features/Profile/SSHKeys/SSHKeyActionMenu'; import { usePagination } from 'src/hooks/usePagination'; -import { useSSHKeysQuery } from 'src/queries/profile'; +import { useSSHKeysQuery } from 'src/queries/profile/profile'; import { parseAPIDate } from 'src/utilities/date'; import { getSSHKeyFingerprint } from 'src/utilities/ssh-fingerprint'; diff --git a/packages/manager/src/features/Profile/Settings/PreferenceEditor.tsx b/packages/manager/src/features/Profile/Settings/PreferenceEditor.tsx index 6eb633a8465..dd2fc9ac06c 100644 --- a/packages/manager/src/features/Profile/Settings/PreferenceEditor.tsx +++ b/packages/manager/src/features/Profile/Settings/PreferenceEditor.tsx @@ -6,7 +6,10 @@ import { Dialog, DialogProps } from 'src/components/Dialog/Dialog'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; type Props = Pick; diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index 61aa3b0726b..f383fdb5279 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -10,8 +10,11 @@ import { RadioGroup } from 'src/components/RadioGroup'; import { Stack } from 'src/components/Stack'; import { Toggle } from 'src/components/Toggle/Toggle'; import { Typography } from 'src/components/Typography'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; import { ThemeChoice } from 'src/utilities/theme'; import { isOSMac } from 'src/utilities/userAgent'; diff --git a/packages/manager/src/features/Search/ResultRow.tsx b/packages/manager/src/features/Search/ResultRow.tsx index d06ea4efd0d..0fcb85abc83 100644 --- a/packages/manager/src/features/Search/ResultRow.tsx +++ b/packages/manager/src/features/Search/ResultRow.tsx @@ -24,7 +24,7 @@ export const ResultRow = (props: ResultRowProps) => { const { result } = props; return ( - + {result.label} diff --git a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptsSection.tsx b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptsSection.tsx index 0d1830bd647..a207afdc66b 100644 --- a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptsSection.tsx +++ b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptsSection.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; import { TableBody } from 'src/components/TableBody'; import { TableRow } from 'src/components/TableRow'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { truncate } from 'src/utilities/truncate'; diff --git a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx index 0bfa67c876d..8d253c45ca3 100644 --- a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx @@ -93,7 +93,7 @@ export class StackScriptSelectionRow extends React.Component< }; return ( - + ( */} {gettingMoreStackScripts && !isSorting && (
    - +
    )}
    diff --git a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx index bebbc1f6b8e..bc39cd3ba1c 100644 --- a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx @@ -37,7 +37,7 @@ import { filterImagesByType } from 'src/store/image/image.helpers'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { storage } from 'src/utilities/storage'; -import { profileQueries } from 'src/queries/profile'; +import { profileQueries } from 'src/queries/profile/profile'; interface State { apiResponse?: StackScript; diff --git a/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.styles.ts b/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.styles.ts index 44e0b04283c..296fda0b312 100644 --- a/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.styles.ts +++ b/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.styles.ts @@ -32,7 +32,7 @@ export const StyledTextField = styled(TextField, { label: 'StyledTextField' })({ export const StyledNotice = styled(Notice, { label: 'StyledNotice' })( ({ theme }) => ({ - backgroundColor: theme.palette.divider, + backgroundColor: theme.palette.background.default, marginLeft: theme.spacing(4), marginTop: `${theme.spacing(4)} !important`, padding: theme.spacing(4), diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx index 9e6a96efd8c..6fd4ddfb56b 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx @@ -6,7 +6,7 @@ import { useHistory } from 'react-router-dom'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Hidden } from 'src/components/Hidden'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { StackScriptCategory, getStackScriptUrl } from '../stackScriptUtils'; diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx index 65bea35b05b..d8874623669 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx @@ -6,7 +6,7 @@ import { compose } from 'recompose'; import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { RenderGuard } from 'src/components/RenderGuard'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getCommunityStackscripts, diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptRow.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptRow.tsx index d66f06d082d..c87b2cc7a7e 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptRow.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptRow.tsx @@ -78,7 +78,7 @@ export const StackScriptRow = (props: Props) => { }; return ( - + {renderLabel()} diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx index 53006820b7b..cb6c1bce806 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx @@ -9,7 +9,7 @@ import { StackScriptCategory, canUserModifyAccountStackScript, } from 'src/features/StackScripts/stackScriptUtils'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { stripImageName } from 'src/utilities/stripImageName'; diff --git a/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx b/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx index 37fc05b2f30..3ea0651917c 100644 --- a/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx @@ -12,7 +12,7 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { NotFound } from 'src/components/NotFound'; import { StackScript as _StackScript } from 'src/components/StackScript/StackScript'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx index 2b787736273..61d1a9325c9 100644 --- a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx +++ b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx @@ -14,7 +14,6 @@ interface Props { isPassword?: boolean; placeholder?: string; tooltip?: JSX.Element; - tooltipInteractive?: boolean; updateFormState: (key: string, value: any) => void; value: string; } @@ -41,14 +40,7 @@ class UserDefinedText extends React.Component { }; renderPasswordField = () => { - const { - error, - field, - isOptional, - placeholder, - tooltip, - tooltipInteractive, - } = this.props; + const { error, field, isOptional, placeholder, tooltip } = this.props; return ( { password={this.props.value} placeholder={placeholder} required={!isOptional} - tooltipInteractive={tooltipInteractive} /> ); }; diff --git a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx index ab85913ec11..691a0549165 100644 --- a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx +++ b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx @@ -102,7 +102,6 @@ const renderField = ( isOptional={isOptional} isPassword={true} placeholder={isTokenPassword ? 'Enter your token' : field.example} - tooltipInteractive={isTokenPassword} updateFor={[field.label, udf_data[field.name], error]} updateFormState={handleChange} /** diff --git a/packages/manager/src/features/StackScripts/stackScriptUtils.ts b/packages/manager/src/features/StackScripts/stackScriptUtils.ts index f66cb6420c1..144645ead73 100644 --- a/packages/manager/src/features/StackScripts/stackScriptUtils.ts +++ b/packages/manager/src/features/StackScripts/stackScriptUtils.ts @@ -2,7 +2,7 @@ import { Grant } from '@linode/api-v4/lib/account'; import { StackScript, getStackScripts } from '@linode/api-v4/lib/stackscripts'; import { Filter, Params, ResourcePage } from '@linode/api-v4/lib/types'; -import { StackScriptsRequest } from './types'; +import type { StackScriptsRequest } from './types'; export type StackScriptCategory = 'account' | 'community'; @@ -13,126 +13,6 @@ export const emptyResult: ResourcePage = { results: 0, }; -/** - * We need a way to make sure that newly added SS that meet - * our filtering criteria don't automatically end up being - * shown to the user before we've updated Cloud to support them. - */ -export const baseApps = { - '401697': 'WordPress - Latest One-Click', - '401698': 'Drupal - Latest One-Click', - '401701': 'LAMP One-Click', - '401702': 'MERN One-Click', - '401706': 'WireGuard - Latest One-Click', - '401707': 'GitLab - Latest One-Click', - '401708': 'WooCommerce - Latest One-Click', - '401709': 'Minecraft - Latest One-Click', - '401719': 'OpenVPN - Latest One-Click', - '593835': 'Plesk One-Click', - '595742': 'cPanel One-Click', - '604068': 'Shadowsocks - Latest One-Click', - '606691': 'LEMP - Latest One-Click', - '607026': 'MySQL - Latest One-Click', - '607401': 'Jenkins - Latest One-Click', - '607433': 'Docker - Latest One-Click', - '607488': 'Redis One-Click', - '609018': 'phpMYAdmin', - '609048': 'Ruby on Rails One-Click', - '609175': 'Django One-Click', - '609392': 'Flask One-Click', - '611376': 'PostgreSQL One-Click', - '611895': 'MEAN One-Click', - '632758': 'Nextcloud', - '662118': 'Azuracast', - '662119': 'Plex', - '662121': 'Jitsi', - '688890': 'RabbitMQ', - '688891': 'Discourse', - '688902': 'Webuzo', - '688903': 'Code Server', - '688911': 'Gitea', - '688912': 'Kepler Builder One-Click', - '688914': 'Guacamole', - '691620': 'FileCloud', - '691621': 'Cloudron', - '691622': 'OpenLiteSpeed', - '692092': 'Secure Your Server', - '741206': 'CyberPanel', - '741207': 'Yacht', - '741208': 'Zabbix', - '774829': 'ServerWand', - '804143': 'Peppermint', - '804144': 'Ant Media Server', - '804172': 'Owncast', - '869127': 'Moodle', - '869129': 'aaPanel', - '869153': 'Splunk', - '869155': 'Chevereto', - '869156': 'NirvaShare', - '869158': 'ClusterControl', - '869623': 'JetBackup', - '912262': 'Harbor', - '912264': 'Rocket.Chat', - '913276': 'Wazuh', - '913277': 'BeEF', - '923029': 'OpenLiteSpeed Django', - '923030': 'OpenLiteSpeed Rails', - '923031': 'OpenLiteSpeed NodeJS', - '923032': 'LiteSpeed cPanel', - '923033': 'Akaunting', - '923036': 'Restyaboard', - '923037': 'WarpSpeed', - '925530': 'UTunnel VPN', - '925722': 'Pritunl', - '954759': 'VictoriaMetrics', - '970522': 'Pi-hole', - '970523': 'Uptime Kuma', - '970559': 'Grav', - '970561': 'NodeJS', - '971042': 'Saltcorn', - '971043': 'Odoo', - '971045': 'Focalboard', - '985364': 'Prometheus & Grafana', - '985372': 'Joomla', - '985374': 'Ant Media Enterprise Edition', - '985380': 'Joplin', - '1008123': 'Liveswitch', - '1008125': 'Easypanel', - '1017300': 'Kali Linux', - '1037036': 'Budibase', - '1037037': 'HashiCorp Nomad', - '1037038': 'HashiCorp Vault', - '1051714': 'Microweber', - '1068726': 'PostgreSQL Cluster', - '1088136': 'Galera Cluster', - '1096122': 'Mastodon', - '1102900': 'Apache Airflow', - '1102902': 'HaltDOS Community WAF', - '1102904': 'Superinsight', - '1102905': 'Gopaddle', - '1102906': 'Passky', - '1102907': 'ONLYOFFICE Docs', - '1132204': 'Redis Sentinel Cluster', - '1160816': 'ownCloud', - '1160820': 'Appwrite', - '1177225': 'Seatable', - '1177605': 'Illa Builder', - '1226544': 'HashiCorp Nomad Cluster', - '1226545': 'HashiCorp Nomad Clients Cluster', - '1243759': 'MainConcept FFmpeg Plugins Demo', - '1243760': 'MainConcept Live Encoder Demo', - '1243762': 'MainConcept P2 AVC ULTRA Transcoder Demo', - '1243763': 'MainConcept XAVC Transcoder Demo', - '1243764': 'MainConcept XDCAM Transcoder Demo', - '1243780': 'SimpleX Chat', - '1298017': 'JupyterLab', - '1308539': 'NATS Single Node', - '1329430': 'Passbolt', - '1329462': 'LinuxGSM', - '1350733': 'Jitsi Cluster', - '1350783': 'GlusterFS Cluster', -}; - const oneClickFilter = [ { '+and': [ diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SeverityChip.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SeverityChip.tsx index 570b6a891f5..8cc7fd08521 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SeverityChip.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SeverityChip.tsx @@ -1,9 +1,11 @@ -import { TicketSeverity } from '@linode/api-v4'; import React from 'react'; -import { Chip, ChipProps } from 'src/components/Chip'; +import { Chip } from 'src/components/Chip'; -import { severityLabelMap } from '../SupportTickets/ticketUtils'; +import { SEVERITY_LABEL_MAP } from '../SupportTickets/constants'; + +import type { TicketSeverity } from '@linode/api-v4'; +import type { ChipProps } from 'src/components/Chip'; const severityColorMap: Record = { 1: 'error', @@ -14,7 +16,7 @@ const severityColorMap: Record = { export const SeverityChip = ({ severity }: { severity: TicketSeverity }) => ( ({ padding: theme.spacing() })} /> ); diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx index 28dcbb0ebfe..2a7ffe83198 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx @@ -11,7 +11,7 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { Stack } from 'src/components/Stack'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useInfiniteSupportTicketRepliesQuery, useSupportTicketQuery, @@ -125,7 +125,7 @@ export const SupportTicketDetail = () => { ticketUpdated={ticket ? ticket.updated : ''} /> ))} - {repliesLoading && } + {repliesLoading && } {repliesError ? ( ) : null} diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx index 954f8b0d4cc..79f3d52f439 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx @@ -9,7 +9,7 @@ import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; import { getLinkTargets } from 'src/utilities/getEventsActionLink'; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index 4069ef30fd5..c882264048d 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -1,12 +1,8 @@ -import { - TicketSeverity, - createSupportTicket, - uploadAttachment, -} from '@linode/api-v4/lib/support'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { uploadAttachment } from '@linode/api-v4/lib/support'; import { update } from 'ramda'; import * as React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; @@ -14,41 +10,36 @@ import { Accordion } from 'src/components/Accordion'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Dialog } from 'src/components/Dialog/Dialog'; -import { FormHelperText } from 'src/components/FormHelperText'; -import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; -import { EntityForTicketDetails } from 'src/components/SupportLink/SupportLink'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { useAccount } from 'src/queries/account/account'; -import { useAllDatabasesQuery } from 'src/queries/databases'; -import { useAllDomainsQuery } from 'src/queries/domains'; -import { useAllFirewallsQuery } from 'src/queries/firewalls'; -import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; -import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; -import { - getAPIErrorOrDefault, - getErrorMap, - getErrorStringOrDefault, -} from 'src/utilities/errorUtils'; +import { useCreateSupportTicketMutation } from 'src/queries/support'; +import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { reduceAsync } from 'src/utilities/reduceAsync'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { storage } from 'src/utilities/storage'; import { AttachFileForm } from '../AttachFileForm'; -import { FileAttachment } from '../index'; -import { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; import { MarkdownReference } from '../SupportTicketDetail/TabbedReply/MarkdownReference'; import { TabbedReply } from '../SupportTicketDetail/TabbedReply/TabbedReply'; -import { TICKET_SEVERITY_TOOLTIP_TEXT } from './constants'; -import SupportTicketSMTPFields, { - fieldNameToLabelMap, - smtpDialogTitle, - smtpHelperText, -} from './SupportTicketSMTPFields'; -import { severityLabelMap, useTicketSeverityCapability } from './ticketUtils'; +import { + ENTITY_ID_TO_NAME_MAP, + SCHEMA_MAP, + SEVERITY_LABEL_MAP, + SEVERITY_OPTIONS, + TICKET_SEVERITY_TOOLTIP_TEXT, + TICKET_TYPE_MAP, +} from './constants'; +import { SupportTicketProductSelectionFields } from './SupportTicketProductSelectionFields'; +import { SupportTicketSMTPFields } from './SupportTicketSMTPFields'; +import { formatDescription, useTicketSeverityCapability } from './ticketUtils'; + +import type { FileAttachment } from '../index'; +import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; +import type { SMTPCustomFields } from './SupportTicketSMTPFields'; +import type { TicketSeverity } from '@linode/api-v4/lib/support'; +import type { Theme } from '@mui/material/styles'; +import type { EntityForTicketDetails } from 'src/components/SupportLink/SupportLink'; const useStyles = makeStyles()((theme: Theme) => ({ expPanelSummary: { @@ -66,6 +57,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ rootReply: { marginBottom: theme.spacing(2), padding: 0, + marginTop: theme.spacing(2), }, })); @@ -92,7 +84,10 @@ export type EntityType = export type TicketType = 'general' | 'smtp'; -interface TicketTypeData { +export type AllSupportTicketFormFields = SupportTicketFormFields & + SMTPCustomFields; + +export interface TicketTypeData { dialogTitle: string; helperText: JSX.Element | string; } @@ -110,53 +105,15 @@ export interface SupportTicketDialogProps { prefilledTitle?: string; } -const ticketTypeMap: Record = { - general: { - dialogTitle: 'Open a Support Ticket', - helperText: ( - <> - {`We love our customers, and we\u{2019}re here to help if you need us. - Please keep in mind that not all topics are within the scope of our support. - For overall system status, please see `} - status.linode.com. - - ), - }, - smtp: { - dialogTitle: smtpDialogTitle, - helperText: smtpHelperText, - }, -}; - -const entityMap: Record = { - Databases: 'database_id', - Domains: 'domain_id', - Firewalls: 'firewall_id', - Kubernetes: 'lkecluster_id', - Linodes: 'linode_id', - NodeBalancers: 'nodebalancer_id', - Volumes: 'volume_id', -}; - -const entityIdToNameMap: Record = { - database_id: 'Database Cluster', - domain_id: 'Domain', - firewall_id: 'Firewall', - general: '', - linode_id: 'Linode', - lkecluster_id: 'Kubernetes Cluster', - nodebalancer_id: 'NodeBalancer', - none: '', - volume_id: 'Volume', -}; - -const severityOptions: { - label: string; - value: TicketSeverity; -}[] = Array.from(severityLabelMap).map(([severity, label]) => ({ - label, - value: severity, -})); +export interface SupportTicketFormFields { + description: string; + entityId: string; + entityInputValue: string; + entityType: EntityType; + selectedSeverity: TicketSeverity | undefined; + summary: string; + ticketType: TicketType; +} export const entitiesToItems = (type: string, entities: any) => { return entities.map((entity: any) => { @@ -183,46 +140,41 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { prefilledTitle, } = props; - const { data: account } = useAccount(); + const formContainerRef = React.useRef(null); const hasSeverityCapability = useTicketSeverityCapability(); const valuesFromStorage = storage.supportText.get(); // Ticket information - const [summary, setSummary] = React.useState( - getInitialValue(prefilledTitle, valuesFromStorage.title) - ); - const [ + const form = useForm({ + defaultValues: { + description: getInitialValue( + prefilledDescription, + valuesFromStorage.description + ), + entityId: prefilledEntity ? String(prefilledEntity.id) : '', + entityInputValue: '', + entityType: prefilledEntity?.type ?? 'general', + summary: getInitialValue(prefilledTitle, valuesFromStorage.title), + ticketType: prefilledTicketType ?? 'general', + }, + resolver: yupResolver(SCHEMA_MAP[prefilledTicketType ?? 'general']), + }); + + const { + description, + entityId, + entityType, selectedSeverity, - setSelectedSeverity, - ] = React.useState(); - const [description, setDescription] = React.useState( - getInitialValue(prefilledDescription, valuesFromStorage.description) - ); - const [entityType, setEntityType] = React.useState( - prefilledEntity?.type ?? 'general' - ); - const [entityInputValue, setEntityInputValue] = React.useState(''); - const [entityID, setEntityID] = React.useState( - prefilledEntity ? String(prefilledEntity.id) : '' - ); - const [ticketType, setTicketType] = React.useState( - prefilledTicketType ?? 'general' - ); + summary, + ticketType, + } = form.watch(); - // SMTP ticket information - const [smtpFields, setSMTPFields] = React.useState({ - companyName: '', - customerName: account ? `${account?.first_name} ${account?.last_name}` : '', - emailDomains: '', - publicInfo: '', - useCase: '', - }); + const { mutateAsync: createSupportTicket } = useCreateSupportTicketMutation(); const [files, setFiles] = React.useState([]); - const [errors, setErrors] = React.useState(); const [submitting, setSubmitting] = React.useState(false); const { classes } = useStyles(); @@ -233,48 +185,6 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { } }, [open]); - // React Query entities - const { - data: databases, - error: databasesError, - isLoading: databasesLoading, - } = useAllDatabasesQuery(entityType === 'database_id'); - - const { - data: firewalls, - error: firewallsError, - isLoading: firewallsLoading, - } = useAllFirewallsQuery(entityType === 'firewall_id'); - - const { - data: domains, - error: domainsError, - isLoading: domainsLoading, - } = useAllDomainsQuery(entityType === 'domain_id'); - const { - data: nodebalancers, - error: nodebalancersError, - isLoading: nodebalancersLoading, - } = useAllNodeBalancersQuery(entityType === 'nodebalancer_id'); - - const { - data: clusters, - error: clustersError, - isLoading: clustersLoading, - } = useAllKubernetesClustersQuery(entityType === 'lkecluster_id'); - - const { - data: linodes, - error: linodesError, - isLoading: linodesLoading, - } = useAllLinodesQuery({}, {}, entityType === 'linode_id'); - - const { - data: volumes, - error: volumesError, - isLoading: volumesLoading, - } = useAllVolumesQuery({}, {}, entityType === 'volume_id'); - const saveText = (_title: string, _description: string) => { storage.supportText.set({ description: _description, title: _title }); }; @@ -287,22 +197,19 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { debouncedSave(summary, description); }, [summary, description]); + /** + * Clear the drawer completely if clearValues is passed (when canceling out of the drawer or successfully submitting) + * or reset to the default values (from localStorage) otherwise. + */ const resetTicket = (clearValues: boolean = false) => { - /** - * Clear the drawer completely if clearValues is passed (as in when closing the drawer) - * or reset to the default values (from props or localStorage) otherwise. - */ - const _summary = clearValues - ? '' - : getInitialValue(prefilledTitle, valuesFromStorage.title); - const _description = clearValues - ? '' - : getInitialValue(prefilledDescription, valuesFromStorage.description); - setSummary(_summary); - setDescription(_description); - setEntityID(''); - setEntityType('general'); - setTicketType('general'); + form.reset({ + ...form.formState.defaultValues, + description: clearValues ? '' : valuesFromStorage.description, + entityId: '', + entityType: 'general', + summary: clearValues ? '' : valuesFromStorage.title, + ticketType: 'general', + }); }; const resetDrawer = (clearValues: boolean = false) => { @@ -314,51 +221,14 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { } }; - const handleSummaryInputChange = (e: React.ChangeEvent) => { - setSummary(e.target.value); - }; - - const handleDescriptionInputChange = (value: string) => { - setDescription(value); - // setErrors? - }; - - const handleEntityTypeChange = (type: EntityType) => { - // Don't reset things if the type hasn't changed - if (type === entityType) { - return; - } - setEntityType(type); - setEntityID(''); - setEntityInputValue(''); - }; - - const handleSMTPFieldChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setSMTPFields((smtpFields) => ({ ...smtpFields, [name]: value })); - }; - - /** - * When variant ticketTypes include additional fields, fields must concat to one description string. - * For readability, replace field names with field labels and format the description in Markdown. - */ - const formatDescription = (fields: Record) => { - return Object.entries(fields) - .map( - ([key, value]) => - `**${fieldNameToLabelMap[key]}**\n${value ? value : 'No response'}` - ) - .join('\n\n'); - }; - - const close = () => { + const handleClose = () => { props.onClose(); if (ticketType === 'smtp') { window.setTimeout(() => resetDrawer(true), 500); } }; - const onCancel = () => { + const handleCancel = () => { props.onClose(); window.setTimeout(() => resetDrawer(true), 500); }; @@ -431,38 +301,35 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { }); }; - const onSubmit = () => { + const handleSubmit = form.handleSubmit(async (values) => { const { onSuccess } = props; - const _description = - ticketType === 'smtp' ? formatDescription(smtpFields) : description; - if (!['general', 'none'].includes(entityType) && !entityID) { - setErrors([ - { - field: 'input', - reason: `Please select a ${entityIdToNameMap[entityType]}.`, - }, - ]); + + const _description = formatDescription(values, ticketType); + + if (!['general', 'none'].includes(entityType) && !entityId) { + form.setError('entityId', { + message: `Please select a ${ENTITY_ID_TO_NAME_MAP[entityType]}.`, + }); + return; } - setErrors(undefined); setSubmitting(true); createSupportTicket({ description: _description, - [entityType]: Number(entityID), + [entityType]: Number(entityId), severity: selectedSeverity, summary, }) .then((response) => { - setErrors(undefined); - setSubmitting(false); - window.setTimeout(() => resetDrawer(true), 500); return response; }) .then((response) => { attachFiles(response!.id).then(({ errors: _errors }: Accumulator) => { + setSubmitting(false); if (!props.keepOpenOnSuccess) { - close(); + window.setTimeout(() => resetDrawer(true), 500); + props.onClose(); } /* Errors will be an array of errors, or empty if all attachments succeeded. */ onSuccess(response!.id, _errors); @@ -471,113 +338,21 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { .catch((errResponse) => { /* This block will only handle errors in creating the actual ticket; attachment * errors are handled above. */ - setErrors(getAPIErrorOrDefault(errResponse)); + for (const error of errResponse) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + form.setError('root', { message: error.reason }); + } + } + setSubmitting(false); - scrollErrorIntoView(); + scrollErrorIntoViewV2(formContainerRef); }); - }; - - const renderEntityTypes = () => { - return Object.keys(entityMap).map((key: string) => { - return { label: key, value: entityMap[key] }; - }); - }; - - const smtpRequirementsMet = - smtpFields.customerName.length > 0 && - smtpFields.useCase.length > 0 && - smtpFields.emailDomains.length > 0 && - smtpFields.publicInfo.length > 0; - const requirementsMet = - summary.length > 0 && - (ticketType === 'smtp' ? smtpRequirementsMet : description.length > 0); - - const hasErrorFor = getErrorMap(['summary', 'description', 'input'], errors); - const summaryError = hasErrorFor.summary; - const descriptionError = hasErrorFor.description; - const generalError = hasErrorFor.none; - const inputError = hasErrorFor.input; - - const topicOptions: { label: string; value: EntityType }[] = [ - { label: 'General/Account/Billing', value: 'general' }, - ...renderEntityTypes(), - ]; - - const selectedTopic = topicOptions.find((eachTopic) => { - return eachTopic.value === entityType; }); - const getEntityOptions = (): { label: string; value: number }[] => { - const reactQueryEntityDataMap = { - database_id: databases, - domain_id: domains, - firewall_id: firewalls, - linode_id: linodes, - lkecluster_id: clusters, - nodebalancer_id: nodebalancers, - volume_id: volumes, - }; - - if (!reactQueryEntityDataMap[entityType]) { - return []; - } - - // domain's don't have a label so we map the domain as the label - if (entityType === 'domain_id') { - return ( - reactQueryEntityDataMap[entityType]?.map(({ domain, id }) => ({ - label: domain, - value: id, - })) || [] - ); - } - - return ( - reactQueryEntityDataMap[entityType]?.map( - ({ id, label }: { id: number; label: string }) => ({ - label, - value: id, - }) - ) || [] - ); - }; - - const loadingMap: Record = { - database_id: databasesLoading, - domain_id: domainsLoading, - firewall_id: firewallsLoading, - general: false, - linode_id: linodesLoading, - lkecluster_id: clustersLoading, - nodebalancer_id: nodebalancersLoading, - none: false, - volume_id: volumesLoading, - }; - - const errorMap: Record = { - database_id: databasesError, - domain_id: domainsError, - firewall_id: firewallsError, - general: null, - linode_id: linodesError, - lkecluster_id: clustersError, - nodebalancer_id: nodebalancersError, - none: null, - volume_id: volumesError, - }; - - const entityOptions = getEntityOptions(); - const areEntitiesLoading = loadingMap[entityType]; - const entityError = Boolean(errorMap[entityType]) - ? `Error loading ${entityIdToNameMap[entityType]}s` - : undefined; - - const selectedEntity = - entityOptions.find((thisEntity) => String(thisEntity.value) === entityID) || - null; - const selectedSeverityLabel = - selectedSeverity && severityLabelMap.get(selectedSeverity); + selectedSeverity && SEVERITY_LABEL_MAP.get(selectedSeverity); const selectedSeverityOption = selectedSeverity != undefined && selectedSeverityLabel != undefined ? { @@ -587,106 +362,94 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { : undefined; return ( - - {props.children || ( - - {generalError && ( - - )} + +
    + + {props.children || ( + <> + {form.formState.errors.root && ( + + )} - - {ticketTypeMap[ticketType].helperText} - - - {hasSeverityCapability && ( - - setSelectedSeverity( - severity != null ? severity.value : undefined - ) - } - textFieldProps={{ - tooltipPosition: 'right', - tooltipText: TICKET_SEVERITY_TOOLTIP_TEXT, - }} - autoHighlight - clearOnBlur - data-qa-ticket-severity - label="Severity" - options={severityOptions} - sx={{ maxWidth: 'initial' }} - value={selectedSeverityOption ?? null} - /> + + {TICKET_TYPE_MAP[ticketType].helperText} + + ( + + )} + control={form.control} + name="summary" + /> + {hasSeverityCapability && ( + ( + + field.onChange( + severity != null ? severity.value : undefined + ) + } + textFieldProps={{ + tooltipPosition: 'right', + tooltipText: TICKET_SEVERITY_TOOLTIP_TEXT, + }} + autoHighlight + data-qa-ticket-severity + label="Severity" + options={SEVERITY_OPTIONS} + sx={{ maxWidth: 'initial' }} + value={selectedSeverityOption ?? null} + /> + )} + control={form.control} + name="selectedSeverity" + /> + )} + )} {ticketType === 'smtp' ? ( - + ) : ( - + <> {props.hideProductSelection ? null : ( - - handleEntityTypeChange(type.value)} - options={topicOptions} - value={selectedTopic} - /> - {!['general', 'none'].includes(entityType) && ( - <> - - setEntityID(id ? String(id?.value) : '') - } - data-qa-ticket-entity-id - disabled={entityOptions.length === 0} - errorText={entityError || inputError} - inputValue={entityInputValue} - label={entityIdToNameMap[entityType] ?? 'Entity Select'} - loading={areEntitiesLoading} - onInputChange={(e, value) => setEntityInputValue(value)} - options={entityOptions} - placeholder={`Select a ${entityIdToNameMap[entityType]}`} - value={selectedEntity} - /> - {!areEntitiesLoading && entityOptions.length === 0 ? ( - - You don’t have any{' '} - {entityIdToNameMap[entityType]}s on your account. - - ) : null} - - )} - + )} - ( + + )} + control={form.control} + name="description" /> { - + )} -
    - )} -
    + + + ); }; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx new file mode 100644 index 00000000000..57bebb30a6c --- /dev/null +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { FormHelperText } from 'src/components/FormHelperText'; +import { useAllDatabasesQuery } from 'src/queries/databases/databases'; +import { useAllDomainsQuery } from 'src/queries/domains'; +import { useAllFirewallsQuery } from 'src/queries/firewalls'; +import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; +import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; + +import { ENTITY_ID_TO_NAME_MAP, ENTITY_MAP } from './constants'; + +import type { + EntityType, + SupportTicketFormFields, +} from './SupportTicketDialog'; +import type { APIError } from '@linode/api-v4'; + +export const SupportTicketProductSelectionFields = () => { + const { + control, + setValue, + watch, + formState: { errors }, + clearErrors, + } = useFormContext(); + + const { entityId, entityInputValue, entityType } = watch(); + + // React Query entities + const { + data: databases, + error: databasesError, + isLoading: databasesLoading, + } = useAllDatabasesQuery(entityType === 'database_id'); + + const { + data: firewalls, + error: firewallsError, + isLoading: firewallsLoading, + } = useAllFirewallsQuery(entityType === 'firewall_id'); + + const { + data: domains, + error: domainsError, + isLoading: domainsLoading, + } = useAllDomainsQuery(entityType === 'domain_id'); + + const { + data: nodebalancers, + error: nodebalancersError, + isLoading: nodebalancersLoading, + } = useAllNodeBalancersQuery(entityType === 'nodebalancer_id'); + + const { + data: clusters, + error: clustersError, + isLoading: clustersLoading, + } = useAllKubernetesClustersQuery(entityType === 'lkecluster_id'); + + const { + data: linodes, + error: linodesError, + isLoading: linodesLoading, + } = useAllLinodesQuery({}, {}, entityType === 'linode_id'); + + const { + data: volumes, + error: volumesError, + isLoading: volumesLoading, + } = useAllVolumesQuery({}, {}, entityType === 'volume_id'); + + const getEntityOptions = (): { label: string; value: number }[] => { + const reactQueryEntityDataMap = { + database_id: databases, + domain_id: domains, + firewall_id: firewalls, + linode_id: linodes, + lkecluster_id: clusters, + nodebalancer_id: nodebalancers, + volume_id: volumes, + }; + + if (!reactQueryEntityDataMap[entityType]) { + return []; + } + + // Domains don't have a label so we map the domain as the label + if (entityType === 'domain_id') { + return ( + reactQueryEntityDataMap[entityType]?.map(({ domain, id }) => ({ + label: domain, + value: id, + })) || [] + ); + } + + return ( + reactQueryEntityDataMap[entityType]?.map( + ({ id, label }: { id: number; label: string }) => ({ + label, + value: id, + }) + ) || [] + ); + }; + + const loadingMap: Record = { + database_id: databasesLoading, + domain_id: domainsLoading, + firewall_id: firewallsLoading, + general: false, + linode_id: linodesLoading, + lkecluster_id: clustersLoading, + nodebalancer_id: nodebalancersLoading, + none: false, + volume_id: volumesLoading, + }; + + const errorMap: Record = { + database_id: databasesError, + domain_id: domainsError, + firewall_id: firewallsError, + general: null, + linode_id: linodesError, + lkecluster_id: clustersError, + nodebalancer_id: nodebalancersError, + none: null, + volume_id: volumesError, + }; + + const entityOptions = getEntityOptions(); + const areEntitiesLoading = loadingMap[entityType]; + const entityError = Boolean(errorMap[entityType]) + ? `Error loading ${ENTITY_ID_TO_NAME_MAP[entityType]}s` + : undefined; + + const selectedEntity = + entityOptions.find((thisEntity) => String(thisEntity.value) === entityId) || + null; + + const renderEntityTypes = () => { + return Object.keys(ENTITY_MAP).map((key: string) => { + return { label: key, value: ENTITY_MAP[key] }; + }); + }; + + const topicOptions: { label: string; value: EntityType }[] = [ + { label: 'General/Account/Billing', value: 'general' }, + ...renderEntityTypes(), + ]; + + const selectedTopic = topicOptions.find((eachTopic) => { + return eachTopic.value === entityType; + }); + + return ( + <> + ( + { + // Don't reset things if the type hasn't changed. + if (type.value === entityType) { + return; + } + field.onChange(type.value); + setValue('entityId', ''); + setValue('entityInputValue', ''); + clearErrors('entityId'); + }} + data-qa-ticket-entity-type + disableClearable + label="What is this regarding?" + options={topicOptions} + value={selectedTopic} + /> + )} + control={control} + name="entityType" + /> + {!['general', 'none'].includes(entityType) && ( + <> + ( + + setValue('entityId', id ? String(id?.value) : '') + } + data-qa-ticket-entity-id + disabled={entityOptions.length === 0} + errorText={ + entityError || + fieldState.error?.message || + errors.entityId?.message + } + inputValue={entityInputValue} + label={ENTITY_ID_TO_NAME_MAP[entityType] ?? 'Entity Select'} + loading={areEntitiesLoading} + onInputChange={(e, value) => field.onChange(value ? value : '')} + options={entityOptions} + placeholder={`Select a ${ENTITY_ID_TO_NAME_MAP[entityType]}`} + value={selectedEntity} + /> + )} + control={control} + name="entityInputValue" + /> + {!areEntitiesLoading && entityOptions.length === 0 ? ( + + You don’t have any {ENTITY_ID_TO_NAME_MAP[entityType]}s on + your account. + + ) : null} + + )} + + ); +}; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketSMTPFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketSMTPFields.tsx index 12a6c71415d..fcb6e1e6dcc 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketSMTPFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketSMTPFields.tsx @@ -1,84 +1,115 @@ import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { TextField } from 'src/components/TextField'; +import { useAccount } from 'src/queries/account/account'; -export interface Props { - formState: { - companyName: string; - customerName: string; - emailDomains: string; - publicInfo: string; - useCase: string; - }; - handleChange: (e: React.ChangeEvent) => void; +import { SMTP_FIELD_NAME_TO_LABEL_MAP } from './constants'; + +import type { CustomFields } from './constants'; + +export interface SMTPCustomFields extends Omit { + companyName: string | undefined; + emailDomains: string; } -export const smtpDialogTitle = 'Contact Support: SMTP Restriction Removal'; -export const smtpHelperText = - 'In an effort to fight spam, outbound connections are restricted on ports 25, 465, and 587. To have these restrictions removed, please provide us with the following information. A member of the Support team will review your request and follow up with you as soon as possible.'; +export const SupportTicketSMTPFields = () => { + const form = useFormContext(); + const { data: account } = useAccount(); -export const fieldNameToLabelMap: Record = { - companyName: 'Business or company name', - customerName: 'First and last name', - emailDomains: 'Domain(s) that will be sending emails', - publicInfo: - "Links to public information - e.g. your business or application's website, Twitter profile, GitHub, etc.", - useCase: - "A clear and detailed description of your email use case, including how you'll avoid sending unwanted emails", -}; + const defaultValues = { + companyName: account?.company, + customerName: `${account?.first_name} ${account?.last_name}`, + ...form.formState.defaultValues, + }; -const SupportTicketSMTPFields: React.FC = (props) => { - const { formState, handleChange } = props; + React.useEffect(() => { + form.reset(defaultValues); + }, []); return ( - - + ( + + )} + control={form.control} name="customerName" - onChange={handleChange} - required - value={formState.customerName} /> - ( + + )} + control={form.control} name="companyName" - onChange={handleChange} - value={formState.companyName} /> - ( + + )} + control={form.control} name="useCase" - onChange={handleChange} - required - value={formState.useCase} /> - ( + + )} + control={form.control} name="emailDomains" - onChange={handleChange} - required - value={formState.emailDomains} /> - ( + + )} + control={form.control} name="publicInfo" - onChange={handleChange} - required - value={formState.publicInfo} /> - + ); }; - -export default SupportTicketSMTPFields; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx index ca550291802..3d66011bb75 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx @@ -10,10 +10,11 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; -import { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; import { SupportTicketDialog } from './SupportTicketDialog'; import TicketList from './TicketList'; +import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; + const tabs = ['open', 'closed']; const SupportTicketsLanding = () => { diff --git a/packages/manager/src/features/Support/SupportTickets/TicketList.tsx b/packages/manager/src/features/Support/SupportTickets/TicketList.tsx index 3907fc95e27..fddf8f1804a 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketList.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketList.tsx @@ -1,4 +1,3 @@ -import { SupportTicket } from '@linode/api-v4/lib/support'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -19,6 +18,8 @@ import { useSupportTicketsQuery } from 'src/queries/support'; import { TicketRow } from './TicketRow'; import { getStatusFilter, useTicketSeverityCapability } from './ticketUtils'; +import type { SupportTicket } from '@linode/api-v4/lib/support'; + export interface Props { filterStatus: 'closed' | 'open'; newTicket?: SupportTicket; @@ -63,9 +64,12 @@ export const TicketList = (props: Props) => { return ( diff --git a/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx b/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx index 276c52bf31f..29210e74fd8 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx @@ -1,4 +1,3 @@ -import { SupportTicket } from '@linode/api-v4/lib/support'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -10,7 +9,10 @@ import { Typography } from 'src/components/Typography'; import { getLinkTargets } from 'src/utilities/getEventsActionLink'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; -import { severityLabelMap, useTicketSeverityCapability } from './ticketUtils'; +import { SEVERITY_LABEL_MAP } from './constants'; +import { useTicketSeverityCapability } from './ticketUtils'; + +import type { SupportTicket } from '@linode/api-v4/lib/support'; interface Props { ticket: SupportTicket; @@ -45,7 +47,6 @@ export const TicketRow = ({ ticket }: Props) => { return ( { {hasSeverityCapability && ( - {ticket.severity ? severityLabelMap.get(ticket.severity) : ''} + {ticket.severity ? SEVERITY_LABEL_MAP.get(ticket.severity) : ''} )} diff --git a/packages/manager/src/features/Support/SupportTickets/constants.tsx b/packages/manager/src/features/Support/SupportTickets/constants.tsx index 61b36aa1e72..bfe5e70a6f7 100644 --- a/packages/manager/src/features/Support/SupportTickets/constants.tsx +++ b/packages/manager/src/features/Support/SupportTickets/constants.tsx @@ -1,6 +1,112 @@ -import { Typography } from '@mui/material'; +import { + createSMTPSupportTicketSchema, + createSupportTicketSchema, +} from '@linode/validation'; import React from 'react'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; + +import type { + EntityType, + TicketType, + TicketTypeData, +} from './SupportTicketDialog'; +import type { TicketSeverity } from '@linode/api-v4'; +import type { AnyObjectSchema } from 'yup'; + +export interface CustomFields { + companyName: string; + customerName: string; + publicInfo: string; + useCase: string; +} + +export const SMTP_DIALOG_TITLE = 'Contact Support: SMTP Restriction Removal'; +export const SMTP_HELPER_TEXT = + 'In an effort to fight spam, outbound connections are restricted on ports 25, 465, and 587. To have these restrictions removed, please provide us with the following information. A member of the Support team will review your request and follow up with you as soon as possible.'; + +export const TICKET_TYPE_MAP: Record = { + general: { + dialogTitle: 'Open a Support Ticket', + helperText: ( + <> + {`We love our customers, and we\u{2019}re here to help if you need us. + Please keep in mind that not all topics are within the scope of our support. + For overall system status, please see `} + status.linode.com. + + ), + }, + smtp: { + dialogTitle: SMTP_DIALOG_TITLE, + helperText: SMTP_HELPER_TEXT, + }, +}; + +// Validation +export const SCHEMA_MAP: Record = { + general: createSupportTicketSchema, + smtp: createSMTPSupportTicketSchema, +}; + +export const ENTITY_MAP: Record = { + Databases: 'database_id', + Domains: 'domain_id', + Firewalls: 'firewall_id', + Kubernetes: 'lkecluster_id', + Linodes: 'linode_id', + NodeBalancers: 'nodebalancer_id', + Volumes: 'volume_id', +}; + +export const ENTITY_ID_TO_NAME_MAP: Record = { + database_id: 'Database Cluster', + domain_id: 'Domain', + firewall_id: 'Firewall', + general: '', + linode_id: 'Linode', + lkecluster_id: 'Kubernetes Cluster', + nodebalancer_id: 'NodeBalancer', + none: '', + volume_id: 'Volume', +}; + +// General custom fields common to multiple custom ticket types. +export const CUSTOM_FIELD_NAME_TO_LABEL_MAP: Record = { + companyName: 'Business or company name', + customerName: 'First and last name', + publicInfo: + "Links to public information - e.g. your business or application's website, Twitter profile, GitHub, etc.", + useCase: 'A clear and detailed description of your use case', +}; + +export const SMTP_FIELD_NAME_TO_LABEL_MAP: Record = { + ...CUSTOM_FIELD_NAME_TO_LABEL_MAP, + emailDomains: 'Domain(s) that will be sending emails', + useCase: + "A clear and detailed description of your email use case, including how you'll avoid sending unwanted emails", +}; + +// Used for finding specific custom fields within form data, based on the ticket type. +export const TICKET_TYPE_TO_CUSTOM_FIELD_KEYS_MAP: Record = { + smtp: Object.keys(SMTP_FIELD_NAME_TO_LABEL_MAP), +}; + +export const SEVERITY_LABEL_MAP: Map = new Map([ + [1, '1-Major Impact'], + [2, '2-Moderate Impact'], + [3, '3-Low Impact'], +]); + +export const SEVERITY_OPTIONS: { + label: string; + value: TicketSeverity; +}[] = Array.from(SEVERITY_LABEL_MAP).map(([severity, label]) => ({ + label, + value: severity, +})); + export const TICKET_SEVERITY_TOOLTIP_TEXT = ( <> diff --git a/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts b/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts new file mode 100644 index 00000000000..025f0469bea --- /dev/null +++ b/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts @@ -0,0 +1,49 @@ +import { SMTP_FIELD_NAME_TO_LABEL_MAP } from './constants'; +import { formatDescription } from './ticketUtils'; + +import type { SupportTicketFormFields } from './SupportTicketDialog'; +import type { SMTPCustomFields } from './SupportTicketSMTPFields'; + +const mockSupportTicketFormFields: SupportTicketFormFields = { + description: 'Mock description.', + entityId: '', + entityInputValue: '', + entityType: 'general', + selectedSeverity: undefined, + summary: 'My Summary', + ticketType: 'general', +}; + +const mockSupportTicketCustomFormFields: SMTPCustomFields = { + companyName: undefined, + customerName: 'Jane Doe', + emailDomains: 'test@akamai.com', + publicInfo: 'public info', + useCase: 'use case', +}; + +describe('formatDescription', () => { + it('returns the original description if there are no custom fields in the payload', () => { + expect(formatDescription(mockSupportTicketFormFields, 'general')).toEqual( + mockSupportTicketFormFields.description + ); + }); + + it('returns the formatted description if there are custom fields in the payload', () => { + const expectedFormattedDescription = `**${SMTP_FIELD_NAME_TO_LABEL_MAP['companyName']}**\nNo response\n\n\ +**${SMTP_FIELD_NAME_TO_LABEL_MAP['customerName']}**\n${mockSupportTicketCustomFormFields.customerName}\n\n\ +**${SMTP_FIELD_NAME_TO_LABEL_MAP['emailDomains']}**\n${mockSupportTicketCustomFormFields.emailDomains}\n\n\ +**${SMTP_FIELD_NAME_TO_LABEL_MAP['publicInfo']}**\n${mockSupportTicketCustomFormFields.publicInfo}\n\n\ +**${SMTP_FIELD_NAME_TO_LABEL_MAP['useCase']}**\n${mockSupportTicketCustomFormFields.useCase}`; + + expect( + formatDescription( + { + ...mockSupportTicketFormFields, + ...mockSupportTicketCustomFormFields, + }, + 'smtp' + ) + ).toEqual(expectedFormattedDescription); + }); +}); diff --git a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts index 3ac529852d3..1abf530610c 100644 --- a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts +++ b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts @@ -1,10 +1,21 @@ -import { Filter, Params } from '@linode/api-v4'; -import { TicketSeverity, getTickets } from '@linode/api-v4/lib/support'; +import { getTickets } from '@linode/api-v4/lib/support'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { + SMTP_FIELD_NAME_TO_LABEL_MAP, + TICKET_TYPE_TO_CUSTOM_FIELD_KEYS_MAP, +} from './constants'; + +import type { + AllSupportTicketFormFields, + SupportTicketFormFields, + TicketType, +} from './SupportTicketDialog'; +import type { Filter, Params } from '@linode/api-v4'; + /** * getStatusFilter * @@ -60,8 +71,40 @@ export const useTicketSeverityCapability = () => { ); }; -export const severityLabelMap: Map = new Map([ - [1, '1-Major Impact'], - [2, '2-Moderate Impact'], - [3, '3-Low Impact'], -]); +/** + * formatDescription + * + * When variant ticketTypes include additional fields, fields must concat to one description string to submit in the payload. + * For readability, replace field names with field labels and format the description in Markdown. + * @param values - the form payload, which can either be the general fields, or the general fields plus any custom fields + * @param ticketType - either 'general' or a custom ticket type (e.g. 'smtp') + * + * @returns a description string + */ +export const formatDescription = ( + values: AllSupportTicketFormFields | SupportTicketFormFields, + ticketType: TicketType +) => { + type customFieldTuple = [string, string | undefined]; + const customFields: customFieldTuple[] = Object.entries( + values + ).filter(([key, _value]: customFieldTuple) => + TICKET_TYPE_TO_CUSTOM_FIELD_KEYS_MAP[ticketType]?.includes(key) + ); + + // If there are no custom fields, just return the initial description. + if (customFields.length === 0) { + return values.description; + } + + // Add all custom fields to the description in the ticket payload, to be viewed on ticket details page and by Customer Support. + return customFields + .map(([key, value]) => { + let label = key; + if (ticketType === 'smtp') { + label = SMTP_FIELD_NAME_TO_LABEL_MAP[key]; + } + return `**${label}**\n${value ? value : 'No response'}`; + }) + .join('\n\n'); +}; diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx index 0acd005aaf6..3d59b269e96 100644 --- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx +++ b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx @@ -25,7 +25,6 @@ import PlacementGroupsIcon from 'src/assets/icons/entityIcons/placement-groups.s import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; import VPCIcon from 'src/assets/icons/entityIcons/vpc.svg'; import { Button } from 'src/components/Button/Button'; -import { Divider } from 'src/components/Divider'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useIsACLBEnabled } from 'src/features/LoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; @@ -181,16 +180,7 @@ export const AddNewMenu = () => { {links.map( (link, i) => !link.hide && [ - i !== 0 && , { }} > - + - - {link.entity} - + {link.entity} {link.description} , diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx index 72c033c80ec..baa052b1dec 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import { IconButton } from '@mui/material'; import Popover from '@mui/material/Popover'; import { styled } from '@mui/material/styles'; diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx new file mode 100644 index 00000000000..ebb7e326c85 --- /dev/null +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NotificationMenuV2 } from './NotificationMenuV2'; + +describe('NotificationMenuV2', () => { + // 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/SearchBar/SearchBar.styles.ts b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.styles.ts index bc4144e18cc..ce2713f1cf1 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.styles.ts +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.styles.ts @@ -31,7 +31,14 @@ export const StyledSearchBarWrapperDiv = styled('div', { label: 'StyledSearchBarWrapperDiv', })(({ theme }) => ({ '& > div .react-select__control': { + '&:hover': { + borderColor: 'transparent', + }, backgroundColor: 'transparent', + borderColor: 'transparent', + }, + '& > div .react-select__control--is-focused:hover': { + borderColor: 'transparent', }, '& > div .react-select__indicators': { display: 'none', @@ -55,9 +62,21 @@ export const StyledSearchBarWrapperDiv = styled('div', { }, overflow: 'hidden', }, + '& svg': { + height: 20, + width: 20, + }, + '&.active': { + ...theme.inputStyles.focused, + '&:hover': { + ...theme.inputStyles.focused, + }, + }, + '&:hover': { + ...theme.inputStyles.hover, + }, + ...theme.inputStyles.default, alignItems: 'center', - backgroundColor: theme.bg.app, - borderRadius: 3, display: 'flex', flex: 1, height: 34, @@ -70,7 +89,6 @@ export const StyledSearchBarWrapperDiv = styled('div', { visibility: 'visible', zIndex: 3, }, - backgroundColor: theme.bg.white, left: 0, margin: 0, opacity: 0, diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx index bc5c34fcdc1..c45414b1a5c 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(); @@ -46,13 +50,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => { )} - ({ - backgroundColor: theme.bg.bgPaper, - color: theme.palette.text.primary, - position: 'relative', - })} - > + ({ '&.MuiToolbar-root': { @@ -67,7 +65,6 @@ export const TopMenu = React.memo((props: TopMenuProps) => { { - + {flags.eventMessagesV2 ? ( + + ) : ( + + )} diff --git a/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx b/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx index b03d486105d..35250f5cbfa 100644 --- a/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx +++ b/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx @@ -28,6 +28,7 @@ export const topMenuIconButtonSx = (theme: Theme) => ({ color: '#606469', }, color: '#c9c7c7', + height: `50px`, [theme.breakpoints.down('sm')]: { padding: 1, }, diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx index f29c643feef..c8596ff8b42 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx @@ -31,9 +31,7 @@ describe('UserMenu', () => { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('parent-user')).toBeInTheDocument(); expect(await findByText('Parent Company')).toBeInTheDocument(); @@ -56,9 +54,7 @@ describe('UserMenu', () => { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('parent-user')).toBeInTheDocument(); expect(await findByText('Child Company')).toBeInTheDocument(); @@ -78,9 +74,7 @@ describe('UserMenu', () => { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('child-user')).toBeInTheDocument(); expect(await findByText('Child Company')).toBeInTheDocument(); @@ -103,9 +97,7 @@ describe('UserMenu', () => { }) ); - const { findByText, queryByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText, queryByText } = renderWithTheme(); expect(await findByText('regular-user')).toBeInTheDocument(); // Should not be displayed for regular users, only parent/child/proxy users. @@ -124,9 +116,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -151,9 +141,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, queryByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, queryByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -173,9 +161,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -212,9 +198,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 477e86e66af..f6d9ee4c6b2 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -21,10 +21,9 @@ import { switchAccountSessionContext } from 'src/context/switchAccountSessionCon import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; import { SwitchAccountDrawer } from 'src/features/Account/SwitchAccountDrawer'; import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getStorage, setStorage } from 'src/utilities/storage'; @@ -64,7 +63,6 @@ export const UserMenu = React.memo(() => { const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { enqueueSnackbar } = useSnackbar(); - const flags = useFlags(); const sessionContext = React.useContext(switchAccountSessionContext); const hasGrant = (grant: GlobalGrantTypes) => @@ -72,39 +70,33 @@ export const UserMenu = React.memo(() => { const isRestrictedUser = profile?.restricted ?? false; const hasAccountAccess = !isRestrictedUser || hasGrant('account_access'); const hasReadWriteAccountAccess = hasGrant('account_access') === 'read_write'; - const hasParentChildAccountAccess = Boolean(flags.parentChildAccountAccess); const isParentUser = profile?.user_type === 'parent'; const isProxyUser = profile?.user_type === 'proxy'; const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'child_account_access', }); const canSwitchBetweenParentOrProxyAccount = - flags.parentChildAccountAccess && - ((!isChildAccountAccessRestricted && isParentUser) || isProxyUser); + (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; const companyNameOrEmail = getCompanyNameOrEmail({ company: account?.company, - isParentChildFeatureEnabled: hasParentChildAccountAccess, profile, }); const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser }); // Used for fetching parent profile and account data by making a request with the parent's token. - const proxyHeaders = - hasParentChildAccountAccess && isProxyUser - ? { - Authorization: getStorage(`authentication/parent_token/token`), - } - : undefined; + const proxyHeaders = isProxyUser + ? { + Authorization: getStorage(`authentication/parent_token/token`), + } + : undefined; const { data: parentProfile } = useProfile({ headers: proxyHeaders }); - const userName = - (hasParentChildAccountAccess && isProxyUser ? parentProfile : profile) - ?.username ?? ''; + const userName = (isProxyUser ? parentProfile : profile)?.username ?? ''; const matchesSmDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm') @@ -217,7 +209,7 @@ export const UserMenu = React.memo(() => { isProxyUser ? ( ) : ( - + ) } sx={(theme) => ({ diff --git a/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts b/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts index a1d8be27b1b..8a960cac142 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts +++ b/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts @@ -13,7 +13,6 @@ describe('getCompanyNameOrEmail', () => { newUserTypes.forEach((newUserType: UserType) => { const actual = getCompanyNameOrEmail({ company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ user_type: newUserType }), }); const expected = MOCK_COMPANY_NAME; @@ -26,7 +25,6 @@ describe('getCompanyNameOrEmail', () => { const actual = getCompanyNameOrEmail({ company: undefined, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ email: parentEmail, user_type: 'parent', @@ -41,7 +39,6 @@ describe('getCompanyNameOrEmail', () => { const actual = getCompanyNameOrEmail({ company: undefined, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ email: childEmail, user_type: 'child', @@ -54,24 +51,9 @@ describe('getCompanyNameOrEmail', () => { it('returns undefined for the company/email of a regular (default) user', async () => { const actual = getCompanyNameOrEmail({ company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ user_type: 'default' }), }); const expected = undefined; expect(actual).toEqual(expected); }); - - it('returns undefined for the company/email of all users when the parent/child feature is not enabled', async () => { - const allUserTypes = ['parent', 'child', 'proxy', 'default']; - - allUserTypes.forEach((userType: UserType) => { - const actual = getCompanyNameOrEmail({ - company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: false, - profile: profileFactory.build({ user_type: userType }), - }); - const expected = undefined; - expect(actual).toEqual(expected); - }); - }); }); diff --git a/packages/manager/src/features/TopMenu/UserMenu/utils.ts b/packages/manager/src/features/TopMenu/UserMenu/utils.ts index 54b7ca28b75..d63d4b3307f 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/utils.ts +++ b/packages/manager/src/features/TopMenu/UserMenu/utils.ts @@ -2,7 +2,6 @@ import { Profile } from '@linode/api-v4'; export interface CompanyNameOrEmailOptions { company: string | undefined; - isParentChildFeatureEnabled: boolean; profile: Profile | undefined; } @@ -13,20 +12,16 @@ export interface CompanyNameOrEmailOptions { */ export const getCompanyNameOrEmail = ({ company, - isParentChildFeatureEnabled, profile, }: CompanyNameOrEmailOptions) => { - const isParentChildOrProxyUser = profile?.user_type !== 'default'; - const isParentUser = profile?.user_type === 'parent'; - // Return early if we do not need the company name or email. - if (!isParentChildFeatureEnabled || !profile || !isParentChildOrProxyUser) { + if (!profile || profile.user_type === 'default') { return undefined; } // For parent users lacking `account_access`: without a company name to identify an account, fall back on the email. // We do not need to do this for child users lacking `account_access` because we do not need to display the email. - if (isParentUser && !company) { + if (profile.user_type === 'parent' && !company) { return profile.email; } diff --git a/packages/manager/src/features/Users/UserDetail.tsx b/packages/manager/src/features/Users/UserDetail.tsx index f23e351399f..292eafa1756 100644 --- a/packages/manager/src/features/Users/UserDetail.tsx +++ b/packages/manager/src/features/Users/UserDetail.tsx @@ -20,7 +20,7 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { accountQueries } from 'src/queries/account/queries'; import { useAccountUser } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import UserPermissions from './UserPermissions'; diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index ad12a3eb673..0d95678a7fc 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -16,7 +16,6 @@ import { QueryClient } from '@tanstack/react-query'; import { enqueueSnackbar } from 'notistack'; import { compose, flatten, lensPath, omit, set } from 'ramda'; import * as React from 'react'; -import { compose as recompose } from 'recompose'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; @@ -46,7 +45,7 @@ import { PARENT_USER, grantTypeMap } from 'src/features/Account/constants'; import { accountQueries } from 'src/queries/account/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { StyledCircleProgress, @@ -109,10 +108,10 @@ class UserPermissions extends React.Component { const { currentUsername } = this.props; return ( - +
    {loading ? : this.renderBody()} - +
    ); } @@ -184,6 +183,8 @@ class UserPermissions extends React.Component { } }; + formContainerRef = React.createRef(); + getTabInformation = (grants: Grants) => this.entityPerms.reduce( (acc: TabInfo, entity: GrantType) => { @@ -232,7 +233,7 @@ class UserPermissions extends React.Component { 'Unknown error occurred while fetching user permissions. Try again later.' ), }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); } }; @@ -255,7 +256,7 @@ class UserPermissions extends React.Component { 'Unknown error occurred while fetching user permissions. Try again later.' ), }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); } } }; @@ -736,7 +737,7 @@ class UserPermissions extends React.Component { ), isSavingGlobal: false, }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); } @@ -794,7 +795,7 @@ class UserPermissions extends React.Component { ), isSavingEntity: false, }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); }; @@ -826,7 +827,4 @@ class UserPermissions extends React.Component { }; } -export default recompose( - withQueryClient, - withFeatureFlags -)(UserPermissions); +export default withQueryClient(withFeatureFlags(UserPermissions)); diff --git a/packages/manager/src/features/Users/UserProfile.tsx b/packages/manager/src/features/Users/UserProfile.tsx index 710c7b48b1e..6f7f7b1f2da 100644 --- a/packages/manager/src/features/Users/UserProfile.tsx +++ b/packages/manager/src/features/Users/UserProfile.tsx @@ -11,7 +11,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { useAccountUser } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { PARENT_USER, RESTRICTED_FIELD_TOOLTIP } from '../Account/constants'; diff --git a/packages/manager/src/features/Users/UserRow.test.tsx b/packages/manager/src/features/Users/UserRow.test.tsx index eabb65e99d4..3841f20d1d3 100644 --- a/packages/manager/src/features/Users/UserRow.test.tsx +++ b/packages/manager/src/features/Users/UserRow.test.tsx @@ -66,9 +66,7 @@ describe('UserRow', () => { ); const { findByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(await findByText('Enabled')).toBeVisible(); }); @@ -91,9 +89,7 @@ describe('UserRow', () => { ); const { findByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(await findByText('Disabled')).toBeVisible(); }); @@ -118,9 +114,7 @@ describe('UserRow', () => { ); const { queryByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(queryByText('Enabled')).not.toBeInTheDocument(); }); @@ -145,9 +139,7 @@ describe('UserRow', () => { ); const { findByText, queryByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); // Renders Username, Email, and Account Access fields for a proxy user. diff --git a/packages/manager/src/features/Users/UserRow.tsx b/packages/manager/src/features/Users/UserRow.tsx index 6344bd37980..51745389d36 100644 --- a/packages/manager/src/features/Users/UserRow.tsx +++ b/packages/manager/src/features/Users/UserRow.tsx @@ -10,9 +10,8 @@ import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; -import { useFlags } from 'src/hooks/useFlags'; import { useAccountUserGrants } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { UsersActionMenu } from './UsersActionMenu'; @@ -25,18 +24,14 @@ interface Props { } export const UserRow = ({ onDelete, user }: Props) => { - const flags = useFlags(); const { data: grants } = useAccountUserGrants(user.username); const { data: profile } = useProfile(); - const isProxyUser = Boolean( - flags.parentChildAccountAccess && user.user_type === 'proxy' - ); - const showChildAccountAccessCol = - flags.parentChildAccountAccess && profile?.user_type === 'parent'; + const isProxyUser = Boolean(user.user_type === 'proxy'); + const showChildAccountAccessCol = profile?.user_type === 'parent'; return ( - + diff --git a/packages/manager/src/features/Users/UsersActionMenu.tsx b/packages/manager/src/features/Users/UsersActionMenu.tsx index 4f0bdad2cec..d2d5ea859cc 100644 --- a/packages/manager/src/features/Users/UsersActionMenu.tsx +++ b/packages/manager/src/features/Users/UsersActionMenu.tsx @@ -5,7 +5,7 @@ import { useHistory } from 'react-router-dom'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; interface Props { isProxyUser: boolean; diff --git a/packages/manager/src/features/Users/UsersLanding.tsx b/packages/manager/src/features/Users/UsersLanding.tsx index 2997ec92bb1..08a74f61930 100644 --- a/packages/manager/src/features/Users/UsersLanding.tsx +++ b/packages/manager/src/features/Users/UsersLanding.tsx @@ -10,11 +10,11 @@ import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { Typography } from 'src/components/Typography'; import { PARENT_USER } from 'src/features/Account/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccountUsers } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import CreateUserDrawer from './CreateUserDrawer'; import { UserDeleteConfirmationDialog } from './UserDeleteConfirmationDialog'; @@ -23,7 +23,6 @@ import { UsersLandingTableBody } from './UsersLandingTableBody'; import { UsersLandingTableHead } from './UsersLandingTableHead'; import type { Filter } from '@linode/api-v4'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; export const UsersLanding = () => { const theme = useTheme(); @@ -32,7 +31,6 @@ export const UsersLanding = () => { ); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [selectedUsername, setSelectedUsername] = React.useState(''); - const flags = useFlags(); const { data: profile } = useProfile(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('sm')); const matchesLgUp = useMediaQuery(theme.breakpoints.up('lg')); @@ -41,8 +39,7 @@ export const UsersLanding = () => { const order = useOrder(); const showProxyUserTable = - flags.parentChildAccountAccess && - (profile?.user_type === 'child' || profile?.user_type === 'proxy'); + profile?.user_type === 'child' || profile?.user_type === 'proxy'; const usersFilter: Filter = { ['+order']: order.order, @@ -67,8 +64,7 @@ export const UsersLanding = () => { error: proxyUserError, isInitialLoading: isLoadingProxyUser, } = useAccountUsers({ - enabled: - flags.parentChildAccountAccess && showProxyUserTable && !isRestrictedUser, + enabled: showProxyUserTable && !isRestrictedUser, filters: { user_type: 'proxy' }, }); @@ -77,9 +73,7 @@ export const UsersLanding = () => { }); const showChildAccountAccessCol = Boolean( - flags.parentChildAccountAccess && - profile?.user_type === 'parent' && - !isChildAccountAccessRestricted + profile?.user_type === 'parent' && !isChildAccountAccessRestricted ); // Parent/Child accounts include additional "child account access" column. 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/VPCDetail/AssignIPRanges.tsx b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx index 946302aa5db..795aa393d5f 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx @@ -54,7 +54,6 @@ export const AssignIPRanges = (props: Props) => { marginLeft: theme.spacing(0.5), padding: theme.spacing(0.5), }} - interactive status="help" text={IPv4RangesDescriptionJSX} /> diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 2a8a854ca64..20827c61afa 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -27,7 +27,7 @@ import { useFormattedDate } from 'src/hooks/useFormattedDate'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { getAllLinodeConfigs } from 'src/queries/linodes/requests'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; import { ExtendedIP } from 'src/utilities/ipUtils'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx index 3bd1be7a28a..f4b3a9527cb 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useCreateSubnetMutation, useVPCQuery } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx index e90b6bc1a83..e5b5279fbf4 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx @@ -7,7 +7,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useUpdateSubnetMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx index 3f582284da8..c51eb4d5fad 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx @@ -68,7 +68,7 @@ describe('SubnetLinodeRow', () => { handlePowerActionsLinode={handlePowerActionsLinode} handleUnassignLinode={handleUnassignLinode} linodeId={linodeFactory1.id} - subnetId={0} + subnetId={1} /> ) ); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 0df5ff9bd16..4879592020f 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -1,8 +1,8 @@ import { APIError, Firewall, Linode } from '@linode/api-v4'; import { Config, Interface } from '@linode/api-v4/lib/linodes/types'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; -import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import * as React from 'react'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; @@ -102,7 +102,7 @@ export const SubnetLinodeRow = (props: Props) => { return ( - + ); @@ -148,7 +148,6 @@ export const SubnetLinodeRow = (props: Props) => {
    } icon={} - interactive status="other" sxTooltipIcon={{ paddingLeft: 0 }} /> diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index ed4fa97d7d6..99de08eabab 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -19,7 +19,7 @@ import { useAllLinodesQuery, } from 'src/queries/linodes/linodes'; import { getAllLinodeConfigs } from 'src/queries/linodes/requests'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; import type { diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts index 5d8f28ef52b..7dc1846cbb2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts @@ -3,7 +3,6 @@ import { styled } from '@mui/material/styles'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; -import { Paper } from 'src/components/Paper'; export const StyledActionButton = styled(Button, { label: 'StyledActionButton', @@ -55,9 +54,10 @@ export const StyledSummaryTextTypography = styled(Typography, { whiteSpace: 'nowrap', })); -export const StyledPaper = styled(Paper, { - label: 'StyledPaper', +export const StyledBox = styled(Box, { + label: 'StyledBox', })(({ theme }) => ({ + background: theme.bg.bgPaper, borderTop: `1px solid ${theme.borderColors.borderTable}`, display: 'flex', padding: theme.spacing(2), diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index 93957c50c98..46ee3584406 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -23,7 +23,7 @@ import { getUniqueLinodesFromSubnets } from '../utils'; import { StyledActionButton, StyledDescriptionBox, - StyledPaper, + StyledBox, StyledSummaryBox, StyledSummaryTextTypography, } from './VPCDetail.styles'; @@ -133,7 +133,7 @@ const VPCDetail = () => { - + {summaryData.map((col) => { return ( @@ -174,7 +174,7 @@ const VPCDetail = () => { )} - +
    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} /> )} { it('should render a VPC row', () => { const vpc = vpcFactory.build(); + resizeScreenSize(1600); const { getAllByText, getByText } = renderWithTheme( wrapWithTableBody( diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx index 690e6785fd9..cbdcf2496fd 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx @@ -37,11 +37,7 @@ export const VPCRow = ({ handleDeleteVPC, handleEditVPC, vpc }: Props) => { ]; return ( - + {label} diff --git a/packages/manager/src/features/VPCs/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts index c572b42ad6a..90e4ea5e2da 100644 --- a/packages/manager/src/features/VPCs/utils.test.ts +++ b/packages/manager/src/features/VPCs/utils.test.ts @@ -50,7 +50,7 @@ describe('getUniqueLinodesFromSubnets', () => { expect(getUniqueLinodesFromSubnets(subnets0)).toBe(0); expect(getUniqueLinodesFromSubnets(subnets1)).toBe(4); expect(getUniqueLinodesFromSubnets(subnets2)).toBe(2); - expect(getUniqueLinodesFromSubnets(subnets3)).toBe(7); + expect(getUniqueLinodesFromSubnets(subnets3)).toBe(6); }); }); @@ -60,15 +60,15 @@ describe('getSubnetInterfaceFromConfigs', () => { const singleConfig = linodeConfigFactory.build({ interfaces }); const configs = [linodeConfigFactory.build(), singleConfig]; - const subnetInterface1 = getSubnetInterfaceFromConfigs(configs, 1); + const subnetInterface1 = getSubnetInterfaceFromConfigs(configs, 2); expect(subnetInterface1).toEqual(interfaces[0]); - const subnetInterface2 = getSubnetInterfaceFromConfigs(configs, 2); + const subnetInterface2 = getSubnetInterfaceFromConfigs(configs, 3); expect(subnetInterface2).toEqual(interfaces[1]); - const subnetInterface3 = getSubnetInterfaceFromConfigs(configs, 3); + const subnetInterface3 = getSubnetInterfaceFromConfigs(configs, 4); expect(subnetInterface3).toEqual(interfaces[2]); - const subnetInterface4 = getSubnetInterfaceFromConfigs(configs, 4); + const subnetInterface4 = getSubnetInterfaceFromConfigs(configs, 5); expect(subnetInterface4).toEqual(interfaces[3]); - const subnetInterface5 = getSubnetInterfaceFromConfigs(configs, 5); + const subnetInterface5 = getSubnetInterfaceFromConfigs(configs, 6); expect(subnetInterface5).toEqual(interfaces[4]); }); diff --git a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx index 65a60178bb5..0204a22f430 100644 --- a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx @@ -1,4 +1,5 @@ import { Volume } from '@linode/api-v4'; +import { styled } from '@mui/material/styles'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -6,17 +7,16 @@ import { number, object } from 'yup'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; -import Select, { Item } from 'src/components/EnhancedSelect'; -import { FormControl } from 'src/components/FormControl'; import { FormHelperText } from 'src/components/FormHelperText'; import { Notice } from 'src/components/Notice/Notice'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { useEventsPollingActions } from 'src/queries/events/events'; -import { useAllLinodeConfigsQuery } from 'src/queries/linodes/configs'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useAttachVolumeMutation } from 'src/queries/volumes/volumes'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; +import { ConfigSelect } from './VolumeDrawer/ConfigSelect'; + interface Props { onClose: () => void; open: boolean; @@ -58,27 +58,10 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { }); }, validateOnBlur: false, - validateOnChange: false, + validateOnChange: true, validationSchema: AttachVolumeValidationSchema, }); - const { data, isLoading: configsLoading } = useAllLinodeConfigsQuery( - formik.values.linode_id, - formik.values.linode_id !== -1 - ); - - const configs = data ?? []; - - const configChoices = configs.map((config) => { - return { label: config.label, value: `${config.id}` }; - }); - - React.useEffect(() => { - if (configs.length === 1) { - formik.setFieldValue('config_id', configs[0].id); - } - }, [configs]); - const reset = () => { formik.resetForm(); }; @@ -115,12 +98,17 @@ export const AttachVolumeDrawer = React.memo((props: Props) => {
    {isReadOnly && ( )} {generalError && } { if (linode !== null) { formik.setFieldValue('linode_id', linode.id); @@ -128,7 +116,6 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { }} clearable={false} disabled={isReadOnly} - errorText={formik.errors.linode_id ?? linodeError} filter={{ region: volume?.region }} noMarginTop value={formik.values.linode_id} @@ -138,27 +125,24 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { Only Linodes in this Volume’s region are displayed. )} - {/* Config Selection */} - - - !configs || configs.length == 0 - ? 'No configs are available for this Linode.' - : 'No options.' // No matches for search + noOptionsText={ + !configs || configs.length == 0 + ? 'No configs are available for this Linode.' + : 'No options.' } - onChange={(e: Item) => { - onChange(+e.value); + onChange={(_, selected) => { + onChange(+selected.value); }} + value={ + value && value !== -1 + ? configList?.find((thisConfig) => thisConfig.value === value) + : { label: '', value: -1 } + } + disableClearable id={name} - isClearable={false} + isOptionEqualToValue={(option, value) => option.value === value.value} label="Config" - name={name} noMarginTop onBlur={onBlur} - options={configList} + options={configList ?? []} placeholder="Select a Config" - value={configList?.find((thisConfig) => thisConfig.value === value)} {...rest} /> diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx index 3d75171ea14..e7a45c3b3e8 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx @@ -8,7 +8,7 @@ import { number, object } from 'yup'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Notice } from 'src/components/Notice/Notice'; import { useEventsPollingActions } from 'src/queries/events/events'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useAttachVolumeMutation } from 'src/queries/volumes/volumes'; import { handleFieldErrors, diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/PricePanel.tsx b/packages/manager/src/features/Volumes/VolumeDrawer/PricePanel.tsx index ad1ee389651..ffa3a5119d8 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawer/PricePanel.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawer/PricePanel.tsx @@ -1,11 +1,12 @@ -import { CircularProgress } from '@mui/material'; import * as React from 'react'; import { Box } from 'src/components/Box'; +import { CircleProgress } from 'src/components/CircleProgress'; import { DisplayPrice } from 'src/components/DisplayPrice'; import { MAX_VOLUME_SIZE } from 'src/constants'; import { useVolumeTypesQuery } from 'src/queries/volumes/volumes'; import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; + interface Props { currentSize: number; regionId: string; @@ -34,13 +35,17 @@ export const PricePanel = ({ currentSize, regionId, value }: Props) => { : getPrice(currentSize); const price = getClampedPrice(value, currentSize); + if (isLoading) { + return ( + + + + ); + } + return ( - {isLoading ? ( - - ) : ( - - )} + ); }; diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/SizeField.tsx b/packages/manager/src/features/Volumes/VolumeDrawer/SizeField.tsx index bad1abae3ab..71b8b6f3b29 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawer/SizeField.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawer/SizeField.tsx @@ -1,9 +1,9 @@ -import { CircularProgress } from '@mui/material'; import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; +import { CircleProgress } from 'src/components/CircleProgress'; import { FormHelperText } from 'src/components/FormHelperText'; import { InputAdornment } from 'src/components/InputAdornment'; import { TextField } from 'src/components/TextField'; @@ -117,7 +117,7 @@ export const SizeField = (props: Props) => { />
    {shouldShowPriceLoadingSpinner ? ( - + ) : hasSelectedRegion ? ( priceDisplayText ) : ( diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx index 97fb11c9d9d..8217745d659 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx @@ -6,6 +6,8 @@ import * as React from 'react'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; export interface ActionHandlers { handleAttach: () => void; @@ -32,42 +34,94 @@ export const VolumesActionMenu = (props: Props) => { const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); + const isVolumeReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'volume', + id: volume.id, + }); + const actions: Action[] = [ { onClick: handlers.handleDetails, title: 'Show Config', }, { + disabled: isVolumeReadOnly, onClick: handlers.handleEdit, title: 'Edit', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'edit', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }, { + disabled: isVolumeReadOnly, onClick: handlers.handleResize, title: 'Resize', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'resize', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }, { + disabled: isVolumeReadOnly, onClick: handlers.handleClone, title: 'Clone', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'clone', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }, ]; if (!attached && isVolumesLanding) { actions.push({ + disabled: isVolumeReadOnly, onClick: handlers.handleAttach, title: 'Attach', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'attach', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }); } else { actions.push({ + disabled: isVolumeReadOnly, onClick: handlers.handleDetach, title: 'Detach', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'detach', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }); } actions.push({ - disabled: attached, + disabled: isVolumeReadOnly || attached, onClick: handlers.handleDelete, title: 'Delete', - tooltip: attached + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'delete', + isSingular: true, + resourceType: 'Volumes', + }) + : attached ? 'Your volume must be detached before it can be deleted.' : undefined, }); @@ -82,8 +136,10 @@ export const VolumesActionMenu = (props: Props) => { return ( ); })} diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index d0c4cd8e3ee..939107840ef 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -1,19 +1,27 @@ +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 { LandingLoading } from 'src/components/LandingLoading/LandingLoading'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TextField } from 'src/components/TextField'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useVolumesQuery } from 'src/queries/volumes/volumes'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -31,13 +39,17 @@ 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 isRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_volumes', + }); + const queryParams = new URLSearchParams(location.search); + const volumeLabelFromParam = queryParams.get(searchQueryKey) ?? ''; const { handleOrderChange, order, orderBy } = useOrder( { @@ -52,14 +64,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,8 +129,19 @@ 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 ; + return ; } if (error) { @@ -128,7 +154,7 @@ export const VolumesLanding = () => { ); } - if (volumes?.results === 0) { + if (volumes?.results === 0 && !volumeLabelFromParam) { return ; } @@ -136,11 +162,49 @@ 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 +238,9 @@ export const VolumesLanding = () => { + {volumes?.data.length === 0 && ( + + )} {volumes?.data.map((volume) => ( { + it('disables the create button if the user does not have permission to create volumes', async () => { + server.use( + http.get('*/v4/profile', () => { + const profile = profileFactory.build({ restricted: true }); + return HttpResponse.json(profile); + }), + http.get('*/v4/profile/grants', () => { + const grants = grantsFactory.build({ global: { add_volumes: false } }); + return HttpResponse.json(grants); + }), + http.get('*/v4/volumes', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByText } = renderWithTheme(); + + await waitFor(() => { + const createVolumeButton = getByText('Create Volume').closest('button'); + + expect(createVolumeButton).toBeDisabled(); + expect(createVolumeButton).toHaveAttribute( + 'data-qa-tooltip', + "You don't have permissions to create Volumes. Please contact your account administrator to request the necessary permissions." + ); + }); + }); +}); diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx index 38d0fa74ab2..560338843e0 100644 --- a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx @@ -3,6 +3,8 @@ import { useHistory } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendEvent } from 'src/utilities/analytics/utils'; import { StyledVolumeIcon } from './VolumesLandingEmptyState.styles'; @@ -16,6 +18,10 @@ import { export const VolumesLandingEmptyState = () => { const { push } = useHistory(); + const isRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_volumes', + }); + return ( <> @@ -23,6 +29,7 @@ export const VolumesLandingEmptyState = () => { buttonProps={[ { children: 'Create Volume', + disabled: isRestricted, onClick: () => { sendEvent({ action: 'Click:button', @@ -31,6 +38,11 @@ export const VolumesLandingEmptyState = () => { }); push('/volumes/create'); }, + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Volumes', + }), }, ]} gettingStartedGuidesData={gettingStartedGuides} diff --git a/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx b/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx new file mode 100644 index 00000000000..5aab48e0aee --- /dev/null +++ b/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx @@ -0,0 +1,65 @@ +import { styled } from '@mui/material/styles'; +import { SxProps } from '@mui/system'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; + +interface DistributedRegionPlanTableProps { + copy?: string; + docsLink?: JSX.Element; + error?: JSX.Element | string; + header: string; + innerClass?: string; + renderTable: () => React.JSX.Element; + rootClass?: string; + sx?: SxProps; +} + +export const DistributedRegionPlanTable = React.memo( + (props: DistributedRegionPlanTableProps) => { + const { + copy, + docsLink, + error, + header, + innerClass, + renderTable, + rootClass, + sx, + } = props; + + return ( + +
    + + {header && ( + + {header} + + )} + {docsLink} + + {error && ( + + {error} + + )} + {copy && {copy}} + {renderTable()} +
    +
    + ); + } +); + +const StyledTypography = styled(Typography)(({ theme }) => ({ + fontSize: '0.875rem', + marginTop: theme.spacing(1), +})); diff --git a/packages/manager/src/features/components/PlansPanel/EdgePlanTable.tsx b/packages/manager/src/features/components/PlansPanel/EdgePlanTable.tsx deleted file mode 100644 index 932ac4d2f41..00000000000 --- a/packages/manager/src/features/components/PlansPanel/EdgePlanTable.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { styled } from '@mui/material/styles'; -import { SxProps } from '@mui/system'; -import React from 'react'; - -import { Box } from 'src/components/Box'; -import { Notice } from 'src/components/Notice/Notice'; -import { Paper } from 'src/components/Paper'; -import { Typography } from 'src/components/Typography'; - -interface EdgePlanTableProps { - copy?: string; - docsLink?: JSX.Element; - error?: JSX.Element | string; - header: string; - innerClass?: string; - renderTable: () => React.JSX.Element; - rootClass?: string; - sx?: SxProps; -} - -export const EdgePlanTable = React.memo((props: EdgePlanTableProps) => { - const { - copy, - docsLink, - error, - header, - innerClass, - renderTable, - rootClass, - sx, - } = props; - - return ( - -
    - - {header && ( - - {header} - - )} - {docsLink} - - {error && ( - - {error} - - )} - {copy && {copy}} - {renderTable()} -
    -
    - ); -}); - -const StyledTypography = styled(Typography)(({ theme }) => ({ - fontSize: '0.875rem', - marginTop: theme.spacing(1), -})); diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.styles.ts b/packages/manager/src/features/components/PlansPanel/PlanContainer.styles.ts index 935c059aabe..30cc5351841 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.styles.ts +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.styles.ts @@ -11,8 +11,6 @@ interface StyledTableCellPropsProps extends TableCellProps { export const StyledTable = styled(Table, { label: 'StyledTable', })(({ theme }) => ({ - borderLeft: `1px solid ${theme.borderColors.borderTable}`, - borderRight: `1px solid ${theme.borderColors.borderTable}`, overflowX: 'hidden', })); diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx index a48c0fcff31..089a59d27ad 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx @@ -11,7 +11,7 @@ import { import type { PlanInformationProps } from './PlanInformation'; const mockProps: PlanInformationProps = { - hasDisabledPlans: false, + hasMajorityOfPlansDisabled: false, hasSelectedRegion: true, isSelectedRegionEligibleForPlan: false, planType: 'standard', @@ -38,7 +38,7 @@ describe('PlanInformation', () => { renderWithTheme( diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx index 399074560ce..2f9bd20478d 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx @@ -22,7 +22,7 @@ import type { Region } from '@linode/api-v4'; export interface PlanInformationProps { disabledClasses?: LinodeTypeClass[]; - hasDisabledPlans: boolean; + hasMajorityOfPlansDisabled: boolean; hasSelectedRegion: boolean; hideLimitedAvailabilityBanner?: boolean; isSelectedRegionEligibleForPlan: boolean; @@ -31,10 +31,9 @@ export interface PlanInformationProps { } export const PlanInformation = (props: PlanInformationProps) => { - const theme = useTheme(); const { disabledClasses, - hasDisabledPlans, + hasMajorityOfPlansDisabled, hasSelectedRegion, hideLimitedAvailabilityBanner, isSelectedRegionEligibleForPlan, @@ -72,111 +71,72 @@ export const PlanInformation = (props: PlanInformationProps) => { ) : null} {hasSelectedRegion && isSelectedRegionEligibleForPlan && - !hideLimitedAvailabilityBanner && ( - + !hideLimitedAvailabilityBanner && + hasMajorityOfPlansDisabled && ( + ({ + marginBottom: theme.spacing(3), + marginLeft: 0, + marginTop: 0, + padding: `${theme.spacing(0.5)} ${theme.spacing(2)}`, + })} + dataTestId={limitedAvailabilityBannerTestId} + variant="warning" + > + + These plans have limited deployment availability. + + )} - - {planTabInfoContent[planType]?.typography} - + ); }; export const limitedAvailabilityBannerTestId = 'limited-availability-banner'; -interface LimitedAvailabilityNoticeProps { - hasDisabledPlans: boolean; +interface ClassDescriptionCopyProps { planType: 'shared' | LinodeTypeClass; } -export const LimitedAvailabilityNotice = ( - props: LimitedAvailabilityNoticeProps -) => { - const { hasDisabledPlans, planType } = props; +export const ClassDescriptionCopy = (props: ClassDescriptionCopyProps) => { + const { planType } = props; + const theme = useTheme(); + let planTypeLabel: null | string; + let docLink: null | string; switch (planType) { case 'dedicated': - return ( - - ); - + planTypeLabel = 'Dedicated CPU'; + docLink = DEDICATED_COMPUTE_INSTANCES_LINK; + break; case 'shared': - return ( - - ); - + planTypeLabel = 'Shared CPU'; + docLink = SHARED_COMPUTE_INSTANCES_LINK; + break; case 'highmem': - return ( - - ); - + planTypeLabel = 'High Memory'; + docLink = HIGH_MEMORY_COMPUTE_INSTANCES_LINK; + break; case 'premium': - return ( - - ); - + planTypeLabel = 'Premium CPU'; + docLink = PREMIUM_COMPUTE_INSTANCES_LINK; + break; case 'gpu': - return ( - - ); - + planTypeLabel = 'GPU'; + docLink = GPU_COMPUTE_INSTANCES_LINK; + break; default: - return null; + planTypeLabel = null; + docLink = null; } -}; - -interface LimitedAvailabilityNoticeCopyProps { - docsLink: string; - hasDisabledPlans: boolean; - planTypeLabel: string; -} - -export const LimitedAvailabilityNoticeCopy = ( - props: LimitedAvailabilityNoticeCopyProps -) => { - const { docsLink, hasDisabledPlans, planTypeLabel } = props; - return hasDisabledPlans ? ( - ({ - marginBottom: theme.spacing(3), - marginLeft: 0, - marginTop: 0, - padding: `${theme.spacing(0.5)} ${theme.spacing(2)}`, - })} - dataTestId={limitedAvailabilityBannerTestId} - variant="warning" + return planTypeLabel && docLink ? ( + - - These plans have limited deployment availability.{' '} - Learn more about our {planTypeLabel} plans. - - + {planTabInfoContent[planType]?.typography}{' '} + Learn more about our {planTypeLabel} plans. + ) : null; }; diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.test.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.test.tsx index 798101be7f0..71fa6a84919 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.test.tsx @@ -28,6 +28,10 @@ const defaultProps: PlansPanelProps = { ...planSelectionTypeFactory.build(), class: 'gpu', }, + { + ...planSelectionTypeFactory.build(), + class: 'premium', + }, ], }; diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index f29b3361440..69813cf0f6f 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -2,15 +2,15 @@ import * as React from 'react'; import { useLocation } from 'react-router-dom'; import { Notice } from 'src/components/Notice/Notice'; -import { getIsLinodeCreateTypeEdgeSupported } from 'src/components/RegionSelect/RegionSelect.utils'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { TabbedPanel } from 'src/components/TabbedPanel/TabbedPanel'; import { useFlags } from 'src/hooks/useFlags'; import { useRegionAvailabilityQuery } from 'src/queries/regions/regions'; import { plansNoticesUtils } from 'src/utilities/planNotices'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; -import { EdgePlanTable } from './EdgePlanTable'; +import { DistributedRegionPlanTable } from './DistributedRegionPlanTable'; import { PlanContainer } from './PlanContainer'; import { PlanInformation } from './PlanInformation'; import { @@ -48,6 +48,14 @@ export interface PlansPanelProps { types: PlanSelectionType[]; } +/** + * PlansPanel is a tabbed panel that displays a list of plans for a Linode. + * It is used in the Linode create, Kubernetes and Database create flows. + * It contains ample logic to determine which plans are available based on the selected region availability and display related visual indicators: + * - If the region is not supported, show an error notice and disable all plans. + * - If more than half the plans are disabled, show the limited availability banner and hide the limited availability tooltip + * - If less than half the plans are disabled, hide the limited availability banner and show the limited availability tooltip + */ export const PlansPanel = (props: PlansPanelProps) => { const { className, @@ -78,49 +86,36 @@ export const PlansPanel = (props: PlansPanelProps) => { Boolean(flags.soldOutChips) && selectedRegionID !== undefined ); - const _types = replaceOrAppendPlaceholder512GbPlans(types); + const _types = types.filter( + (type) => + !type.id.includes('dedicated-edge') && !type.id.includes('nanode-edge') + ); const _plans = getPlanSelectionsByPlanType( - flags.disableLargestGbPlans ? _types : types + flags.disableLargestGbPlans + ? replaceOrAppendPlaceholder512GbPlans(_types) + : _types ); - const hideEdgeRegions = + const hideDistributedRegions = !flags.gecko2?.enabled || - !getIsLinodeCreateTypeEdgeSupported(params.type as LinodeCreateType); - - const showEdgePlanTable = - !hideEdgeRegions && - getIsEdgeRegion(regionsData ?? [], selectedRegionID ?? ''); - - const getDedicatedEdgePlanType = () => { - const edgePlans = types.filter((type) => type.class === 'edge'); - if (edgePlans.length) { - return edgePlans; - } - - // @TODO Remove fallback once edge plans are activated - // 256GB and 512GB plans will not be supported for Edge - const plansUpTo128GB = (_plans.dedicated ?? []).filter( - (planType) => - !['Dedicated 256 GB', 'Dedicated 512 GB'].includes( - planType.formattedLabel - ) + !isDistributedRegionSupported(params.type as LinodeCreateType); + + const showDistributedRegionPlanTable = + !hideDistributedRegions && + getIsDistributedRegion(regionsData ?? [], selectedRegionID ?? ''); + + const getDedicatedDistributedRegionPlanType = () => { + return types.filter( + (type) => + type.id.includes('dedicated-edge') || + type.id.includes('nanode-edge') || + type.class === 'edge' ); - - return plansUpTo128GB.map((plan) => { - delete plan.transfer; - return { - ...plan, - price: { - hourly: 0, - monthly: 0, - }, - }; - }); }; - const plans = showEdgePlanTable + const plans = showDistributedRegionPlanTable ? { - dedicated: getDedicatedEdgePlanType(), + dedicated: getDedicatedDistributedRegionPlanType(), } : _plans; @@ -137,7 +132,6 @@ export const PlansPanel = (props: PlansPanelProps) => { const plansMap: PlanSelectionType[] = plans[plan]; const { allDisabledPlans, - hasDisabledPlans, hasMajorityOfPlansDisabled, plansForThisLinodeTypeClass, } = extractPlansInformation({ @@ -156,20 +150,22 @@ export const PlansPanel = (props: PlansPanelProps) => { <> - {showEdgePlanTable && ( + {showDistributedRegionPlanTable && ( )} @@ -200,9 +196,9 @@ export const PlansPanel = (props: PlansPanelProps) => { currentPlanHeading ); - if (showEdgePlanTable) { + if (showDistributedRegionPlanTable) { return ( - 0; const hasMajorityOfPlansDisabled = - plans.length !== 1 && allDisabledPlans.length > plansForThisLinodeTypeClass.length / 2; return { diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index aadb1219ee8..4a374ee9d1b 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -1,67 +1,123 @@ -import { ThemeOptions } from '@mui/material/styles'; +import { + Action, + Badge, + Button, + Color, + Dropdown, + Interaction, + NotificationToast, + Select, + TextField, +} from '@linode/design-language-system/themes/dark'; import { breakpoints } from 'src/foundations/breakpoints'; +import { latoWeb } from 'src/foundations/fonts'; + +import type { ThemeOptions } from '@mui/material/styles'; const primaryColors = { - dark: '#2466b3', - divider: '#222222', - headline: '#f4f4f4', - light: '#4d99f1', - main: '#3683dc', - text: '#ffffff', - white: '#222', + dark: Color.Brand[90], + divider: Color.Neutrals.Black, + headline: Color.Neutrals[5], + light: Color.Brand[60], + main: Color.Brand[80], + text: Color.Neutrals.White, + white: Color.Neutrals.Black, }; +// Eventually we'll probably want Color.Neutrals.Black once we fully migrate to CDS 2.0 +// We will need to consult with the design team to determine the correct dark shade handling for: +// - appBar +// - popoverPaper (create menu, notification center) +// - MenuItem (create menu, action menu) +// since Color.Neutrals.Black is pitch black and may not be the correct choice yet. +const tempReplacementforColorNeutralsBlack = '#222'; + export const customDarkModeOptions = { bg: { - app: '#3a3f46', - bgAccessRow: '#454b54', + app: Color.Neutrals[100], + appBar: tempReplacementforColorNeutralsBlack, + bgAccessRow: Color.Neutrals[80], bgAccessRowTransparentGradient: 'rgb(69, 75, 84, .001)', - bgPaper: '#2e3238', - lightBlue1: '#222', - lightBlue2: '#364863', - main: '#2f3236', - mainContentBanner: '#23272b', - offWhite: '#444', - primaryNavPaper: '#2e3238', - tableHeader: '#33373e', - white: '#32363c', + bgPaper: Color.Neutrals[90], + interactionBgPrimary: Interaction.Background.Secondary, + lightBlue1: Color.Neutrals.Black, + lightBlue2: Color.Brand[100], + main: Color.Neutrals[100], + mainContentBanner: Color.Neutrals[100], + offWhite: Color.Neutrals[90], + primaryNavPaper: Color.Neutrals[100], + tableHeader: Color.Neutrals[100], + white: Color.Neutrals[100], }, borderColors: { - borderTable: '#3a3f46', - borderTypography: '#454b54', - divider: '#222', + borderFocus: Interaction.Border.Focus, + borderHover: Interaction.Border.Hover, + borderTable: Color.Neutrals[80], + borderTypography: Color.Neutrals[80], + divider: Color.Neutrals[80], }, color: { - black: '#ffffff', - blueDTwhite: '#fff', - border2: '#111', - border3: '#222', - boxShadow: '#222', - boxShadowDark: '#000', + black: Color.Neutrals.White, + blueDTwhite: Color.Neutrals.White, + border2: Color.Neutrals.Black, + border3: Color.Neutrals.Black, + boxShadow: 'rgba(0, 0, 0, 0.5)', + boxShadowDark: Color.Neutrals.Black, + buttonPrimaryHover: Button.Primary.Hover.Background, drawerBackdrop: 'rgba(0, 0, 0, 0.5)', - grey1: '#abadaf', - grey2: 'rgba(0, 0, 0, 0.2)', - grey3: '#999', - grey5: 'rgba(0, 0, 0, 0.2)', - grey6: '#606469', - grey7: '#2e3238', + grey1: Color.Neutrals[50], + grey2: Color.Neutrals[100], + grey3: Color.Neutrals[60], + grey5: Color.Neutrals[100], + grey6: Color.Neutrals[50], + grey7: Color.Neutrals[80], grey9: primaryColors.divider, headline: primaryColors.headline, - label: '#c9cacb', - offBlack: '#ffffff', - red: '#fb6d6d', - tableHeaderText: '#fff', - tagButton: '#364863', - tagIcon: '#9caec9', - white: '#32363c', + label: Color.Neutrals[40], + offBlack: Color.Neutrals.White, + red: Color.Red[70], + tableHeaderText: Color.Neutrals.White, + // TODO: `tagButton*` should be moved to component level. + tagButtonBg: Color.Brand[40], + tagButtonBgHover: Button.Primary.Hover.Background, + tagButtonText: Button.Primary.Default.Text, + tagButtonTextHover: Button.Primary.Hover.Text, + tagIcon: Button.Primary.Default.Icon, + tagIconHover: Button.Primary.Default.Text, + white: Color.Neutrals[100], }, textColors: { - headlineStatic: '#e6e6e6', - linkActiveLight: '#74aae6', - tableHeader: '#888F91', - tableStatic: '#e6e6e6', - textAccessTable: '#acb0b4', + headlineStatic: Color.Neutrals[20], + linkActiveLight: Action.Primary.Default, + linkHover: Action.Primary.Hover, + tableHeader: Color.Neutrals[60], + tableStatic: Color.Neutrals[20], + textAccessTable: Color.Neutrals[50], + }, +} as const; + +export const notificationToast = { + default: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + color: NotificationToast.Text, + }, + error: { + backgroundColor: NotificationToast.Error.Background, + borderLeft: `6px solid ${NotificationToast.Error.Border}`, + }, + info: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + }, + success: { + backgroundColor: NotificationToast.Success.Background, + borderLeft: `6px solid ${NotificationToast.Success.Border}`, + }, + warning: { + backgroundColor: NotificationToast.Warning.Background, + borderLeft: `6px solid ${NotificationToast.Warning.Border}`, }, } as const; @@ -71,7 +127,7 @@ const iconCircleAnimation = { transition: 'fill .2s ease-in-out .2s', }, '& .insidePath *': { - stroke: 'white', + stroke: Color.Neutrals.White, transition: 'fill .2s ease-in-out .2s, stroke .2s ease-in-out .2s', }, '& .outerCircle': { @@ -85,12 +141,12 @@ const iconCircleAnimation = { // Used for styling html buttons to look like our generic links const genericLinkStyle = { '&:hover': { - color: primaryColors.main, + color: Action.Primary.Hover, textDecoration: 'underline', }, background: 'none', border: 'none', - color: customDarkModeOptions.textColors.linkActiveLight, + color: Action.Primary.Default, cursor: 'pointer', font: 'inherit', padding: 0, @@ -148,6 +204,10 @@ export const darkTheme: ThemeOptions = { colorDefault: { backgroundColor: 'transparent', }, + root: { + backgroundColor: tempReplacementforColorNeutralsBlack, + border: 0, + }, }, }, MuiAutocomplete: { @@ -157,10 +217,10 @@ export const darkTheme: ThemeOptions = { border: `1px solid ${primaryColors.main}`, }, loading: { - color: '#fff', + color: Color.Neutrals.White, }, noOptions: { - color: '#fff', + color: Color.Neutrals.White, }, tag: { '.MuiChip-deleteIcon': { color: primaryColors.text }, @@ -176,63 +236,100 @@ export const darkTheme: ThemeOptions = { MuiButton: { styleOverrides: { containedPrimary: { + // TODO: We can remove this after migration since we can define variants '&.loading': { - color: primaryColors.text, + backgroundColor: primaryColors.text, }, '&:active': { - backgroundColor: primaryColors.dark, + backgroundColor: Button.Primary.Pressed.Background, }, '&:disabled': { - backgroundColor: '#454b54', - color: '#5c6470', + backgroundColor: Button.Primary.Disabled.Background, + color: Button.Primary.Disabled.Text, }, '&:hover, &:focus': { - backgroundColor: '#226dc3', + backgroundColor: Button.Primary.Hover.Background, + color: Button.Primary.Default.Text, }, '&[aria-disabled="true"]': { - backgroundColor: '#454b54', - color: '#5c6470', + backgroundColor: Button.Primary.Disabled.Background, + color: Button.Primary.Disabled.Text, }, + backgroundColor: Button.Primary.Default.Background, + color: Button.Primary.Default.Text, + padding: '2px 20px', }, containedSecondary: { - '&[aria-disabled="true"]': { - color: '#c9cacb', + // TODO: We can remove this after migration since we can define variants + '&.loading': { + color: primaryColors.text, + }, + '&:active': { + backgroundColor: 'transparent', + color: Button.Secondary.Pressed.Text, + }, + '&:disabled': { + backgroundColor: 'transparent', + color: Button.Secondary.Disabled.Text, }, - }, - outlined: { '&:hover, &:focus': { backgroundColor: 'transparent', - border: '1px solid #fff', - color: '#fff', + color: Button.Secondary.Hover.Text, }, '&[aria-disabled="true"]': { - backgroundColor: '#454b54', - border: '1px solid rgba(255, 255, 255, 0.12)', - color: '#5c6470', + backgroundColor: 'transparent', + color: Button.Secondary.Disabled.Text, }, - color: customDarkModeOptions.textColors.linkActiveLight, + backgroundColor: 'transparent', + color: Button.Secondary.Default.Text, }, - root: { - '&.loading': { - color: primaryColors.text, + outlined: { + '&:active': { + backgroundColor: Button.Secondary.Pressed.Background, + borderColor: Button.Secondary.Pressed.Text, + color: Button.Secondary.Pressed.Text, }, - '&:disabled': { - backgroundColor: '#454b54', - color: '#5c6470', + '&:hover, &:focus': { + backgroundColor: Button.Secondary.Hover.Background, + border: `1px solid ${Button.Secondary.Hover.Border}`, + color: Button.Secondary.Hover.Text, }, - '&:hover': { - backgroundColor: '#000', + '&[aria-disabled="true"]': { + backgroundColor: Button.Secondary.Disabled.Background, + border: `1px solid ${Button.Secondary.Disabled.Border}`, + color: Button.Secondary.Disabled.Text, }, + backgroundColor: Button.Secondary.Default.Background, + border: `1px solid ${Button.Secondary.Default.Border}`, + color: Button.Secondary.Default.Text, + minHeight: 34, + }, + root: { '&[aria-disabled="true"]': { cursor: 'not-allowed', }, - color: primaryColors.main, + border: 'none', + borderRadius: 1, + cursor: 'pointer', + fontFamily: latoWeb.bold, + fontSize: '1rem', + lineHeight: 1, + minHeight: 34, + minWidth: 105, + textTransform: 'capitalize', + transition: 'none', }, }, }, MuiButtonBase: { styleOverrides: { root: { + '&[aria-disabled="true"]': { + '& .MuiSvgIcon-root': { + fill: Button.Primary.Disabled.Icon, + }, + cursor: 'not-allowed', + }, fontSize: '1rem', }, }, @@ -247,7 +344,7 @@ export const darkTheme: ThemeOptions = { MuiCardHeader: { styleOverrides: { root: { - backgroundColor: 'rgba(0, 0, 0, 0.2)', + backgroundColor: Color.Neutrals[50], }, }, }, @@ -258,21 +355,42 @@ export const darkTheme: ThemeOptions = { }, styleOverrides: { clickable: { - '&:focus': { - backgroundColor: '#374863', - }, - '&:hover': { - backgroundColor: '#374863', - }, - backgroundColor: '#415d81', + color: Color.Brand[100], + }, + colorError: { + backgroundColor: Badge.Bold.Red.Background, + color: Badge.Bold.Red.Text, }, colorInfo: { - color: primaryColors.dark, + backgroundColor: Badge.Bold.Ultramarine.Background, + color: Badge.Bold.Ultramarine.Text, + }, + colorPrimary: { + backgroundColor: Badge.Bold.Ultramarine.Background, + color: Badge.Bold.Ultramarine.Text, + }, + colorSecondary: { + '&.MuiChip-clickable': { + '&:hover': { + backgroundColor: Badge.Bold.Ultramarine.Background, + color: Badge.Bold.Ultramarine.Text, + }, + }, + backgroundColor: Badge.Bold.Ultramarine.Background, + color: Badge.Bold.Ultramarine.Text, + }, + colorSuccess: { + backgroundColor: Badge.Bold.Green.Background, + color: Badge.Bold.Green.Text, }, colorWarning: { - color: primaryColors.dark, + backgroundColor: Badge.Bold.Amber.Background, + color: Badge.Bold.Amber.Text, }, outlined: { + '& .MuiChip-label': { + color: primaryColors.text, + }, backgroundColor: 'transparent', borderRadius: 1, }, @@ -284,30 +402,39 @@ export const darkTheme: ThemeOptions = { MuiDialog: { styleOverrides: { paper: { - boxShadow: '0 0 5px #222', + boxShadow: `0 0 5px ${Color.Neutrals[100]}`, }, }, }, MuiDialogTitle: { styleOverrides: { root: { - borderBottom: '1px solid #222', + borderBottom: `1px solid ${Color.Neutrals[100]}`, color: primaryColors.headline, }, }, }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: customDarkModeOptions.borderColors.divider, + }, + }, + }, MuiDrawer: { styleOverrides: { paper: { - boxShadow: '0 0 5px #222', + border: 0, + boxShadow: `0 0 5px ${Color.Neutrals[100]}`, }, }, }, MuiFormControl: { styleOverrides: { root: { + // Component.Checkbox.Checked.Disabled '&.copy > div': { - backgroundColor: '#2f3236', + backgroundColor: Color.Neutrals[100], }, }, }, @@ -317,7 +444,7 @@ export const darkTheme: ThemeOptions = { disabled: {}, label: { '&.Mui-disabled': { - color: '#aaa !important', + color: `${Color.Neutrals[50]} !important`, }, color: primaryColors.text, }, @@ -327,10 +454,10 @@ export const darkTheme: ThemeOptions = { MuiFormHelperText: { styleOverrides: { root: { - '&$error': { - color: '#fb6d6d', + '&[class*="error"]': { + color: Select.Error.HintText, }, - color: '#c9cacb', + color: Color.Neutrals[40], lineHeight: 1.25, }, }, @@ -339,15 +466,24 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '&$disabled': { - color: '#c9cacb', + color: Color.Neutrals[40], }, '&$error': { - color: '#c9cacb', + color: Color.Neutrals[40], }, '&.Mui-focused': { - color: '#c9cacb', + color: Color.Neutrals[40], + }, + color: Color.Neutrals[40], + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + '&:hover': { + color: primaryColors.main, }, - color: '#c9cacb', }, }, }, @@ -358,31 +494,48 @@ export const darkTheme: ThemeOptions = { input: { '&.Mui-disabled': { WebkitTextFillColor: 'unset !important', - borderColor: '#606469', - color: '#ccc !important', - opacity: 0.5, }, }, root: { '& svg': { - color: primaryColors.main, + color: TextField.Default.InfoIcon, }, '&.Mui-disabled': { - backgroundColor: '#444444', - borderColor: '#606469', - color: '#ccc !important', - opacity: 0.5, + '& svg': { + color: TextField.Disabled.InfoIcon, + }, + backgroundColor: TextField.Disabled.Background, + borderColor: TextField.Disabled.Border, + color: TextField.Disabled.Text, }, '&.Mui-error': { - borderColor: '#fb6d6d', + '& svg': { + color: TextField.Error.Icon, + }, + backgroundColor: TextField.Error.Background, + borderColor: TextField.Error.Border, + color: TextField.Error.Text, }, '&.Mui-focused': { - borderColor: primaryColors.main, - boxShadow: '0 0 2px 1px #222', + '& svg': { + color: TextField.Focus.Icon, + }, + backgroundColor: TextField.Focus.Background, + borderColor: TextField.Focus.Border, + boxShadow: `0 0 2px 1px ${Color.Neutrals[100]}`, + color: TextField.Focus.Text, + }, + '&.Mui-hover': { + '& svg': { + color: TextField.Hover.Icon, + }, + backgroundColor: TextField.Hover.Background, + borderColor: TextField.Hover.Border, + color: TextField.Hover.Text, }, - backgroundColor: '#444', - border: '1px solid #222', - color: primaryColors.text, + backgroundColor: TextField.Default.Background, + borderColor: TextField.Default.Border, + color: TextField.Filled.Text, }, }, }, @@ -390,9 +543,9 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '& p': { - color: '#eee', + color: Color.Neutrals[20], }, - color: '#eee', + color: Color.Neutrals[20], }, }, }, @@ -409,12 +562,32 @@ export const darkTheme: ThemeOptions = { MuiMenuItem: { styleOverrides: { root: { - '&$selected, &$selected:hover': { - backgroundColor: 'transparent', - color: primaryColors.main, + '&.loading': { + backgroundColor: primaryColors.text, + }, + '&:active': { + backgroundColor: Dropdown.Background.Default, + }, + '&:disabled': { + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Disabled, opacity: 1, }, - color: primaryColors.text, + '&:hover, &:focus': { + backgroundColor: Dropdown.Background.Hover, + color: Dropdown.Text.Default, + }, + '&:last-child': { + borderBottom: 0, + }, + '&[aria-disabled="true"]': { + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Disabled, + opacity: 1, + }, + backgroundColor: tempReplacementforColorNeutralsBlack, + color: Dropdown.Text.Default, + padding: '10px 10px 10px 16px', }, selected: {}, }, @@ -422,18 +595,23 @@ export const darkTheme: ThemeOptions = { MuiPaper: { styleOverrides: { outlined: { - border: '1px solid rgba(0, 0, 0, 0.2)', + // TODO: We can remove this variant since they will always have a border + backgroundColor: Color.Neutrals[90], + border: `1px solid ${Color.Neutrals[80]}`, }, root: { - backgroundColor: '#2e3238', + backgroundColor: Color.Neutrals[90], backgroundImage: 'none', // I have no idea why MUI defaults to setting a background image... + border: 0, }, }, }, MuiPopover: { styleOverrides: { paper: { - boxShadow: '0 0 5px #222', + background: tempReplacementforColorNeutralsBlack, + border: 0, + boxShadow: `0 2px 6px 0 rgba(0, 0, 0, 0.18)`, // TODO: Fix Elevation.S to remove `inset` }, }, }, @@ -454,14 +632,14 @@ export const darkTheme: ThemeOptions = { root: ({ theme }) => ({ '& .defaultFill': { '& circle': { - color: '#ccc', + color: Color.Neutrals[40], }, - color: '#55595c', - fill: '#53575a', + color: Color.Neutrals[80], + fill: Color.Neutrals[80], }, '&.Mui-disabled': { '& .defaultFill': { - color: '#ccc', + color: Color.Neutrals[40], opacity: 0.15, }, }, @@ -477,8 +655,8 @@ export const darkTheme: ThemeOptions = { MuiSnackbarContent: { styleOverrides: { root: { - backgroundColor: '#32363c', - boxShadow: '0 0 5px #222', + backgroundColor: Color.Neutrals[100], + boxShadow: `0 0 5px ${Color.Neutrals[100]}`, color: primaryColors.text, }, }, @@ -494,7 +672,7 @@ export const darkTheme: ThemeOptions = { }, }, track: { - backgroundColor: '#55595c', + backgroundColor: Color.Neutrals[80], }, }, }, @@ -502,16 +680,29 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '&$selected, &$selected:hover': { - color: '#fff', + color: Color.Neutrals.White, }, - color: '#fff', + color: Color.Neutrals.White, }, selected: {}, textColorPrimary: { '&$selected, &$selected:hover': { - color: '#fff', + color: Color.Neutrals.White, }, - color: '#fff', + color: Color.Neutrals.White, + }, + }, + }, + MuiTable: { + styleOverrides: { + root: { + // For nested tables like VPC + '& table': { + border: 0, + }, + border: `1px solid ${customDarkModeOptions.borderColors.borderTable}`, + borderBottom: 0, + borderTop: 0, }, }, }, @@ -523,10 +714,10 @@ export const darkTheme: ThemeOptions = { }, root: { '& a': { - color: customDarkModeOptions.textColors.linkActiveLight, + color: Action.Primary.Default, }, '& a:hover': { - color: primaryColors.main, + color: Action.Primary.Hover, }, borderBottom: `1px solid ${primaryColors.divider}`, borderTop: `1px solid ${primaryColors.divider}`, @@ -539,7 +730,7 @@ export const darkTheme: ThemeOptions = { '&:before': { backgroundColor: 'rgba(0, 0, 0, 0.15) !important', }, - backgroundColor: '#32363c', + backgroundColor: Color.Neutrals[100], }, hover: { '& a': { @@ -548,14 +739,13 @@ export const darkTheme: ThemeOptions = { }, root: { '&:before': { - borderLeftColor: '#32363c', + borderLeftColor: Color.Neutrals[90], }, '&:hover, &:focus': { - '&$hover': { - backgroundColor: 'rgba(0, 0, 0, 0.1)', - }, + backgroundColor: Color.Neutrals[80], }, - backgroundColor: '#32363c', + backgroundColor: Color.Neutrals[90], + border: `1px solid ${Color.Neutrals[50]}`, }, }, }, @@ -563,23 +753,23 @@ export const darkTheme: ThemeOptions = { styleOverrides: { flexContainer: { '& $scrollButtons:first-of-type': { - color: '#222', + color: Color.Neutrals.Black, }, }, root: { - boxShadow: 'inset 0 -1px 0 #222', + boxShadow: `inset 0 -1px 0 ${Color.Neutrals[100]}`, }, scrollButtons: { - color: '#fff', + color: Color.Neutrals.White, }, }, }, MuiTooltip: { styleOverrides: { tooltip: { - backgroundColor: '#444', - boxShadow: '0 0 5px #222', - color: '#fff', + backgroundColor: Color.Neutrals[70], + boxShadow: `0 0 5px ${Color.Neutrals[100]}`, + color: Color.Neutrals.White, }, }, }, @@ -587,7 +777,7 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '& a': { - color: customDarkModeOptions.textColors.linkActiveLight, + color: Action.Primary.Default, }, '& a.black': { color: primaryColors.text, @@ -599,7 +789,7 @@ export const darkTheme: ThemeOptions = { color: primaryColors.text, }, '& a:hover': { - color: primaryColors.main, + color: Action.Primary.Hover, }, }, }, @@ -623,17 +813,58 @@ export const darkTheme: ThemeOptions = { red: `rgb(255, 99, 60)`, yellow: `rgb(255, 220, 125)`, }, + inputStyles: { + default: { + backgroundColor: Select.Default.Background, + borderColor: Select.Default.Border, + color: Select.Default.Text, + }, + disabled: { + '& svg': { + color: Select.Disabled.Icon, + }, + backgroundColor: Select.Disabled.Background, + borderColor: Select.Disabled.Border, + color: Select.Disabled.Text, + }, + error: { + '& svg': { + color: Select.Error.Icon, + }, + backgroundColor: Select.Error.Background, + borderColor: Select.Error.Border, + color: Select.Error.Text, + }, + focused: { + '& svg': { + color: Select.Focus.Icon, + }, + backgroundColor: Select.Focus.Background, + borderColor: Select.Focus.Border, + boxShadow: `0 0 2px 1px ${Color.Neutrals[100]}`, + color: Select.Focus.Text, + }, + hover: { + '& svg': { + color: Select.Hover.Icon, + }, + backgroundColor: Select.Hover.Background, + borderColor: Select.Hover.Border, + color: Select.Hover.Text, + }, + }, name: 'dark', + notificationToast, palette: { background: { default: customDarkModeOptions.bg.app, - paper: '#2e3238', + paper: Color.Neutrals[100], }, divider: primaryColors.divider, error: { - dark: customDarkModeOptions.color.red, - light: customDarkModeOptions.color.red, - main: customDarkModeOptions.color.red, + dark: Color.Red[60], + light: Color.Red[10], + main: Color.Red[40], }, mode: 'dark', primary: primaryColors, diff --git a/packages/manager/src/foundations/themes/index.ts b/packages/manager/src/foundations/themes/index.ts index a30625e34f3..113dd754683 100644 --- a/packages/manager/src/foundations/themes/index.ts +++ b/packages/manager/src/foundations/themes/index.ts @@ -1,18 +1,23 @@ import { createTheme } from '@mui/material/styles'; -import { latoWeb } from 'src/foundations/fonts'; // Themes & Brands import { darkTheme } from 'src/foundations/themes/dark'; -// Types & Interfaces -import { customDarkModeOptions } from 'src/foundations/themes/dark'; import { lightTheme } from 'src/foundations/themes/light'; -import { +import { deepMerge } from 'src/utilities/deepMerge'; + +import type { latoWeb } from 'src/foundations/fonts'; +// Types & Interfaces +import type { + customDarkModeOptions, + notificationToast as notificationToastDark, +} from 'src/foundations/themes/dark'; +import type { bg, borderColors, color, + notificationToast, textColors, } from 'src/foundations/themes/light'; -import { deepMerge } from 'src/utilities/deepMerge'; export type ThemeName = 'dark' | 'light'; @@ -38,9 +43,15 @@ type TextColors = MergeTypes; type LightModeBorderColors = typeof borderColors; type DarkModeBorderColors = typeof customDarkModeOptions.borderColors; - type BorderColors = MergeTypes; +type LightNotificationToast = typeof notificationToast; +type DarkNotificationToast = typeof notificationToastDark; +type NotificationToast = MergeTypes< + LightNotificationToast, + DarkNotificationToast +>; + /** * Augmenting the Theme and ThemeOptions. * This allows us to add custom fields to the theme. @@ -58,7 +69,9 @@ declare module '@mui/material/styles/createTheme' { color: Colors; font: Fonts; graphs: any; + inputStyles: any; name: ThemeName; + notificationToast: NotificationToast; textColors: TextColors; visually: any; } @@ -74,7 +87,9 @@ declare module '@mui/material/styles/createTheme' { color?: DarkModeColors | LightModeColors; font?: Fonts; graphs?: any; + inputStyles?: any; name: ThemeName; + notificationToast?: NotificationToast; textColors?: DarkModeTextColors | LightModeTextColors; visually?: any; } diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index 1c2c63b87e1..548cfb375cb 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -1,80 +1,128 @@ -import { ThemeOptions } from '@mui/material/styles'; +import { + Action, + Border, + Button, + Color, + Dropdown, + Interaction, + NotificationToast, + Select, +} from '@linode/design-language-system'; import { breakpoints } from 'src/foundations/breakpoints'; import { latoWeb } from 'src/foundations/fonts'; +import type { ThemeOptions } from '@mui/material/styles'; + export const inputMaxWidth = 416; export const bg = { - app: '#f4f5f6', - bgAccessRow: '#fafafa', + app: Color.Neutrals[5], + appBar: 'transparent', + bgAccessRow: Color.Neutrals[5], bgAccessRowTransparentGradient: 'rgb(255, 255, 255, .001)', - bgPaper: '#ffffff', - lightBlue1: '#f0f7ff', - lightBlue2: '#e5f1ff', - main: '#f4f4f4', - mainContentBanner: '#33373d', - offWhite: '#fbfbfb', - primaryNavPaper: '#3a3f46', - tableHeader: '#f9fafa', - white: '#fff', + bgPaper: Color.Neutrals.White, + interactionBgPrimary: Interaction.Background.Secondary, + lightBlue1: Color.Brand[10], + lightBlue2: Color.Brand[40], + main: Color.Neutrals[5], + mainContentBanner: Color.Neutrals[100], + offWhite: Color.Neutrals[5], + primaryNavPaper: Color.Neutrals[100], + tableHeader: Color.Neutrals[10], + white: Color.Neutrals.White, } as const; const primaryColors = { - dark: '#2466b3', - divider: '#f4f4f4', - headline: '#32363c', - light: '#4d99f1', - main: '#3683dc', - text: '#606469', - white: '#fff', + dark: Color.Brand[90], + divider: Color.Neutrals[5], + headline: Color.Neutrals[100], + light: Color.Brand[60], + main: Color.Brand[80], + text: Color.Neutrals[70], + white: Color.Neutrals.White, }; export const color = { - black: '#222', - blue: '#3683dc', - blueDTwhite: '#3683dc', - border2: '#c5c6c8', - border3: '#eee', - boxShadow: '#ddd', - boxShadowDark: '#aaa', - disabledText: '#c9cacb', + black: Color.Neutrals.Black, + blue: Color.Brand[80], + blueDTwhite: Color.Brand[80], + border2: Color.Neutrals[40], + border3: Color.Neutrals[20], + boxShadow: Color.Neutrals[30], + boxShadowDark: Color.Neutrals[50], + buttonPrimaryHover: Button.Primary.Hover.Background, + disabledText: Color.Neutrals[40], drawerBackdrop: 'rgba(255, 255, 255, 0.5)', - green: '#00b159', - grey1: '#abadaf', - grey2: '#e7e7e7', - grey3: '#ccc', - grey4: '#8C929D', - grey5: '#f5f5f5', - grey6: '#e3e5e8', - grey7: '#e9eaef', - grey8: '#dbdde1', - grey9: '#f4f5f6', + green: Color.Green[70], + grey1: Color.Neutrals[50], + grey2: Color.Neutrals[30], + grey3: Color.Neutrals[40], + grey4: Color.Neutrals[60], + grey5: Color.Neutrals[5], + grey6: Color.Neutrals[30], + grey7: Color.Neutrals[20], + grey8: Color.Neutrals[30], + grey9: Color.Neutrals[5], + grey10: Color.Neutrals[10], headline: primaryColors.headline, - label: '#555', - offBlack: '#444', - orange: '#ffb31a', - red: '#ca0813', + label: Color.Neutrals[70], + offBlack: Color.Neutrals[90], + orange: Color.Amber[70], + red: Color.Red[70], tableHeaderText: 'rgba(0, 0, 0, 0.54)', - tagButton: '#f1f7fd', - tagIcon: '#7daee8', - teal: '#17cf73', - white: '#fff', - yellow: '#fecf2f', + // TODO: `tagButton*` should be moved to component level. + tagButtonBg: Color.Brand[10], + tagButtonBgHover: Button.Primary.Hover.Background, + tagButtonText: Color.Brand[60], + tagButtonTextHover: Color.Neutrals.White, + tagIcon: Color.Brand[60], + tagIconHover: Button.Primary.Default.Text, + teal: Color.Teal[70], + white: Color.Neutrals.White, + yellow: Color.Yellow[70], } as const; export const textColors = { - headlineStatic: '#32363c', - linkActiveLight: '#2575d0', - tableHeader: '#888f91', - tableStatic: '#606469', - textAccessTable: '#606469', + headlineStatic: Color.Neutrals[100], + linkActiveLight: Action.Primary.Default, + linkHover: Action.Primary.Hover, + tableHeader: Color.Neutrals[60], + tableStatic: Color.Neutrals[70], + textAccessTable: Color.Neutrals[70], } as const; export const borderColors = { - borderTable: '#f4f5f6', - borderTypography: '#e3e5e8', - divider: '#e3e5e8', + borderFocus: Interaction.Border.Focus, + borderHover: Interaction.Border.Hover, + borderTable: Color.Neutrals[5], + borderTypography: Color.Neutrals[30], + divider: Color.Neutrals[30], + dividerDark: Color.Neutrals[80], +} as const; + +export const notificationToast = { + default: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + color: NotificationToast.Text, + }, + error: { + backgroundColor: NotificationToast.Error.Background, + borderLeft: `6px solid ${NotificationToast.Error.Border}`, + }, + info: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + }, + success: { + backgroundColor: NotificationToast.Success.Background, + borderLeft: `6px solid ${NotificationToast.Success.Border}`, + }, + warning: { + backgroundColor: NotificationToast.Warning.Background, + borderLeft: `6px solid ${NotificationToast.Warning.Border}`, + }, } as const; const iconCircleAnimation = { @@ -105,14 +153,18 @@ const iconCircleHoverEffect = { // Used for styling html buttons to look like our generic links const genericLinkStyle = { - '&:hover': { + '&:disabled': { + color: Action.Primary.Disabled, + cursor: 'not-allowed', + }, + '&:hover:not(:disabled)': { backgroundColor: 'transparent', - color: primaryColors.main, + color: Action.Primary.Hover, textDecoration: 'underline', }, background: 'none', border: 'none', - color: textColors.linkActiveLight, + color: Action.Primary.Default, cursor: 'pointer', font: 'inherit', minWidth: 0, @@ -196,6 +248,9 @@ export const lightTheme: ThemeOptions = { paddingBottom: 12, paddingLeft: 16, }, + '&:before': { + display: 'none', + }, flexBasis: '100%', width: '100%', }, @@ -222,8 +277,8 @@ export const lightTheme: ThemeOptions = { transition: 'color 400ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', }, '& svg': { - fill: '#2575d0', - stroke: '#2575d0', + fill: Color.Brand[80], + stroke: Color.Brand[80], }, '&.Mui-expanded': { '& .caret': { @@ -249,6 +304,13 @@ export const lightTheme: ThemeOptions = { colorDefault: { backgroundColor: 'inherit', }, + root: { + backgroundColor: bg.bgPaper, + borderLeft: 0, + borderTop: 0, + color: primaryColors.text, + position: 'relative', + }, }, }, MuiAutocomplete: { @@ -268,7 +330,7 @@ export const lightTheme: ThemeOptions = { }, paddingRight: 4, svg: { - color: '#aaa', + color: Color.Neutrals[40], }, top: 'unset', }, @@ -360,7 +422,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { colorDefault: { backgroundColor: 'unset', - color: '#c9c7c7', + color: Color.Neutrals[40], // TODO: This was the closest color according to our palette }, }, }, @@ -382,20 +444,22 @@ export const lightTheme: ThemeOptions = { backgroundColor: primaryColors.text, }, '&:active': { - backgroundColor: primaryColors.dark, + backgroundColor: Button.Primary.Pressed.Background, }, '&:disabled': { - color: 'white', + backgroundColor: Button.Primary.Disabled.Background, + color: Button.Primary.Disabled.Text, }, '&:hover, &:focus': { - backgroundColor: '#226dc3', + backgroundColor: Button.Primary.Hover.Background, + color: Button.Primary.Default.Text, }, '&[aria-disabled="true"]': { - backgroundColor: 'rgba(0, 0, 0, 0.12)', - color: 'white', + backgroundColor: Button.Primary.Disabled.Background, + color: Button.Primary.Disabled.Text, }, - backgroundColor: primaryColors.main, - color: '#fff', + backgroundColor: Button.Primary.Default.Background, + color: Button.Primary.Default.Text, padding: '2px 20px', }, containedSecondary: { @@ -405,29 +469,29 @@ export const lightTheme: ThemeOptions = { }, '&:active': { backgroundColor: 'transparent', - borderColor: primaryColors.dark, - color: primaryColors.dark, + borderColor: Button.Secondary.Pressed.Text, + color: Button.Secondary.Pressed.Text, }, '&:disabled': { backgroundColor: 'transparent', - borderColor: '#c9cacb', - color: '#c9cacb', + borderColor: Button.Secondary.Disabled.Text, + color: Button.Secondary.Disabled.Text, }, '&:hover, &:focus': { backgroundColor: 'transparent', - color: textColors.linkActiveLight, + color: Button.Secondary.Hover.Text, }, '&[aria-disabled="true"]': { color: '#c9cacb', }, backgroundColor: 'transparent', - color: textColors.linkActiveLight, + color: Button.Secondary.Default.Text, }, outlined: { '&:hover, &:focus': { - backgroundColor: '#f5f8ff', - border: '1px solid #d7dfed', - color: '#2575d0', + backgroundColor: Color.Neutrals[5], + border: `1px solid ${Border.Normal}`, + color: Color.Brand[80], }, '&[aria-disabled="true"]': { backgroundColor: 'transparent', @@ -459,6 +523,12 @@ export const lightTheme: ThemeOptions = { MuiButtonBase: { styleOverrides: { root: { + '&[aria-disabled="true"]': { + '& .MuiSvgIcon-root': { + fill: Button.Primary.Disabled.Icon, + }, + cursor: 'not-allowed', + }, fontSize: '1rem', }, }, @@ -470,14 +540,14 @@ export const lightTheme: ThemeOptions = { minWidth: 0, }, root: { - backgroundColor: '#fbfbfb', + backgroundColor: Color.Neutrals[5], }, }, }, MuiCheckbox: { styleOverrides: { root: { - color: '#ccc', + color: Color.Neutrals[40], }, }, }, @@ -485,14 +555,15 @@ export const lightTheme: ThemeOptions = { styleOverrides: { clickable: { '&:focus': { - backgroundColor: '#cce2ff', + backgroundColor: Color.Brand[30], // TODO: This was the closest color according to our palette }, '&:hover': { - backgroundColor: '#cce2ff', + backgroundColor: Color.Brand[30], // TODO: This was the closest color according to our palette }, - backgroundColor: '#e5f1ff', + backgroundColor: Color.Brand[10], // TODO: This was the closest color according to our palette }, colorError: { + background: Color.Red[70], color: color.white, }, colorPrimary: { @@ -502,6 +573,7 @@ export const lightTheme: ThemeOptions = { color: color.white, }, colorSuccess: { + background: Color.Green[70], color: color.white, }, deleteIcon: { @@ -528,7 +600,7 @@ export const lightTheme: ThemeOptions = { }, root: { '&:focus': { - outline: '1px dotted #999', + outline: `1px dotted ${Color.Neutrals[60]}`, }, '&:last-child': { marginRight: 0, @@ -552,6 +624,9 @@ export const lightTheme: ThemeOptions = { }, }, MuiCircularProgress: { + defaultProps: { + disableShrink: true, + }, styleOverrides: { circle: { strokeLinecap: 'inherit', @@ -568,7 +643,7 @@ export const lightTheme: ThemeOptions = { MuiDialog: { styleOverrides: { paper: { - boxShadow: '0 0 5px #bbb', + boxShadow: `0 0 5px ${Color.Neutrals[50]}`, // TODO: This was the closest color according to our palette [breakpoints.down('sm')]: { margin: 24, maxHeight: 'calc(100% - 48px)', @@ -605,7 +680,7 @@ export const lightTheme: ThemeOptions = { '& h2': { lineHeight: 1.2, }, - borderBottom: '1px solid #eee', + borderBottom: `1px solid ${Color.Neutrals[20]}`, color: primaryColors.headline, marginBottom: 20, padding: '16px 24px', @@ -615,7 +690,7 @@ export const lightTheme: ThemeOptions = { MuiDivider: { styleOverrides: { root: { - borderColor: 'rgba(0, 0, 0, 0.12)', + borderColor: borderColors.divider, marginBottom: spacing, marginTop: spacing, }, @@ -624,7 +699,7 @@ export const lightTheme: ThemeOptions = { MuiDrawer: { styleOverrides: { paper: { - boxShadow: '0 0 5px #bbb', + boxShadow: `0 0 5px ${Color.Neutrals[50]}`, // TODO: This was the closest color according to our palette /** @todo This is breaking typing. */ // overflowY: 'overlay', display: 'block', @@ -638,7 +713,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { root: { '&.copy > div': { - backgroundColor: '#f4f4f4', + backgroundColor: Color.Neutrals[5], }, [breakpoints.down('xs')]: { width: '100%', @@ -672,7 +747,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { root: { '&$error': { - color: '#ca0813', + color: Select.Error.HintText, }, fontSize: '0.875rem', lineHeight: 1.25, @@ -684,16 +759,16 @@ export const lightTheme: ThemeOptions = { styleOverrides: { root: { '&$disabled': { - color: '#555', + color: Color.Neutrals[70], opacity: 0.5, }, '&$error': { - color: '#555', + color: Color.Neutrals[70], }, '&.Mui-focused': { - color: '#555', + color: Color.Neutrals[70], }, - color: '#555', + color: Color.Neutrals[70], fontFamily: latoWeb.bold, fontSize: '.875rem', marginBottom: 8, @@ -730,6 +805,9 @@ export const lightTheme: ThemeOptions = { }, }, input: { + '&::placeholder': { + color: Color.Neutrals[50], + }, boxSizing: 'border-box', [breakpoints.only('xs')]: { fontSize: '1rem', @@ -745,14 +823,14 @@ export const lightTheme: ThemeOptions = { root: { '& svg': { '&:hover': { - color: '#5e9aea', + color: Color.Brand[60], }, color: primaryColors.main, fontSize: 18, }, '&.Mui-disabled': { backgroundColor: '#f4f4f4', - borderColor: '#ccc', + borderColor: Color.Neutrals[40], color: 'rgba(0, 0, 0, 0.75)', input: { cursor: 'not-allowed', @@ -760,21 +838,21 @@ export const lightTheme: ThemeOptions = { opacity: 0.5, }, '&.Mui-error': { - borderColor: '#ca0813', + borderColor: Interaction.Border.Error, }, '&.Mui-focused': { '& .select-option-icon': { paddingLeft: `30px !important`, }, borderColor: primaryColors.main, - boxShadow: '0 0 2px 1px #e1edfa', + boxShadow: `0 0 2px 1px ${Color.Neutrals[30]}`, }, '&.affirmative': { - borderColor: '#00b159', + borderColor: Color.Green[70], }, alignItems: 'center', - backgroundColor: '#fff', - border: '1px solid #ccc', + backgroundColor: Color.Neutrals.White, + border: `1px solid ${Color.Neutrals[40]}`, boxSizing: 'border-box', [breakpoints.down('xs')]: { maxWidth: '100%', @@ -798,13 +876,13 @@ export const lightTheme: ThemeOptions = { [breakpoints.only('xs')]: { fontSize: '1rem', }, - color: '#606469', + color: Color.Neutrals[70], fontSize: '0.9rem', }, [breakpoints.only('xs')]: { fontSize: '1rem', }, - color: '#606469', + color: Color.Neutrals[70], fontSize: '0.9rem', whiteSpace: 'nowrap', }, @@ -814,7 +892,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { input: { '&::placeholder': { - opacity: 0.42, + opacity: 1, }, height: 'auto', }, @@ -833,7 +911,7 @@ export const lightTheme: ThemeOptions = { MuiLinearProgress: { styleOverrides: { colorPrimary: { - backgroundColor: '#b7d6f9', + backgroundColor: Color.Brand[40], // TODO: This was the closest color according to our palette }, }, }, @@ -918,6 +996,8 @@ export const lightTheme: ThemeOptions = { outline: 0, position: 'absolute', }, + borderLeft: 0, + borderRight: 0, maxWidth: 350, }, }, @@ -925,28 +1005,31 @@ export const lightTheme: ThemeOptions = { MuiMenuItem: { styleOverrides: { root: { - '& em': { - fontStyle: 'normal !important', + '&.loading': { + backgroundColor: primaryColors.text, }, - '&$selected, &$selected:hover': { - backgroundColor: 'transparent', - color: primaryColors.main, - opacity: 1, + '&:active': { + backgroundColor: Dropdown.Background.Default, }, - '&:hover': { - backgroundColor: primaryColors.main, - color: 'white', + '&:disabled': { + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Disabled, }, - color: primaryColors.text, - fontFamily: latoWeb.normal, - fontSize: '.9rem', - height: 'auto', - minHeight: '38px', - paddingBottom: 8, - paddingTop: 8, - textOverflow: 'initial', - transition: `${'background-color 150ms cubic-bezier(0.4, 0, 0.2, 1)'}`, - whiteSpace: 'initial', + '&:hover, &:focus': { + backgroundColor: Dropdown.Background.Hover, + color: Dropdown.Text.Default, + }, + '&:last-child)': { + borderBottom: 0, + }, + '&[aria-disabled="true"]': { + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Disabled, + opacity: 1, + }, + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Default, + padding: '10px 10px 10px 16px', }, selected: {}, }, @@ -954,7 +1037,7 @@ export const lightTheme: ThemeOptions = { MuiPaper: { styleOverrides: { outlined: { - border: '1px solid #e7e7e7', + border: `1px solid ${Color.Neutrals[30]}`, }, root: {}, rounded: { @@ -966,7 +1049,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { paper: { borderRadius: 0, - boxShadow: '0 0 5px #ddd', + boxShadow: `0 2px 6px 0 rgba(0, 0, 0, 0.18)`, // TODO: Fix Elevation.S to remove `inset` [breakpoints.up('lg')]: { minWidth: 250, }, @@ -1001,10 +1084,10 @@ export const lightTheme: ThemeOptions = { }, '&.Mui-disabled': { '& .defaultFill': { - fill: '#f4f4f4', + fill: Color.Neutrals[5], }, - color: '#ccc !important', - fill: '#f4f4f4 !important', + color: `${Color.Neutrals[40]} !important`, + fill: `${Color.Neutrals[5]} !important`, pointerEvents: 'none', }, '&:hover': { @@ -1014,7 +1097,7 @@ export const lightTheme: ThemeOptions = { color: theme.palette.primary.main, fill: theme.color.white, }, - color: '#ccc', + color: Color.Neutrals[40], padding: '10px 10px', transition: theme.transitions.create(['color']), }), @@ -1024,7 +1107,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { disabled: {}, icon: { - color: '#aaa !important', + color: `${Color.Neutrals[50]} !important`, height: 28, marginRight: 4, marginTop: -2, @@ -1058,8 +1141,8 @@ export const lightTheme: ThemeOptions = { backgroundColor: 'white', borderLeft: `6px solid transparent`, borderRadius: 4, - boxShadow: '0 0 5px #ddd', - color: '#606469', + boxShadow: `0 0 5px ${Color.Neutrals[30]}`, + color: Color.Neutrals[70], }, }, }, @@ -1092,8 +1175,8 @@ export const lightTheme: ThemeOptions = { '& $disabled': { '&$switchBase': { '& + $track': { - backgroundColor: '#ddd', - borderColor: '#ccc', + backgroundColor: Color.Neutrals[30], + borderColor: Color.Neutrals[40], }, '& .square': { fill: 'white', @@ -1131,15 +1214,15 @@ export const lightTheme: ThemeOptions = { }, '&.Mui-disabled': { '& +.MuiSwitch-track': { - backgroundColor: '#ddd', - borderColor: '#ccc', + backgroundColor: Color.Neutrals[30], + borderColor: Color.Neutrals[40], }, }, color: primaryColors.main, padding: 16, }, track: { - backgroundColor: '#C9CACB', + backgroundColor: Color.Neutrals[40], borderRadius: 1, boxSizing: 'content-box', height: 24, @@ -1187,7 +1270,7 @@ export const lightTheme: ThemeOptions = { selected: {}, textColorPrimary: { '&$selected': { - color: '#32363c', + color: Color.Neutrals[100], }, }, }, @@ -1195,7 +1278,10 @@ export const lightTheme: ThemeOptions = { MuiTable: { styleOverrides: { root: { + border: `1px solid ${borderColors.borderTable}`, + borderBottom: 0, borderCollapse: 'initial', + borderTop: 0, }, }, }, @@ -1219,7 +1305,7 @@ export const lightTheme: ThemeOptions = { MuiTableRow: { styleOverrides: { head: { - backgroundColor: '#fbfbfb', + backgroundColor: Color.Neutrals[5], height: 'auto', }, hover: { @@ -1236,12 +1322,7 @@ export const lightTheme: ThemeOptions = { }, root: { '&:hover, &:focus': { - '&$hover': { - backgroundColor: '#fbfbfb', - [breakpoints.up('md')]: { - boxShadow: `inset 5px 0 0 ${primaryColors.main}`, - }, - }, + backgroundColor: Color.Neutrals[5], }, backfaceVisibility: 'hidden', backgroundColor: primaryColors.white, @@ -1265,11 +1346,8 @@ export const lightTheme: ThemeOptions = { transform: 'rotate(180deg)', }, root: { - '&.Mui-active': { - color: textColors.tableHeader, - }, '&:focus': { - outline: '1px dotted #999', + outline: `1px dotted ${Color.Neutrals[60]}`, }, '&:hover': { color: primaryColors.main, @@ -1316,7 +1394,7 @@ export const lightTheme: ThemeOptions = { width: 38, }, }, - boxShadow: 'inset 0 -1px 0 #c5c6c8', + boxShadow: `inset 0 -1px 0 ${Color.Neutrals[40]}`, margin: '16px 0', minHeight: 48, position: 'relative', @@ -1334,12 +1412,12 @@ export const lightTheme: ThemeOptions = { tooltip: { backgroundColor: 'white', borderRadius: 0, - boxShadow: '0 0 5px #bbb', + boxShadow: `0 0 5px ${Color.Neutrals[50]}`, // TODO: This was the closest color according to our palette [breakpoints.up('sm')]: { fontSize: '.9rem', padding: '8px 10px', }, - color: '#606469', + color: Color.Neutrals[70], maxWidth: 200, textAlign: 'left', }, @@ -1374,7 +1452,7 @@ export const lightTheme: ThemeOptions = { maxHeight: 34, minWidth: 100, }, - color: '#fff', + color: Color.Neutrals.White, cursor: 'pointer', fontFamily: latoWeb.bold, fontSize: '1rem', @@ -1453,37 +1531,78 @@ export const lightTheme: ThemeOptions = { }, yellow: `rgba(255, 220, 125, ${graphTransparency})`, }, + inputStyles: { + default: { + backgroundColor: Select.Default.Background, + border: `1px solid ${Color.Neutrals[40]}`, // TODO: This should convert to token in future + color: Select.Default.Text, + }, + disabled: { + '& svg': { + color: Select.Disabled.Icon, + }, + backgroundColor: Select.Disabled.Background, + border: `1px solid ${Select.Disabled.Border}`, + color: Select.Disabled.Text, + }, + error: { + '& svg': { + color: Select.Error.Icon, + }, + backgroundColor: Select.Error.Background, + border: `1px solid ${Select.Error.Border}`, + color: Select.Error.Text, + }, + focused: { + '& svg': { + color: Select.Focus.Icon, + }, + backgroundColor: Select.Focus.Background, + border: `1px solid ${Select.Focus.Border}`, + boxShadow: `0 0 2px 1px ${Color.Neutrals[30]}`, + color: Select.Focus.Text, + }, + hover: { + '& svg': { + color: Select.Hover.Icon, + }, + backgroundColor: Select.Hover.Background, + border: `1px solid ${Color.Neutrals[40]}`, // TODO: This should convert to token in future + color: Select.Hover.Text, + }, + }, name: 'light', // @todo remove this because we leverage pallete.mode now + notificationToast, palette: { background: { default: bg.app, }, divider: primaryColors.divider, error: { - dark: color.red, - light: color.red, - main: color.red, + dark: Color.Red[70], + light: Color.Red[10], + main: Color.Red[40], }, info: { - dark: '#3682dd', - light: '#d7e3ef', - main: '#d7e3ef', + dark: Color.Ultramarine[70], + light: Color.Ultramarine[10], + main: Color.Ultramarine[40], }, mode: 'light', primary: primaryColors, secondary: primaryColors, success: { - dark: '#00b159', - light: '#00b159', - main: '#00b159', + dark: Color.Green[70], + light: Color.Green[10], + main: Color.Green[40], }, text: { primary: primaryColors.text, }, warning: { - dark: '#ffd002', - light: '#ffd002', - main: '#ffd002', + dark: Color.Amber[70], + light: Color.Amber[10], + main: Color.Amber[40], }, }, shadows: [ diff --git a/packages/manager/src/hooks/useAccountManagement.ts b/packages/manager/src/hooks/useAccountManagement.ts index 3501b7f0951..1829a3ca9ee 100644 --- a/packages/manager/src/hooks/useAccountManagement.ts +++ b/packages/manager/src/hooks/useAccountManagement.ts @@ -2,7 +2,7 @@ import { GlobalGrantTypes } from '@linode/api-v4/lib/account'; import { useAccount } from 'src/queries/account/account'; import { useAccountSettings } from 'src/queries/account/settings'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; export const useAccountManagement = () => { const { data: account, error: accountError } = useAccount(); diff --git a/packages/manager/src/hooks/useCreateVPC.ts b/packages/manager/src/hooks/useCreateVPC.ts index 3343ba59e0b..2ba217f9d8a 100644 --- a/packages/manager/src/hooks/useCreateVPC.ts +++ b/packages/manager/src/hooks/useCreateVPC.ts @@ -8,7 +8,7 @@ import { useFormik } from 'formik'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useCreateVPCMutation } from 'src/queries/vpcs/vpcs'; import { diff --git a/packages/manager/src/hooks/useDebouncedValue.test.ts b/packages/manager/src/hooks/useDebouncedValue.test.ts new file mode 100644 index 00000000000..cf560b93944 --- /dev/null +++ b/packages/manager/src/hooks/useDebouncedValue.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useDebouncedValue } from './useDebouncedValue'; + +describe('useDebouncedValue', () => { + it('debounces the provided value by the given delay', () => { + vi.useFakeTimers(); + + const { rerender, result } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: 'test' } } + ); + + expect(result.current).toBe('test'); + + rerender({ value: 'test-1' }); + + expect(result.current).toBe('test'); + + act(() => { + vi.advanceTimersByTime(400); + }); + + expect(result.current).toBe('test'); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(result.current).toBe('test-1'); + }); +}); diff --git a/packages/manager/src/hooks/useDebouncedValue.ts b/packages/manager/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000000..526ed0a470a --- /dev/null +++ b/packages/manager/src/hooks/useDebouncedValue.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useDebouncedValue = (value: T, delay: number = 500) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/packages/manager/src/hooks/useDismissibleNotifications.ts b/packages/manager/src/hooks/useDismissibleNotifications.ts index d87f35b0ce3..56cfc021664 100644 --- a/packages/manager/src/hooks/useDismissibleNotifications.ts +++ b/packages/manager/src/hooks/useDismissibleNotifications.ts @@ -2,7 +2,10 @@ import { DateTime } from 'luxon'; import md5 from 'md5'; import { useState } from 'react'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { DismissedNotification } from 'src/types/ManagerPreferences'; /** diff --git a/packages/manager/src/hooks/useEventHandlers.ts b/packages/manager/src/hooks/useEventHandlers.ts index 268422f9aaa..d4692ae1f42 100644 --- a/packages/manager/src/hooks/useEventHandlers.ts +++ b/packages/manager/src/hooks/useEventHandlers.ts @@ -1,17 +1,17 @@ import { useQueryClient } from '@tanstack/react-query'; import { oauthClientsEventHandler } from 'src/queries/account/oauth'; -import { databaseEventsHandler } from 'src/queries/databases'; +import { databaseEventsHandler } from 'src/queries/databases/events'; import { domainEventsHandler } from 'src/queries/domains'; import { firewallEventsHandler } from 'src/queries/firewalls'; import { imageEventsHandler } from 'src/queries/images'; import { diskEventHandler } from 'src/queries/linodes/events'; import { linodeEventsHandler } from 'src/queries/linodes/events'; -import { nodebalanacerEventHandler } from 'src/queries/nodebalancers'; -import { sshKeyEventHandler } from 'src/queries/profile'; +import { nodebalancerEventHandler } from 'src/queries/nodebalancers'; +import { sshKeyEventHandler } from 'src/queries/profile/profile'; import { stackScriptEventHandler } from 'src/queries/stackscripts'; import { supportTicketEventHandler } from 'src/queries/support'; -import { tokenEventHandler } from 'src/queries/tokens'; +import { tokenEventHandler } from 'src/queries/profile/tokens'; import { volumeEventsHandler } from 'src/queries/volumes/events'; import type { Event } from '@linode/api-v4'; @@ -58,7 +58,7 @@ export const eventHandlers: { }, { filter: (event) => event.action.startsWith('nodebalancer'), - handler: nodebalanacerEventHandler, + handler: nodebalancerEventHandler, }, { filter: (event) => event.action.startsWith('oauth_client'), diff --git a/packages/manager/src/hooks/useFlags.ts b/packages/manager/src/hooks/useFlags.ts index f3f64b623bd..36da4807a00 100644 --- a/packages/manager/src/hooks/useFlags.ts +++ b/packages/manager/src/hooks/useFlags.ts @@ -1,8 +1,8 @@ import { useFlags as ldUseFlags } from 'launchdarkly-react-client-sdk'; import { useSelector } from 'react-redux'; -import { FlagSet } from 'src/featureFlags'; -import { ApplicationState } from 'src/store'; +import type { FlagSet } from 'src/featureFlags'; +import type { ApplicationState } from 'src/store'; export { useLDClient } from 'launchdarkly-react-client-sdk'; /** @@ -29,5 +29,9 @@ export const useFlags = () => { return { ...flags, ...mockFlags, + // gecko2: { + // enabled: true, + // ga: true, + // }, }; }; diff --git a/packages/manager/src/hooks/useGlobalKeyboardListener.ts b/packages/manager/src/hooks/useGlobalKeyboardListener.ts index 0a632cf5f01..e95e600f1e4 100644 --- a/packages/manager/src/hooks/useGlobalKeyboardListener.ts +++ b/packages/manager/src/hooks/useGlobalKeyboardListener.ts @@ -1,6 +1,9 @@ import React from 'react'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { getNextThemeValue } from 'src/utilities/theme'; import { isOSMac } from 'src/utilities/userAgent'; diff --git a/packages/manager/src/hooks/useInitialRequests.ts b/packages/manager/src/hooks/useInitialRequests.ts index 25202e587bf..ec1dca356d6 100644 --- a/packages/manager/src/hooks/useInitialRequests.ts +++ b/packages/manager/src/hooks/useInitialRequests.ts @@ -5,7 +5,7 @@ import * as React from 'react'; import { useAuthentication } from 'src/hooks/useAuthentication'; import { usePendingUpload } from 'src/hooks/usePendingUpload'; import { accountQueries } from 'src/queries/account/queries'; -import { profileQueries } from 'src/queries/profile'; +import { profileQueries } from 'src/queries/profile/profile'; import { redirectToLogin } from 'src/session'; /** diff --git a/packages/manager/src/hooks/useIsResourceRestricted.ts b/packages/manager/src/hooks/useIsResourceRestricted.ts index e9126cf36af..997fcf6afb2 100644 --- a/packages/manager/src/hooks/useIsResourceRestricted.ts +++ b/packages/manager/src/hooks/useIsResourceRestricted.ts @@ -1,4 +1,4 @@ -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import type { GrantLevel, GrantType } from '@linode/api-v4'; diff --git a/packages/manager/src/hooks/useOrder.test.tsx b/packages/manager/src/hooks/useOrder.test.tsx index e3776006646..4451588aa48 100644 --- a/packages/manager/src/hooks/useOrder.test.tsx +++ b/packages/manager/src/hooks/useOrder.test.tsx @@ -1,9 +1,7 @@ -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/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { OrderSet } from 'src/types/ManagerPreferences'; import { wrapWithTheme } from 'src/utilities/testHelpers'; @@ -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/hooks/useOrder.ts b/packages/manager/src/hooks/useOrder.ts index ce31f94a334..0a9f195c57e 100644 --- a/packages/manager/src/hooks/useOrder.ts +++ b/packages/manager/src/hooks/useOrder.ts @@ -3,7 +3,10 @@ import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { getInitialValuesFromUserPreferences } from 'src/components/OrderBy'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { OrderSet } from 'src/types/ManagerPreferences'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; diff --git a/packages/manager/src/hooks/usePagination.ts b/packages/manager/src/hooks/usePagination.ts index ce90c3c1272..6095ef68bcc 100644 --- a/packages/manager/src/hooks/usePagination.ts +++ b/packages/manager/src/hooks/usePagination.ts @@ -1,7 +1,10 @@ import { useHistory, useLocation } from 'react-router-dom'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; export interface PaginationProps { handlePageChange: (page: number) => void; diff --git a/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.test.ts b/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.test.ts index b3ffd123797..6c298817a24 100644 --- a/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.test.ts +++ b/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.test.ts @@ -7,8 +7,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useGrants: queryMocks.useGrants, diff --git a/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.ts b/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.ts index d8243ad9806..266a0ef879e 100644 --- a/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.ts +++ b/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.ts @@ -1,4 +1,4 @@ -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import type { RestrictedGlobalGrantType } from 'src/features/Account/utils'; diff --git a/packages/manager/src/hooks/useToastNotifications.tsx b/packages/manager/src/hooks/useToastNotifications.tsx index fca04d10fd9..d62593921b7 100644 --- a/packages/manager/src/hooks/useToastNotifications.tsx +++ b/packages/manager/src/hooks/useToastNotifications.tsx @@ -1,4 +1,3 @@ -import { Event, EventAction } from '@linode/api-v4/lib/account/types'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -6,6 +5,8 @@ import { Link } from 'src/components/Link'; import { SupportLink } from 'src/components/SupportLink'; import { sendLinodeDiskEvent } from 'src/utilities/analytics/customEventAnalytics'; +import type { Event, EventAction } from '@linode/api-v4/lib/account/types'; + export const getLabel = (event: Event) => event.entity?.label ?? ''; export const getSecondaryLabel = (event: Event) => event.secondary_entity?.label ?? ''; @@ -17,11 +18,19 @@ const formatLink = (text: string, link: string, handleClick?: () => void) => { ); }; -interface Toast { - failure?: ((event: Event) => string | undefined) | string; +interface ToastMessage { link?: JSX.Element; - persistFailureMessage?: boolean; - success?: ((event: Event) => string | undefined) | string; + message: ((event: Event) => string | undefined) | string; + persist?: boolean; +} + +interface Toast { + failure?: ToastMessage; + /** + * If true, the toast will be displayed with an error variant. + */ + invertVariant?: boolean; + success?: ToastMessage; } type Toasts = { @@ -34,165 +43,220 @@ type Toasts = { * * Use this feature to notify users of *asynchronous tasks* such as migrating a Linode. * - * DO NOT use this feature to notifiy the user of tasks like changing the label of a Linode. - * Toasts for that can be handeled at the time of making the PUT request. + * DO NOT use this feature to notify the user of tasks like changing the label of a Linode. + * Toasts for that can be handled at the time of making the PUT request. */ const toasts: Toasts = { backups_restore: { - failure: (e) => `Backup restoration failed for ${getLabel(e)}.`, - link: formatLink( - 'Learn more about limits and considerations.', - 'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations' - ), - persistFailureMessage: true, + failure: { + link: formatLink( + 'Learn more about limits and considerations.', + 'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations' + ), + message: (e) => `Backup restoration failed for ${getLabel(e)}.`, + persist: true, + }, }, disk_delete: { - failure: (e) => - `Unable to delete disk ${getSecondaryLabel(e)} ${ - getLabel(e) ? ` on ${getLabel(e)}` : '' - }. Is it attached to a configuration profile that is in use?`, - success: (e) => `Disk ${getSecondaryLabel(e)} successfully deleted.`, + failure: { + message: (e) => + `Unable to delete disk ${getSecondaryLabel(e)} ${ + getLabel(e) ? ` on ${getLabel(e)}` : '' + }. Is it attached to a configuration profile that is in use?`, + }, + success: { + message: (e) => `Disk ${getSecondaryLabel(e)} successfully deleted.`, + }, }, disk_imagize: { - failure: (e) => - `There was a problem creating Image ${getSecondaryLabel(e)}.`, - link: formatLink( - 'Learn more about image technical specifications.', - 'https://www.linode.com/docs/products/tools/images/#technical-specifications' - ), - persistFailureMessage: true, - success: (e) => `Image ${getSecondaryLabel(e)} successfully created.`, + failure: { + link: formatLink( + 'Learn more about image technical specifications.', + 'https://www.linode.com/docs/products/tools/images/#technical-specifications' + ), + message: (e) => + `There was a problem creating Image ${getSecondaryLabel(e)}.`, + persist: true, + }, + + success: { + message: (e) => `Image ${getSecondaryLabel(e)} successfully created.`, + }, }, disk_resize: { - failure: `Disk resize failed.`, - link: formatLink( - 'Learn more about resizing restrictions.', - 'https://www.linode.com/docs/products/compute/compute-instances/guides/disks-and-storage/', - () => - sendLinodeDiskEvent('Resize', 'Click:link', 'Disk resize failed toast') - ), - persistFailureMessage: true, - success: (e) => `Disk ${getSecondaryLabel(e)} successfully resized.`, + failure: { + link: formatLink( + 'Learn more about resizing restrictions.', + 'https://www.linode.com/docs/products/compute/compute-instances/guides/disks-and-storage/', + () => + sendLinodeDiskEvent( + 'Resize', + 'Click:link', + 'Disk resize failed toast' + ) + ), + message: `Disk resize failed.`, + persist: true, + }, + success: { + message: (e) => `Disk ${getSecondaryLabel(e)} successfully resized.`, + }, }, image_delete: { - failure: (e) => `Error deleting Image ${getLabel(e)}.`, - success: (e) => `Image ${getLabel(e)} successfully deleted.`, + failure: { message: (e) => `Error deleting Image ${getLabel(e)}.` }, + success: { message: (e) => `Image ${getLabel(e)} successfully deleted.` }, }, image_upload: { - failure(event) { - const isDeletion = event.message === 'Upload canceled.'; + failure: { + message: (e) => { + const isDeletion = e.message === 'Upload canceled.'; - if (isDeletion) { - return undefined; - } + if (isDeletion) { + return undefined; + } - return `There was a problem uploading image ${getLabel( - event - )}: ${event.message?.replace(/(\d+)/g, '$1 MB')}`; + return `There was a problem uploading image ${getLabel( + e + )}: ${e.message?.replace(/(\d+)/g, '$1 MB')}`; + }, + persist: true, }, - persistFailureMessage: true, - success: (e) => `Image ${getLabel(e)} is now available.`, + success: { message: (e) => `Image ${getLabel(e)} is now available.` }, }, linode_clone: { - failure: (e) => `Error cloning Linode ${getLabel(e)}.`, - success: (e) => - `Linode ${getLabel(e)} successfully cloned to ${getSecondaryLabel(e)}.`, + failure: { message: (e) => `Error cloning Linode ${getLabel(e)}.` }, + success: { + message: (e) => + `Linode ${getLabel(e)} successfully cloned to ${getSecondaryLabel(e)}.`, + }, }, linode_migrate: { - failure: (e) => `Error migrating Linode ${getLabel(e)}.`, - success: (e) => `Linode ${getLabel(e)} successfully migrated.`, + failure: { message: (e) => `Error migrating Linode ${getLabel(e)}.` }, + success: { message: (e) => `Linode ${getLabel(e)} successfully migrated.` }, }, linode_migrate_datacenter: { - failure: (e) => `Error migrating Linode ${getLabel(e)}.`, - success: (e) => `Linode ${getLabel(e)} successfully migrated.`, + failure: { message: (e) => `Error migrating Linode ${getLabel(e)}.` }, + success: { message: (e) => `Linode ${getLabel(e)} successfully migrated.` }, }, linode_resize: { - failure: (e) => `Error resizing Linode ${getLabel(e)}.`, - success: (e) => `Linode ${getLabel(e)} successfully resized.`, + failure: { message: (e) => `Error resizing Linode ${getLabel(e)}.` }, + success: { message: (e) => `Linode ${getLabel(e)} successfully resized.` }, }, linode_snapshot: { - failure: (e) => `Snapshot backup failed on Linode ${getLabel(e)}.`, - link: formatLink( - 'Learn more about limits and considerations.', - 'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations' - ), - persistFailureMessage: true, + failure: { + link: formatLink( + 'Learn more about limits and considerations.', + 'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations' + ), + message: (e) => `Snapshot backup failed on Linode ${getLabel(e)}.`, + persist: true, + }, }, longviewclient_create: { - failure: (e) => `Error creating Longview Client ${getLabel(e)}.`, - success: (e) => `Longview Client ${getLabel(e)} successfully created.`, + failure: { + message: (e) => `Error creating Longview Client ${getLabel(e)}.`, + }, + success: { + message: (e) => `Longview Client ${getLabel(e)} successfully created.`, + }, + }, + tax_id_invalid: { + failure: { message: 'Error validating Tax Identification Number.' }, + invertVariant: true, + success: { + message: 'Tax Identification Number could not be verified.', + persist: true, + }, }, volume_attach: { - failure: (e) => `Error attaching Volume ${getLabel(e)}.`, - success: (e) => `Volume ${getLabel(e)} successfully attached.`, + failure: { message: (e) => `Error attaching Volume ${getLabel(e)}.` }, + success: { message: (e) => `Volume ${getLabel(e)} successfully attached.` }, }, volume_create: { - failure: (e) => `Error creating Volume ${getLabel(e)}.`, - success: (e) => `Volume ${getLabel(e)} successfully created.`, + failure: { message: (e) => `Error creating Volume ${getLabel(e)}.` }, + success: { message: (e) => `Volume ${getLabel(e)} successfully created.` }, }, volume_delete: { - failure: 'Error deleting Volume.', - success: 'Volume successfully deleted.', + failure: { message: 'Error deleting Volume.' }, + success: { message: 'Volume successfully deleted.' }, }, volume_detach: { - failure: (e) => `Error detaching Volume ${getLabel(e)}.`, - success: (e) => `Volume ${getLabel(e)} successfully detached.`, + failure: { message: (e) => `Error detaching Volume ${getLabel(e)}.` }, + success: { message: (e) => `Volume ${getLabel(e)} successfully detached.` }, }, volume_migrate: { - failure: (e) => `Error upgrading Volume ${getLabel(e)}.`, - success: (e) => `Volume ${getLabel(e)} successfully upgraded.`, + failure: { message: (e) => `Error upgrading Volume ${getLabel(e)}.` }, + success: { message: (e) => `Volume ${getLabel(e)} successfully upgraded.` }, }, }; -export const useToastNotifications = () => { +const getToastMessage = ( + toastMessage: ((event: Event) => string | undefined) | string, + event: Event +): string | undefined => + typeof toastMessage === 'function' ? toastMessage(event) : toastMessage; + +const createFormattedMessage = ( + message: string | undefined, + link: JSX.Element | undefined, + hasSupportLink: boolean +) => ( + <> + {message?.replace(/ contact Support/i, '') ?? message} + {hasSupportLink && ( + <> +   + . + + )} + {link && <> {link}} + +); + +export const useToastNotifications = (): { + handleGlobalToast: (event: Event) => void; +} => { const { enqueueSnackbar } = useSnackbar(); - const handleGlobalToast = (event: Event) => { + const handleGlobalToast = (event: Event): void => { const toastInfo = toasts[event.action]; - if (!toastInfo) { return; } - if ( - ['finished', 'notification'].includes(event.status) && - toastInfo.success - ) { - const successMessage = - typeof toastInfo.success === 'function' - ? toastInfo.success(event) - : toastInfo.success; - - enqueueSnackbar(successMessage, { - variant: 'success', + const isSuccessEvent = ['finished', 'notification'].includes(event.status); + + if (isSuccessEvent && toastInfo.success) { + const { link, message, persist } = toastInfo.success; + const successMessage = getToastMessage(message, event); + + const formattedSuccessMessage = createFormattedMessage( + successMessage, + link, + false + ); + + enqueueSnackbar(formattedSuccessMessage, { + persist: persist ?? false, + variant: toastInfo.invertVariant ? 'error' : 'success', }); } if (event.status === 'failed' && toastInfo.failure) { - const failureMessage = - typeof toastInfo.failure === 'function' - ? toastInfo.failure(event) - : toastInfo.failure; - + const { link, message, persist } = toastInfo.failure; + const failureMessage = getToastMessage(message, event); const hasSupportLink = failureMessage?.includes('contact Support') ?? false; - const formattedFailureMessage = ( - <> - {failureMessage?.replace(/ contact Support/i, '') ?? failureMessage} - {hasSupportLink ? ( - <> -   - . - - ) : null} - {toastInfo.link ? <> {toastInfo.link} : null} - + const formattedFailureMessage = createFormattedMessage( + failureMessage, + link, + hasSupportLink ); enqueueSnackbar(formattedFailureMessage, { - persist: toastInfo.persistFailureMessage, - variant: 'error', + persist: persist ?? false, + variant: toastInfo.invertVariant ? 'success' : 'error', }); } }; diff --git a/packages/manager/src/index.css b/packages/manager/src/index.css index d5a52915b96..86b93124e98 100644 --- a/packages/manager/src/index.css +++ b/packages/manager/src/index.css @@ -305,10 +305,6 @@ button::-moz-focus-inner { } } -.fade-in-table { - animation: fadeIn 0.3s ease-in-out; -} - @keyframes pulse { to { background-color: hsla(40, 100%, 55%, 0); 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/layouts/LoginAsCustomerCallback.tsx b/packages/manager/src/layouts/LoginAsCustomerCallback.tsx index 94a9d345215..e59b917b6b9 100644 --- a/packages/manager/src/layouts/LoginAsCustomerCallback.tsx +++ b/packages/manager/src/layouts/LoginAsCustomerCallback.tsx @@ -9,7 +9,6 @@ import { PureComponent } from 'react'; import { MapDispatchToProps, connect } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { compose } from 'recompose'; import { handleStartSession } from 'src/store/authentication/authentication.actions'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; @@ -117,7 +116,4 @@ const mapDispatchToProps: MapDispatchToProps = ( const connected = connect(undefined, mapDispatchToProps); -export default compose( - connected, - withRouter -)(LoginAsCustomerCallback); +export default connected(withRouter(LoginAsCustomerCallback)); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index c0dceb9e76e..198d63e9244 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, @@ -76,6 +70,8 @@ import { objectStorageBucketFactory, objectStorageClusterFactory, objectStorageKeyFactory, + objectStorageOverageTypeFactory, + objectStorageTypeFactory, paymentFactory, paymentMethodFactory, placementGroupFactory, @@ -106,6 +102,16 @@ 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, + UpdateImageRegionsPayload, + User, + VolumeStatus, +} from '@linode/api-v4'; + export const makeResourcePage = ( e: T[], override: { page: number; pages: number; results?: number } = { @@ -602,7 +608,21 @@ export const handlers = [ http.get('*/regions', async () => { return HttpResponse.json(makeResourcePage(regions)); }), - http.get('*/images', async () => { + http.get<{ id: string }>('*/v4/images/:id', ({ params }) => { + const distributedImage = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-images'], + id: 'private/distributed-image', + label: 'distributed-image', + regions: [{ region: 'us-east', status: 'available' }], + }); + + if (params.id === distributedImage.id) { + return HttpResponse.json(distributedImage); + } + + return HttpResponse.json(imageFactory.build()); + }), + http.get('*/images', async ({ request }) => { const privateImages = imageFactory.buildList(5, { status: 'available', type: 'manual', @@ -622,6 +642,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', @@ -635,17 +665,54 @@ export const handlers = [ type: 'automatic', }); const publicImages = imageFactory.buildList(4, { is_public: true }); + const distributedImage = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-images'], + id: 'private/distributed-image', + label: 'distributed-image', + regions: [{ region: 'us-east', status: 'available' }], + }); const images = [ cloudinitCompatableDistro, cloudinitCompatableImage, + multiRegionsImage, + distributedImage, ...automaticImages, ...privateImages, ...publicImages, ...pendingImages, ...creatingImages, ]; + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json( + makeResourcePage(images.filter((image) => image.type === 'manual')) + ); + } + + if (filter?.includes('automatic')) { + return HttpResponse.json( + makeResourcePage(images.filter((image) => image.type === 'automatic')) + ); + } + return HttpResponse.json(makeResourcePage(images)); }), + http.post( + '*/v4/images/:id/regions', + async ({ request }) => { + const data = await request.json(); + + const image = imageFactory.build(); + + image.regions = data.regions.map((regionId) => ({ + region: regionId, + status: 'pending replication', + })); + + return HttpResponse.json(image); + } + ), http.get('*/linode/types', () => { return HttpResponse.json( @@ -677,10 +744,10 @@ export const handlers = [ label: 'metadata-test-region', region: 'eu-west', }); - const linodeInEdgeRegion = linodeFactory.build({ - image: 'edge-test-image', - label: 'Gecko Edge Test', - region: 'us-edge-1', + const linodeInDistributedRegion = linodeFactory.build({ + image: 'distributed-region-test-image', + label: 'Gecko Distributed Region Test', + region: 'us-den-10', }); const onlineLinodes = linodeFactory.buildList(40, { backups: { enabled: false }, @@ -712,7 +779,7 @@ export const handlers = [ const linodes = [ metadataLinodeWithCompatibleImage, metadataLinodeWithCompatibleImageAndRegion, - linodeInEdgeRegion, + linodeInDistributedRegion, ...onlineLinodes, linodeWithEligibleVolumes, ...offlineLinodes, @@ -769,8 +836,8 @@ export const handlers = [ linodeFactory.build({ backups: { enabled: false }, id, - label: 'Gecko Edge Test', - region: 'us-edge-1', + label: 'Gecko Distributed Region Test', + region: 'us-den-10', }) ); }), @@ -830,6 +897,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)); @@ -849,9 +923,14 @@ export const handlers = [ return HttpResponse.json(cluster); }), http.get('*/lke/clusters/:clusterId/pools', async () => { - const pools = nodePoolFactory.buildList(10); + const encryptedPools = nodePoolFactory.buildList(5); + const unencryptedPools = nodePoolFactory.buildList(5, { + disk_encryption: 'disabled', + }); nodePoolFactory.resetSequenceNumber(); - return HttpResponse.json(makeResourcePage(pools)); + return HttpResponse.json( + makeResourcePage([...encryptedPools, ...unencryptedPools]) + ); }), http.get('*/lke/clusters/*/api-endpoints', async () => { const endpoints = kubeEndpointFactory.buildList(2); @@ -919,6 +998,13 @@ export const handlers = [ ]; return HttpResponse.json(makeResourcePage(configs)); }), + http.get('*/v4/object-storage/types', () => { + const objectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), + ]; + return HttpResponse.json(makeResourcePage(objectStorageTypes)); + }), http.get('*object-storage/buckets/*/*/access', async () => { await sleep(2000); return HttpResponse.json({ @@ -2287,6 +2373,64 @@ export const handlers = [ return HttpResponse.json(response); }), + http.get('*/v4/monitor/services/linode/dashboards', () => { + const response = { + data: [ + { + id: 1, + type: 'standard', + service_type: 'linode', + label: 'Linode Service I/O Statistics', + created: '2024-04-29T17:09:29', + updated: null, + widgets: [ + { + metric: 'system_cpu_utilization_percent', + unit: '%', + label: 'CPU utilization', + color: 'blue', + size: 12, + chart_type: 'area', + y_label: 'system_cpu_utilization_ratio', + aggregate_function: 'avg', + }, + { + metric: 'system_memory_usage_by_resource', + unit: 'Bytes', + label: 'Memory Usage', + color: 'red', + size: 12, + chart_type: 'area', + y_label: 'system_memory_usage_bytes', + aggregate_function: 'avg', + }, + { + metric: 'system_network_io_by_resource', + unit: 'Bytes', + label: 'Network Traffic', + color: 'green', + size: 6, + chart_type: 'area', + y_label: 'system_network_io_bytes_total', + aggregate_function: 'avg', + }, + { + metric: 'system_disk_OPS_total', + unit: 'OPS', + label: 'Disk I/O', + color: 'yellow', + size: 6, + chart_type: 'area', + y_label: 'system_disk_operations_total', + aggregate_function: 'avg', + }, + ], + }, + ], + }; + + return HttpResponse.json(response); + }), ...entityTransfers, ...statusPage, ...databases, diff --git a/packages/manager/src/queries/account/account.ts b/packages/manager/src/queries/account/account.ts index 4d520314936..55f242914d6 100644 --- a/packages/manager/src/queries/account/account.ts +++ b/packages/manager/src/queries/account/account.ts @@ -8,8 +8,10 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; +import { useSnackbar } from 'notistack'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useIsTaxIdEnabled } from 'src/features/Account/utils'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; @@ -36,10 +38,41 @@ export const useAccount = () => { export const useMutateAccount = () => { const queryClient = useQueryClient(); + const { enqueueSnackbar } = useSnackbar(); + const { isTaxIdEnabled } = useIsTaxIdEnabled(); return useMutation>(updateAccountInfo, { onSuccess(account) { - queryClient.setQueryData(accountQueries.account.queryKey, account); + queryClient.setQueryData( + accountQueries.account.queryKey, + (prevAccount) => { + if (!prevAccount) { + return account; + } + + if ( + isTaxIdEnabled && + account.tax_id && + account.country !== 'US' && + prevAccount?.tax_id !== account.tax_id + ) { + enqueueSnackbar( + "You edited the Tax Identification Number. It's being verified. You'll get an email with the verification result.", + { + hideIconVariant: false, + style: { + display: 'flex', + flexWrap: 'nowrap', + width: '372px', + }, + variant: 'info', + } + ); + } + + return account; + } + ); }, }); }; diff --git a/packages/manager/src/queries/account/agreements.ts b/packages/manager/src/queries/account/agreements.ts index 09db7945afa..4b754a2d950 100644 --- a/packages/manager/src/queries/account/agreements.ts +++ b/packages/manager/src/queries/account/agreements.ts @@ -2,7 +2,7 @@ import { signAgreement } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { reportException } from 'src/exceptionReporting'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/account/payment.ts b/packages/manager/src/queries/account/payment.ts index 70b4d13c455..5db52a3a581 100644 --- a/packages/manager/src/queries/account/payment.ts +++ b/packages/manager/src/queries/account/payment.ts @@ -6,7 +6,7 @@ import { import { APIError } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/account/settings.ts b/packages/manager/src/queries/account/settings.ts index 77e894c32ad..27c8b1adc72 100644 --- a/packages/manager/src/queries/account/settings.ts +++ b/packages/manager/src/queries/account/settings.ts @@ -10,7 +10,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/account/users.ts b/packages/manager/src/queries/account/users.ts index dfe0e764df1..e498baa0b3b 100644 --- a/packages/manager/src/queries/account/users.ts +++ b/packages/manager/src/queries/account/users.ts @@ -1,7 +1,7 @@ import { deleteUser } from '@linode/api-v4/lib/account'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/aclb/certificates.ts b/packages/manager/src/queries/aclb/certificates.ts index 840dc4537ea..24d9586e6d6 100644 --- a/packages/manager/src/queries/aclb/certificates.ts +++ b/packages/manager/src/queries/aclb/certificates.ts @@ -1,8 +1,6 @@ import { createLoadbalancerCertificate, deleteLoadbalancerCertificate, - getLoadbalancerCertificate, - getLoadbalancerCertificates, updateLoadbalancerCertificate, } from '@linode/api-v4'; import { @@ -12,7 +10,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { QUERY_KEY } from './loadbalancers'; +import { aclbQueries } from './queries'; import type { APIError, @@ -29,19 +27,12 @@ export const useLoadBalancerCertificatesQuery = ( params: Params, filter: Filter ) => { - return useQuery, APIError[]>( - [ - QUERY_KEY, - 'loadbalancer', - id, - 'certificates', - 'paginated', - params, - filter, - ], - () => getLoadbalancerCertificates(id, params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.certificates._ctx.lists._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadbalancerCertificateQuery = ( @@ -49,48 +40,34 @@ export const useLoadbalancerCertificateQuery = ( certificateId: number, enabled = true ) => { - return useQuery( - [ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - 'certificate', - certificateId, - ], - () => getLoadbalancerCertificate(loadbalancerId, certificateId), - { enabled } - ); + return useQuery({ + ...aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.certificates._ctx.certificate(certificateId), + enabled, + }); }; export const useLoadBalancerCertificateCreateMutation = ( loadbalancerId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLoadbalancerCertificate(loadbalancerId, data), - { - onSuccess(certificate) { - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - ]); - queryClient.setQueryData( - [ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - 'certificate', - certificate.id, - ], - certificate - ); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLoadbalancerCertificate(loadbalancerId, data), + onSuccess(certificate) { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.certificates + ._ctx.lists.queryKey, + }); + + queryClient.setQueryData( + aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.certificates._ctx.certificate(certificate.id).queryKey, + certificate + ); + }, + }); }; export const useLoadBalancerCertificateMutation = ( @@ -98,31 +75,23 @@ export const useLoadBalancerCertificateMutation = ( certificateId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => + return useMutation({ + mutationFn: (data) => updateLoadbalancerCertificate(loadbalancerId, certificateId, data), - { - onSuccess(certificate) { - queryClient.setQueryData( - [ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - 'certificate', - certificate.id, - ], - certificate - ); - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - ]); - }, - } - ); + onSuccess(certificate) { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.certificates + ._ctx.lists.queryKey, + }); + + queryClient.setQueryData( + aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.certificates._ctx.certificate(certificate.id).queryKey, + certificate + ); + }, + }); }; export const useLoadBalancerCertificateDeleteMutation = ( @@ -130,48 +99,36 @@ export const useLoadBalancerCertificateDeleteMutation = ( certificateId: number ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLoadbalancerCertificate(loadbalancerId, certificateId), - { - onSuccess() { - queryClient.removeQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - 'certificate', - certificateId, - ]); - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - ]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => + deleteLoadbalancerCertificate(loadbalancerId, certificateId), + onSuccess() { + queryClient.removeQueries({ + queryKey: aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.certificates._ctx.certificate(certificateId).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.certificates + ._ctx.lists.queryKey, + }); + }, + }); }; export const useLoadBalancerCertificatesInfiniteQuery = ( id: number, filter: Filter = {} ) => { - return useInfiniteQuery, APIError[]>( - [QUERY_KEY, 'loadbalancer', id, 'certificates', 'infinite', filter], - ({ pageParam }) => - getLoadbalancerCertificates( - id, - { page: pageParam, page_size: 25 }, - filter - ), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + return useInfiniteQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.certificates._ctx.lists._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); }; diff --git a/packages/manager/src/queries/aclb/configurations.ts b/packages/manager/src/queries/aclb/configurations.ts index 8c7dfe347ce..2a54b7233f3 100644 --- a/packages/manager/src/queries/aclb/configurations.ts +++ b/packages/manager/src/queries/aclb/configurations.ts @@ -1,8 +1,6 @@ import { createLoadbalancerConfiguration, deleteLoadbalancerConfiguration, - getLoadbalancerConfigurations, - getLoadbalancerConfigurationsEndpointHealth, updateLoadbalancerConfiguration, } from '@linode/api-v4'; import { @@ -12,7 +10,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { QUERY_KEY } from './loadbalancers'; +import { aclbQueries } from './queries'; import type { APIError, @@ -30,25 +28,20 @@ export const useLoadBalancerConfigurationsQuery = ( params?: Params, filter?: Filter ) => { - return useQuery, APIError[]>( - [QUERY_KEY, 'aclb', loadbalancerId, 'configurations', params, filter], - () => getLoadbalancerConfigurations(loadbalancerId, params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.configurations._ctx.lists._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadBalancerConfigurationsEndpointsHealth = ( loadbalancerId: number ) => { return useQuery({ - queryFn: () => getLoadbalancerConfigurationsEndpointHealth(loadbalancerId), - queryKey: [ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'configurations', - 'endpoint-health', - ], + ...aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations._ctx + .endpointHealth, refetchInterval: 10_000, }); }; @@ -56,22 +49,16 @@ export const useLoadBalancerConfigurationsEndpointsHealth = ( export const useLoabalancerConfigurationsInfiniteQuery = ( loadbalancerId: number ) => { - return useInfiniteQuery, APIError[]>( - [QUERY_KEY, 'aclb', loadbalancerId, 'configurations', 'infinite'], - ({ pageParam }) => - getLoadbalancerConfigurations(loadbalancerId, { - page: pageParam, - page_size: 25, - }), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + return useInfiniteQuery, APIError[]>({ + ...aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations._ctx.lists + ._ctx.infinite, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); }; export const useLoadBalancerConfigurationMutation = ( @@ -80,23 +67,23 @@ export const useLoadBalancerConfigurationMutation = ( ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => + return useMutation({ + mutationFn: (data) => updateLoadbalancerConfiguration(loadbalancerId, configurationId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'configurations', - ]); - // The GET /v4/aclb endpoint also returns configuration data that we must update - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - queryClient.invalidateQueries([QUERY_KEY, 'aclb', loadbalancerId]); - }, - } - ); + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations + ._ctx.lists.queryKey, + }); + // The GET /v4/aclb endpoint also returns configuration data that we must update + // the paginated list and the ACLB object + queryClient.invalidateQueries({ queryKey: aclbQueries.paginated._def }); + queryClient.invalidateQueries({ + exact: true, + queryKey: aclbQueries.loadbalancer(loadbalancerId).queryKey, + }); + }, + }); }; export const useLoadBalancerConfigurationCreateMutation = ( @@ -104,22 +91,22 @@ export const useLoadBalancerConfigurationCreateMutation = ( ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLoadbalancerConfiguration(loadbalancerId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'configurations', - ]); - // The GET /v4/aclb endpoint also returns configuration data that we must update - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - queryClient.invalidateQueries([QUERY_KEY, 'aclb', loadbalancerId]); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLoadbalancerConfiguration(loadbalancerId, data), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations + ._ctx.lists.queryKey, + }); + // The GET /v4/aclb endpoint also returns configuration data that we must update + // the paginated list and the ACLB object + queryClient.invalidateQueries({ queryKey: aclbQueries.paginated._def }); + queryClient.invalidateQueries({ + exact: true, + queryKey: aclbQueries.loadbalancer(loadbalancerId).queryKey, + }); + }, + }); }; export const useLoadBalancerConfigurationDeleteMutation = ( @@ -128,20 +115,21 @@ export const useLoadBalancerConfigurationDeleteMutation = ( ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLoadbalancerConfiguration(loadbalancerId, configurationId), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'configurations', - ]); - // The GET /v4/aclb endpoint also returns configuration data that we must update - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - queryClient.invalidateQueries([QUERY_KEY, 'aclb', loadbalancerId]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => + deleteLoadbalancerConfiguration(loadbalancerId, configurationId), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations + ._ctx.lists.queryKey, + }); + // The GET /v4/aclb endpoint also returns configuration data that we must update + // the paginated list and the ACLB object + queryClient.invalidateQueries({ queryKey: aclbQueries.paginated._def }); + queryClient.invalidateQueries({ + exact: true, + queryKey: aclbQueries.loadbalancer(loadbalancerId).queryKey, + }); + }, + }); }; diff --git a/packages/manager/src/queries/aclb/loadbalancers.ts b/packages/manager/src/queries/aclb/loadbalancers.ts index b46215e267f..eb437f6e635 100644 --- a/packages/manager/src/queries/aclb/loadbalancers.ts +++ b/packages/manager/src/queries/aclb/loadbalancers.ts @@ -2,13 +2,12 @@ import { createBasicLoadbalancer, createLoadbalancer, deleteLoadbalancer, - getLoadbalancer, - getLoadbalancerEndpointHealth, - getLoadbalancers, updateLoadbalancer, } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { aclbQueries } from './queries'; + import type { APIError, CreateBasicLoadbalancerPayload, @@ -21,75 +20,86 @@ import type { UpdateLoadbalancerPayload, } from '@linode/api-v4'; -export const QUERY_KEY = 'aclbs'; - export const useLoadBalancersQuery = (params?: Params, filter?: Filter) => { - return useQuery, APIError[]>( - [QUERY_KEY, 'paginated', params, filter], - () => getLoadbalancers(params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadBalancerQuery = (id: number, enabled = true) => { - return useQuery( - [QUERY_KEY, 'aclb', id], - () => getLoadbalancer(id), - { enabled } - ); + return useQuery({ + ...aclbQueries.loadbalancer(id), + enabled, + }); }; export const useLoadBalancerEndpointHealthQuery = (id: number) => { return useQuery({ - queryFn: () => getLoadbalancerEndpointHealth(id), - queryKey: [QUERY_KEY, 'aclb', id, 'endpoint-health'], + ...aclbQueries.loadbalancer(id)._ctx.endpointHealth, refetchInterval: 10_000, }); }; export const useLoadBalancerMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => updateLoadbalancer(id, data), - { - onSuccess(data) { - queryClient.setQueryData([QUERY_KEY, 'aclb', id], data); - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - }, - } - ); + return useMutation({ + mutationFn: (data) => updateLoadbalancer(id, data), + onSuccess(loadbalancer) { + queryClient.setQueryData( + aclbQueries.loadbalancer(id).queryKey, + loadbalancer + ); + queryClient.invalidateQueries({ + queryKey: aclbQueries.paginated._def, + }); + }, + }); }; export const useLoadBalancerBasicCreateMutation = () => { const queryClient = useQueryClient(); - return useMutation( - (data) => createBasicLoadbalancer(data), - { - onSuccess(data) { - queryClient.setQueryData([QUERY_KEY, 'aclb', data.id], data); - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - }, - } - ); + return useMutation({ + mutationFn: createBasicLoadbalancer, + onSuccess(loadbalancer) { + queryClient.setQueryData( + aclbQueries.loadbalancer(loadbalancer.id).queryKey, + loadbalancer + ); + queryClient.invalidateQueries({ + queryKey: aclbQueries.paginated._def, + }); + }, + }); }; export const useLoadBalancerCreateMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createLoadbalancer, - onSuccess(data) { - queryClient.setQueryData([QUERY_KEY, 'aclb', data.id], data); - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); + onSuccess(loadbalancer) { + queryClient.setQueryData( + aclbQueries.loadbalancer(loadbalancer.id).queryKey, + loadbalancer + ); + queryClient.invalidateQueries({ + queryKey: aclbQueries.paginated._def, + }); }, }); }; export const useLoadBalancerDeleteMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteLoadbalancer(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteLoadbalancer(id), onSuccess() { - queryClient.removeQueries([QUERY_KEY, 'aclb', id]); - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); + queryClient.removeQueries({ + queryKey: aclbQueries.loadbalancer(id).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: aclbQueries.paginated._def, + }); }, }); }; diff --git a/packages/manager/src/queries/aclb/queries.ts b/packages/manager/src/queries/aclb/queries.ts new file mode 100644 index 00000000000..abe18c7e096 --- /dev/null +++ b/packages/manager/src/queries/aclb/queries.ts @@ -0,0 +1,146 @@ +import { + getLoadbalancer, + getLoadbalancerCertificate, + getLoadbalancerCertificates, + getLoadbalancerConfigurations, + getLoadbalancerConfigurationsEndpointHealth, + getLoadbalancerEndpointHealth, + getLoadbalancerRoutes, + getLoadbalancerServiceTargets, + getLoadbalancers, + getServiceTargetsEndpointHealth, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import type { Filter, Params } from '@linode/api-v4'; + +export const aclbQueries = createQueryKeys('aclbs', { + loadbalancer: (id: number) => ({ + contextQueries: { + certificates: { + contextQueries: { + certificate: (certificateId: number) => ({ + queryFn: () => getLoadbalancerCertificate(id, certificateId), + queryKey: [certificateId], + }), + lists: { + contextQueries: { + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getLoadbalancerCertificates( + id, + { + page: pageParam, + page_size: 25, + }, + filter + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLoadbalancerCertificates(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryKey: null, + }, + configurations: { + contextQueries: { + endpointHealth: { + queryFn: () => getLoadbalancerConfigurationsEndpointHealth(id), + queryKey: null, + }, + lists: { + contextQueries: { + infinite: { + queryFn: ({ pageParam }) => + getLoadbalancerConfigurations(id, { + page: pageParam, + page_size: 25, + }), + queryKey: null, + }, + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => + getLoadbalancerConfigurations(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryKey: null, + }, + endpointHealth: { + queryFn: () => getLoadbalancerEndpointHealth(id), + queryKey: null, + }, + routes: { + contextQueries: { + lists: { + contextQueries: { + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getLoadbalancerRoutes( + id, + { + page: pageParam, + page_size: 25, + }, + filter + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLoadbalancerRoutes(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryKey: null, + }, + serviceTargets: { + contextQueries: { + endpointHealth: { + queryFn: () => getServiceTargetsEndpointHealth(id), + queryKey: null, + }, + lists: { + contextQueries: { + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getLoadbalancerServiceTargets( + id, + { + page: pageParam, + page_size: 25, + }, + filter + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => + getLoadbalancerServiceTargets(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryKey: null, + }, + }, + queryFn: () => getLoadbalancer(id), + queryKey: [id], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLoadbalancers(params, filter), + queryKey: [params, filter], + }), +}); 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/aclb/routes.ts b/packages/manager/src/queries/aclb/routes.ts index 2769d9846d3..3c3cf062e5d 100644 --- a/packages/manager/src/queries/aclb/routes.ts +++ b/packages/manager/src/queries/aclb/routes.ts @@ -1,8 +1,6 @@ import { - CreateRoutePayload, createLoadbalancerRoute, deleteLoadbalancerRoute, - getLoadbalancerRoutes, updateLoadbalancerRoute, } from '@linode/api-v4'; import { @@ -13,10 +11,11 @@ import { } from '@tanstack/react-query'; import { updateInPaginatedStore } from '../base'; -import { QUERY_KEY } from './loadbalancers'; +import { aclbQueries } from './queries'; import type { APIError, + CreateRoutePayload, Filter, Params, ResourcePage, @@ -29,28 +28,25 @@ export const useLoadBalancerRoutesQuery = ( params: Params, filter: Filter ) => { - return useQuery, APIError[]>( - [QUERY_KEY, 'loadbalancer', id, 'routes', 'paginated', params, filter], - () => getLoadbalancerRoutes(id, params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.routes._ctx.lists._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadBalancerRouteCreateMutation = (loadbalancerId: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLoadbalancerRoute(loadbalancerId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'routes', - ]); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLoadbalancerRoute(loadbalancerId, data), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists.queryKey, + }); + }, + }); }; export const useLoadBalancerRouteUpdateMutation = ( @@ -58,31 +54,35 @@ export const useLoadBalancerRouteUpdateMutation = ( routeId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => updateLoadbalancerRoute(loadbalancerId, routeId, data), - { - onError() { - // On error, refetch to keep the client in sync with the API - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'routes', - ]); - }, - onMutate(variables) { - const key = [ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'routes', - 'paginated', - ]; - // Optimistically update the route on mutate - updateInPaginatedStore(key, routeId, variables, queryClient); - }, - } - ); + return useMutation({ + mutationFn: (data) => + updateLoadbalancerRoute(loadbalancerId, routeId, data), + onError() { + // On error, refetch to keep the client in sync with the API + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists.queryKey, + }); + }, + onMutate(variables) { + const key = aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists._ctx.paginated._def; + // Optimistically update the route on mutate + updateInPaginatedStore(key, routeId, variables, queryClient); + }, + onSuccess() { + // Invalidate the infinite store (the paginated store is optimistically updated already) + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists._ctx.infinite._def, + }); + // Invalidate configs because GET configs returns configuration labels + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations + ._ctx.lists.queryKey, + }); + }, + }); }; export const useLoadBalancerRouteDeleteMutation = ( @@ -90,36 +90,30 @@ export const useLoadBalancerRouteDeleteMutation = ( routeId: number ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLoadbalancerRoute(loadbalancerId, routeId), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'routes', - ]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteLoadbalancerRoute(loadbalancerId, routeId), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists.queryKey, + }); + }, + }); }; export const useLoadBalancerRoutesInfiniteQuery = ( id: number, filter: Filter = {} ) => { - return useInfiniteQuery, APIError[]>( - [QUERY_KEY, 'loadbalancer', id, 'routes', 'infinite', filter], - ({ pageParam }) => - getLoadbalancerRoutes(id, { page: pageParam, page_size: 25 }, filter), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + return useInfiniteQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.routes._ctx.lists._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); }; diff --git a/packages/manager/src/queries/aclb/serviceTargets.ts b/packages/manager/src/queries/aclb/serviceTargets.ts index 535c89e5a06..a44bea86aaa 100644 --- a/packages/manager/src/queries/aclb/serviceTargets.ts +++ b/packages/manager/src/queries/aclb/serviceTargets.ts @@ -1,8 +1,6 @@ import { createLoadbalancerServiceTarget, deleteLoadbalancerServiceTarget, - getLoadbalancerServiceTargets, - getServiceTargetsEndpointHealth, updateLoadbalancerServiceTarget, } from '@linode/api-v4'; import { @@ -12,7 +10,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { QUERY_KEY } from './loadbalancers'; +import { aclbQueries } from './queries'; import type { APIError, @@ -29,44 +27,35 @@ export const useLoadBalancerServiceTargetsQuery = ( params: Params, filter: Filter ) => { - return useQuery, APIError[]>( - [QUERY_KEY, 'aclb', loadbalancerId, 'service-targets', params, filter], - () => getLoadbalancerServiceTargets(loadbalancerId, params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.serviceTargets._ctx.lists._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadBalancerServiceTargetsEndpointHealthQuery = ( loadbalancerId: number ) => { return useQuery({ - queryFn: () => getServiceTargetsEndpointHealth(loadbalancerId), - queryKey: [ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'service-targets', - 'endpoint-health', - ], + ...aclbQueries.loadbalancer(loadbalancerId)._ctx.serviceTargets._ctx + .endpointHealth, refetchInterval: 10_000, }); }; export const useServiceTargetCreateMutation = (loadbalancerId: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLoadbalancerServiceTarget(loadbalancerId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'service-targets', - ]); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLoadbalancerServiceTarget(loadbalancerId, data), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.serviceTargets + ._ctx.lists.queryKey, + }); + }, + }); }; export const useServiceTargetUpdateMutation = ( @@ -74,20 +63,21 @@ export const useServiceTargetUpdateMutation = ( serviceTargetId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => + return useMutation({ + mutationFn: (data) => updateLoadbalancerServiceTarget(loadbalancerId, serviceTargetId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'service-targets', - ]); - }, - } - ); + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.serviceTargets + ._ctx.lists.queryKey, + }); + // Invalidate routes because GET routes returns service target labels + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists.queryKey, + }); + }, + }); }; export const useLoadBalancerServiceTargetDeleteMutation = ( @@ -95,40 +85,31 @@ export const useLoadBalancerServiceTargetDeleteMutation = ( serviceTargetId: number ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLoadbalancerServiceTarget(loadbalancerId, serviceTargetId), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'service-targets', - ]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => + deleteLoadbalancerServiceTarget(loadbalancerId, serviceTargetId), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.serviceTargets + ._ctx.lists.queryKey, + }); + }, + }); }; export const useLoadBalancerServiceTargetsInfiniteQuery = ( id: number, filter: Filter = {} ) => { - return useInfiniteQuery, APIError[]>( - [QUERY_KEY, 'aclb', id, 'service-targets', 'infinite', filter], - ({ pageParam }) => - getLoadbalancerServiceTargets( - id, - { page: pageParam, page_size: 25 }, - filter - ), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + return useInfiniteQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.serviceTargets._ctx.lists._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); }; 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/dashboards.ts b/packages/manager/src/queries/cloudpulse/dashboards.ts new file mode 100644 index 00000000000..4371a79862a --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/dashboards.ts @@ -0,0 +1,36 @@ +import { Dashboard, getDashboards } from '@linode/api-v4'; +import { APIError, ResourcePage } from '@linode/api-v4/lib/types'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useQuery } from '@tanstack/react-query'; + +export const queryKey = 'cloudview-dashboards'; + +export const dashboardQueries = createQueryKeys('cloudview-dashboards', { + lists: { + contextQueries: { + allDashboards: { + queryFn: getDashboards, + queryKey: null, + }, + }, + queryKey: null, + }, + + dashboardById: (dashboardId: number) => ({ + contextQueries: { + dashboard: { + queryFn: () => {}, //Todo: will be implemented later + queryKey: [dashboardId], + }, + }, + queryKey: [dashboardId], + }), +}); + +//Fetch the list of all the dashboard available +export const useCloudViewDashboardsQuery = (enabled: boolean) => { + return useQuery, APIError[]>({ + ...dashboardQueries.lists._ctx.allDashboards, + enabled, + }); +}; 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/databases.ts b/packages/manager/src/queries/databases.ts deleted file mode 100644 index 3a15d2978e7..00000000000 --- a/packages/manager/src/queries/databases.ts +++ /dev/null @@ -1,282 +0,0 @@ -/* eslint-disable sonarjs/no-small-switch */ -import { - createDatabase, - deleteDatabase, - getDatabaseBackups, - getDatabaseCredentials, - getDatabaseEngines, - getDatabaseTypes, - getDatabases, - getEngineDatabase, - resetDatabaseCredentials, - restoreWithBackup, - updateDatabase, -} from '@linode/api-v4/lib/databases'; -import { - CreateDatabasePayload, - Database, - DatabaseBackup, - DatabaseCredentials, - DatabaseEngine, - DatabaseInstance, - DatabaseType, - Engine, - UpdateDatabasePayload, - UpdateDatabaseResponse, -} from '@linode/api-v4/lib/databases/types'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; -import { - QueryClient, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; - -import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { getAll } from 'src/utilities/getAll'; - -import { queryPresets, updateInPaginatedStore } from './base'; -import { profileQueries } from './profile'; - -export const queryKey = 'databases'; - -export const useDatabaseQuery = (engine: Engine, id: number) => - useQuery( - [queryKey, id], - () => getEngineDatabase(engine, id), - // @TODO Consider removing polling - // The refetchInterval will poll the API for this Database. We will do this - // to ensure we have up to date information. We do this polling because the events - // API does not provide us every feature we need currently. - { refetchInterval: 20000 } - ); - -export const useDatabasesQuery = (params: Params, filter: Filter) => - useQuery, APIError[]>( - [`${queryKey}-list`, params, filter], - () => getDatabases(params, filter), - // @TODO Consider removing polling - { keepPreviousData: true, refetchInterval: 20000 } - ); - -export const useAllDatabasesQuery = (enabled: boolean = true) => - useQuery( - [`${queryKey}-all-list`], - getAllDatabases, - { enabled } - ); - -export const useDatabaseMutation = (engine: Engine, id: number) => { - const queryClient = useQueryClient(); - return useMutation( - (data) => updateDatabase(engine, id, data), - { - onSuccess: (data) => { - queryClient.setQueryData( - [queryKey, Number(id)], - (oldEntity) => { - if (oldEntity === undefined) { - return undefined; - } - - if (oldEntity.label !== data.label) { - updateInPaginatedStore( - [`${queryKey}-list`], - id, - { - label: data.label, - }, - queryClient - ); - } - - return { ...oldEntity, ...data }; - } - ); - }, - } - ); -}; - -export const useCreateDatabaseMutation = () => { - const queryClient = useQueryClient(); - return useMutation( - (data) => createDatabase(data.engine?.split('/')[0] as Engine, data), - { - onSuccess: (data) => { - // Invalidate useDatabasesQuery to show include the new database. - // We choose to refetch insted of manually mutate the cache because it - // is API paginated. - queryClient.invalidateQueries([`${queryKey}-list`]); - // Add database to the cache - queryClient.setQueryData([queryKey, data.id], data); - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); - }, - } - ); -}; - -export const useDeleteDatabaseMutation = (engine: Engine, id: number) => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteDatabase(engine, id), { - onSuccess: () => { - // Invalidate useDatabasesQuery to remove the deleted database. - // We choose to refetch insted of manually mutate the cache because it - // is API paginated. - queryClient.invalidateQueries([`${queryKey}-list`]); - }, - }); -}; - -export const useDatabaseBackupsQuery = (engine: Engine, id: number) => - useQuery, APIError[]>( - [`${queryKey}-backups`, id], - () => getDatabaseBackups(engine, id) - ); - -export const getAllDatabases = () => - getAll((params) => getDatabases(params))().then( - (data) => data.data - ); - -export const getAllDatabaseEngines = () => - getAll((params) => getDatabaseEngines(params))().then( - (data) => data.data - ); - -export const useDatabaseEnginesQuery = (enabled: boolean = false) => - useQuery( - [`${queryKey}-versions`], - getAllDatabaseEngines, - { enabled } - ); - -export const getAllDatabaseTypes = () => - getAll((params) => getDatabaseTypes(params))().then( - (data) => data.data - ); - -export const useDatabaseTypesQuery = () => - useQuery( - [`${queryKey}-types`], - getAllDatabaseTypes - ); - -export const useDatabaseCredentialsQuery = ( - engine: Engine, - id: number, - enabled: boolean = false -) => - useQuery( - [queryKey, 'credentials', id], - () => getDatabaseCredentials(engine, id), - { ...queryPresets.oneTimeFetch, enabled } - ); - -export const useDatabaseCredentialsMutation = (engine: Engine, id: number) => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => resetDatabaseCredentials(engine, id), - { - onSuccess: () => { - queryClient.invalidateQueries([queryKey, 'credentials', id]); - queryClient.removeQueries([queryKey, 'credentials', id]); - }, - } - ); -}; - -export const useRestoreFromBackupMutation = ( - engine: Engine, - databaseId: number, - backupId: number -) => { - const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => restoreWithBackup(engine, databaseId, backupId), - { - onSuccess: () => - updateStoreForDatabase( - databaseId, - { status: 'restoring' }, - queryClient - ), - } - ); -}; - -export const databaseEventsHandler = (event: EventHandlerData) => { - const { - event: { action, entity, status }, - queryClient, - } = event; - - switch (action) { - case 'database_create': - switch (status) { - case 'failed': - case 'finished': - // Database status will change from `provisioning` to `active` (or `failed`) and - // the host fields will populate. We need to refetch to get the hostnames. - queryClient.invalidateQueries([queryKey, entity!.id]); - queryClient.invalidateQueries([`${queryKey}-list`]); - case 'notification': - // In this case, the API let us know the user initialized a Database create event. - // We use this logic for the case a user created a Database from outside Cloud Manager, - // they would expect to see their database populate without a refresh. - const storedDatabase = queryClient.getQueryData([ - queryKey, - entity!.id, - ]); - if (!storedDatabase) { - queryClient.invalidateQueries([`${queryKey}-list`]); - } - case 'scheduled': - case 'started': - return; - } - } -}; - -interface DatabaseData extends Partial, Partial {} - -const updateStoreForDatabase = ( - id: number, - data: DatabaseData, - queryClient: QueryClient -) => { - updateDatabaseStore(id, data, queryClient); - updateInPaginatedStore([`${queryKey}-list`], id, data, queryClient); -}; - -const updateDatabaseStore = ( - id: number, - newData: Partial, - queryClient: QueryClient -) => { - const previousValue = queryClient.getQueryData([queryKey, id]); - - // This previous value check makes sure we don't set the Database store to undefined. - // This is an odd edge case. - if (previousValue) { - queryClient.setQueryData( - [queryKey, id], - (oldData) => { - if (oldData === undefined) { - return undefined; - } - - return { - ...oldData, - ...newData, - }; - } - ); - } -}; diff --git a/packages/manager/src/queries/databases/databases.ts b/packages/manager/src/queries/databases/databases.ts new file mode 100644 index 00000000000..4d7413418f2 --- /dev/null +++ b/packages/manager/src/queries/databases/databases.ts @@ -0,0 +1,210 @@ +import { + createDatabase, + deleteDatabase, + getDatabaseBackups, + getDatabaseCredentials, + getDatabases, + getEngineDatabase, + resetDatabaseCredentials, + restoreWithBackup, + updateDatabase, +} from '@linode/api-v4/lib/databases'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { queryPresets } from '../base'; +import { profileQueries } from '../profile/profile'; +import { + getAllDatabaseEngines, + getAllDatabaseTypes, + getAllDatabases, +} from './requests'; + +import type { + APIError, + CreateDatabasePayload, + Database, + DatabaseBackup, + DatabaseCredentials, + DatabaseEngine, + DatabaseInstance, + DatabaseType, + Engine, + Filter, + Params, + ResourcePage, + UpdateDatabasePayload, +} from '@linode/api-v4'; + +export const databaseQueries = createQueryKeys('databases', { + database: (engine: Engine, id: number) => ({ + contextQueries: { + backups: { + queryFn: () => getDatabaseBackups(engine, id), + queryKey: null, + }, + credentials: { + queryFn: () => getDatabaseCredentials(engine, id), + queryKey: null, + }, + }, + queryFn: () => getEngineDatabase(engine, id), + queryKey: [engine, id], + }), + databases: { + contextQueries: { + all: { + queryFn: getAllDatabases, + queryKey: null, + }, + paginated: (params: Params, filter: Filter) => ({ + queryFn: () => getDatabases(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + engines: { + queryFn: getAllDatabaseEngines, + queryKey: null, + }, + types: { + queryFn: getAllDatabaseTypes, + queryKey: null, + }, +}); + +export const useDatabaseQuery = (engine: Engine, id: number) => + useQuery({ + ...databaseQueries.database(engine, id), + // @TODO Consider removing polling + // The refetchInterval will poll the API for this Database. We will do this + // to ensure we have up to date information. We do this polling because the events + // API does not provide us every feature we need currently. + refetchInterval: 20000, + }); + +export const useDatabasesQuery = (params: Params, filter: Filter) => + useQuery, APIError[]>({ + ...databaseQueries.databases._ctx.paginated(params, filter), + keepPreviousData: true, + // @TODO Consider removing polling + refetchInterval: 20000, + }); + +export const useAllDatabasesQuery = (enabled: boolean = true) => + useQuery({ + ...databaseQueries.databases._ctx.all, + enabled, + }); + +export const useDatabaseMutation = (engine: Engine, id: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => updateDatabase(engine, id, data), + onSuccess(database) { + queryClient.invalidateQueries({ + queryKey: databaseQueries.databases.queryKey, + }); + queryClient.setQueryData( + databaseQueries.database(engine, id).queryKey, + database + ); + }, + }); +}; + +export const useCreateDatabaseMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => + createDatabase(data.engine?.split('/')[0] as Engine, data), + onSuccess(database) { + queryClient.invalidateQueries({ + queryKey: databaseQueries.databases.queryKey, + }); + queryClient.setQueryData( + databaseQueries.database(database.engine, database.id).queryKey, + database + ); + // If a restricted user creates an entity, we must make sure grants are up to date. + queryClient.invalidateQueries(profileQueries.grants.queryKey); + }, + }); +}; + +export const useDeleteDatabaseMutation = (engine: Engine, id: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteDatabase(engine, id), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: databaseQueries.databases.queryKey, + }); + queryClient.removeQueries({ + queryKey: databaseQueries.database(engine, id).queryKey, + }); + }, + }); +}; + +export const useDatabaseBackupsQuery = (engine: Engine, id: number) => + useQuery, APIError[]>( + databaseQueries.database(engine, id)._ctx.backups + ); + +export const useDatabaseEnginesQuery = (enabled: boolean = false) => + useQuery({ + ...databaseQueries.engines, + enabled, + }); + +export const useDatabaseTypesQuery = () => + useQuery(databaseQueries.types); + +export const useDatabaseCredentialsQuery = ( + engine: Engine, + id: number, + enabled: boolean = false +) => + useQuery({ + ...databaseQueries.database(engine, id)._ctx.credentials, + ...queryPresets.oneTimeFetch, + enabled, + }); + +export const useDatabaseCredentialsMutation = (engine: Engine, id: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>({ + mutationFn: () => resetDatabaseCredentials(engine, id), + onSuccess() { + queryClient.removeQueries({ + queryKey: databaseQueries.database(engine, id)._ctx.credentials + .queryKey, + }); + queryClient.invalidateQueries({ + queryKey: databaseQueries.database(engine, id)._ctx.credentials + .queryKey, + }); + }, + }); +}; + +export const useRestoreFromBackupMutation = ( + engine: Engine, + databaseId: number, + backupId: number +) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>({ + mutationFn: () => restoreWithBackup(engine, databaseId, backupId), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: databaseQueries.databases.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: databaseQueries.database(engine, databaseId).queryKey, + }); + }, + }); +}; diff --git a/packages/manager/src/queries/databases/events.ts b/packages/manager/src/queries/databases/events.ts new file mode 100644 index 00000000000..c138284e787 --- /dev/null +++ b/packages/manager/src/queries/databases/events.ts @@ -0,0 +1,44 @@ +import { getEngineFromDatabaseEntityURL } from 'src/utilities/getEventsActionLink'; + +import { databaseQueries } from './databases'; + +import type { Engine } from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; + +export const databaseEventsHandler = ({ + event, + queryClient, +}: EventHandlerData) => { + if (['failed', 'finished', 'notification'].includes(event.status)) { + queryClient.invalidateQueries({ + queryKey: databaseQueries.databases.queryKey, + }); + + /** + * This is what a Database event entity looks like: + * + * "entity": { + * "label": "my-db-staging", + * "id": 2959, + * "type": "database", + * "url": "/v4/databases/postgresql/instances/2959" + * }, + */ + if (event.entity) { + const engine = getEngineFromDatabaseEntityURL(event.entity.url); + + if (!engine) { + // eslint-disable-next-line no-console + return console.warn( + 'Unable to get DBaaS engine from entity URL in event', + event.id + ); + } + + queryClient.invalidateQueries({ + queryKey: databaseQueries.database(engine as Engine, event.entity.id) + .queryKey, + }); + } + } +}; diff --git a/packages/manager/src/queries/databases/requests.ts b/packages/manager/src/queries/databases/requests.ts new file mode 100644 index 00000000000..6ef7540103e --- /dev/null +++ b/packages/manager/src/queries/databases/requests.ts @@ -0,0 +1,23 @@ +import { + getDatabaseEngines, + getDatabaseTypes, + getDatabases, +} from '@linode/api-v4'; +import { DatabaseEngine, DatabaseInstance, DatabaseType } from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +export const getAllDatabases = () => + getAll((params) => getDatabases(params))().then( + (data) => data.data + ); + +export const getAllDatabaseEngines = () => + getAll((params) => getDatabaseEngines(params))().then( + (data) => data.data + ); + +export const getAllDatabaseTypes = () => + getAll((params) => getDatabaseTypes(params))().then( + (data) => data.data + ); diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index 0c061adf2f5..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'; +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/entityTransfers.ts b/packages/manager/src/queries/entityTransfers.ts index a0dfee17111..2480d06f81d 100644 --- a/packages/manager/src/queries/entityTransfers.ts +++ b/packages/manager/src/queries/entityTransfers.ts @@ -8,7 +8,7 @@ import { import { APIError, Filter, Params } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { creationHandlers, listToItemsByID, queryPresets } from './base'; 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 0ff8a8adbed..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 { +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { getAll } from 'src/utilities/getAll'; + +import { nodebalancerQueries } from './nodebalancers'; +import { profileQueries } from './profile/profile'; + +import type { APIError, + CreateFirewallPayload, Filter, + Firewall, + FirewallDevice, + FirewallDevicePayload, + FirewallRules, Params, ResourcePage, -} from '@linode/api-v4/lib/types'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { getAll } from 'src/utilities/getAll'; +const getAllFirewallDevices = ( + id: number, + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getFirewallDevices( + id, + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); -import { updateInPaginatedStore } from './base'; -import { profileQueries } from './profile'; +const getAllFirewallsRequest = () => + getAll((passedParams, passedFilter) => + getFirewalls(passedParams, passedFilter) + )().then((data) => data.data); -export const queryKey = 'firewall'; +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; } - ); - // update our paginated store with new rules - updateInPaginatedStore( - [queryKey, 'paginated'], - firewallId, - { - id: firewallId, + 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, - }, - queryClient - ); - }, - } - ); + }; + 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; + } + + 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, + }; + + 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 4e678cf646b..66cc2eab3d4 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -2,12 +2,14 @@ import { CreateImagePayload, Image, ImageUploadPayload, + UpdateImageRegionsPayload, UploadImageResponse, createImage, deleteImage, getImage, getImages, updateImage, + updateImageRegions, uploadImage, } from '@linode/api-v4'; import { @@ -22,7 +24,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; -import { profileQueries } from './profile'; +import { profileQueries } from './profile/profile'; export const getAllImages = ( passedParams: Params = {}, @@ -80,10 +82,10 @@ export const useUpdateImageMutation = () => { return useMutation< Image, APIError[], - { description?: string; imageId: string; label?: string } + { description?: string; imageId: string; label?: string; tags?: string[] } >({ - mutationFn: ({ description, imageId, label }) => - updateImage(imageId, label, description), + mutationFn: ({ description, imageId, label, tags }) => + updateImage(imageId, { description, label, tags }), onSuccess(image) { queryClient.invalidateQueries(imageQueries.paginated._def); queryClient.setQueryData( @@ -119,10 +121,35 @@ export const useAllImagesQuery = ( enabled, }); -export const useUploadImageMutation = (payload: ImageUploadPayload) => - useMutation({ - mutationFn: () => uploadImage(payload), +export const useUploadImageMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: uploadImage, + onSuccess(data) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); + queryClient.setQueryData( + imageQueries.image(data.image.id).queryKey, + data.image + ); + }, }); +}; + +export const useUpdateImageRegionsMutation = (imageId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => updateImageRegions(imageId, data), + onSuccess(image) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); + queryClient.setQueryData( + imageQueries.image(image.id).queryKey, + image + ); + }, + }); +}; export const imageEventsHandler = ({ event, diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index dbae81e7de1..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,19 +18,31 @@ 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'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; -import { profileQueries } from './profile'; +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) => ({ @@ -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/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index 85266a7d098..34a5549729a 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -42,7 +42,7 @@ import { manuallySetVPCConfigInterfacesToActive } from 'src/utilities/configs'; import { accountQueries } from '../account/queries'; import { queryPresets } from '../base'; -import { profileQueries } from '../profile'; +import { profileQueries } from '../profile/profile'; import { vlanQueries } from '../vlans'; import { getAllLinodeKernelsRequest, getAllLinodesRequest } from './requests'; 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 6dfdb690d7d..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 { profileQueries } from './profile'; +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/queries/objectStorage.ts b/packages/manager/src/queries/objectStorage.ts index 052a81d7989..44de89ed1a9 100644 --- a/packages/manager/src/queries/objectStorage.ts +++ b/packages/manager/src/queries/objectStorage.ts @@ -20,11 +20,17 @@ import { getClusters, getObjectList, getObjectStorageKeys, + getObjectStorageTypes, getObjectURL, getSSLCert, uploadSSLCert, } from '@linode/api-v4'; -import { APIError, Params, ResourcePage } from '@linode/api-v4/lib/types'; +import { + APIError, + Params, + PriceType, + ResourcePage, +} from '@linode/api-v4/lib/types'; import { QueryClient, useInfiniteQuery, @@ -406,3 +412,16 @@ export const useBucketSSLDeleteMutation = (cluster: string, bucket: string) => { }, }); }; + +const getAllObjectStorageTypes = () => + getAll((params) => getObjectStorageTypes(params))().then( + (data) => data.data + ); + +export const useObjectStorageTypesQuery = (enabled = true) => + useQuery({ + queryFn: getAllObjectStorageTypes, + queryKey: [queryKey, 'types'], + ...queryPresets.oneTimeFetch, + enabled, + }); diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index 1a599080b3c..4f8a4fefd70 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -19,7 +19,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { queryKey as linodeQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; -import { profileQueries } from './profile'; +import { profileQueries } from './profile/profile'; import type { AssignLinodesToPlacementGroupPayload, diff --git a/packages/manager/src/queries/preferences.ts b/packages/manager/src/queries/profile/preferences.ts similarity index 66% rename from packages/manager/src/queries/preferences.ts rename to packages/manager/src/queries/profile/preferences.ts index f99f578931b..086b3e6fcfc 100644 --- a/packages/manager/src/queries/preferences.ts +++ b/packages/manager/src/queries/profile/preferences.ts @@ -1,8 +1,4 @@ -import { - getUserPreferences, - updateUserPreferences, -} from '@linode/api-v4/lib/profile'; -import { APIError } from '@linode/api-v4/lib/types'; +import { updateUserPreferences } from '@linode/api-v4'; import { QueryClient, useMutation, @@ -12,12 +8,14 @@ import { import { ManagerPreferences } from 'src/types/ManagerPreferences'; -import { queryPresets } from './base'; +import { queryPresets } from '../base'; +import { profileQueries } from './profile'; -export const queryKey = 'preferences'; +import type { APIError } from '@linode/api-v4'; export const usePreferences = (enabled = true) => - useQuery([queryKey], getUserPreferences, { + useQuery({ + ...profileQueries.preferences, ...queryPresets.oneTimeFetch, enabled, }); @@ -25,20 +23,19 @@ export const usePreferences = (enabled = true) => export const useMutatePreferences = (replace = false) => { const { data: preferences } = usePreferences(!replace); const queryClient = useQueryClient(); + return useMutation< ManagerPreferences, APIError[], Partial - >( - (data) => + >({ + mutationFn: (data) => updateUserPreferences({ ...(!replace && preferences !== undefined ? preferences : {}), ...data, }), - { - onMutate: (data) => updatePreferenceData(data, replace, queryClient), - } - ); + onMutate: (data) => updatePreferenceData(data, replace, queryClient), + }); }; export const updatePreferenceData = ( @@ -47,8 +44,8 @@ export const updatePreferenceData = ( queryClient: QueryClient ): void => { queryClient.setQueryData( - [queryKey], - (oldData: ManagerPreferences) => ({ + profileQueries.preferences.queryKey, + (oldData) => ({ ...(!replace ? oldData : {}), ...newData, }) diff --git a/packages/manager/src/queries/profile.ts b/packages/manager/src/queries/profile/profile.ts similarity index 94% rename from packages/manager/src/queries/profile.ts rename to packages/manager/src/queries/profile/profile.ts index 2306c075962..cc57db89c4e 100644 --- a/packages/manager/src/queries/profile.ts +++ b/packages/manager/src/queries/profile/profile.ts @@ -1,31 +1,25 @@ import { Profile, SSHKey, - SendPhoneVerificationCodePayload, TrustedDevice, - VerifyVerificationCodePayload, createSSHKey, deleteSSHKey, deleteTrustedDevice, disableTwoFactor, + getAppTokens, + getPersonalAccessTokens, getProfile, getSSHKeys, + getSecurityQuestions, getTrustedDevices, + getUserPreferences, listGrants, sendCodeToPhoneNumber, smsOptOut, updateProfile, updateSSHKey, verifyPhoneNumberCode, - getAppTokens, - getPersonalAccessTokens, -} from '@linode/api-v4/lib/profile'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; +} from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { QueryClient, @@ -36,11 +30,19 @@ import { import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { Grants } from '../../../api-v4/lib'; -import { accountQueries } from './account/queries'; -import { queryPresets } from './base'; +import { accountQueries } from '../account/queries'; +import { queryPresets } from '../base'; -import type { RequestOptions } from '@linode/api-v4'; +import type { + APIError, + Filter, + Grants, + Params, + RequestOptions, + ResourcePage, + SendPhoneVerificationCodePayload, + VerifyVerificationCodePayload, +} from '@linode/api-v4'; export const profileQueries = createQueryKeys('profile', { appTokens: (params: Params = {}, filter: Filter = {}) => ({ @@ -55,10 +57,18 @@ export const profileQueries = createQueryKeys('profile', { queryFn: () => getPersonalAccessTokens(params, filter), queryKey: [params, filter], }), + preferences: { + queryFn: getUserPreferences, + queryKey: null, + }, profile: (options: RequestOptions = {}) => ({ queryFn: () => getProfile(options), queryKey: [options], }), + securityQuestions: { + queryFn: getSecurityQuestions, + queryKey: null, + }, sshKeys: (params: Params = {}, filter: Filter = {}) => ({ queryFn: () => getSSHKeys(params, filter), queryKey: [params, filter], diff --git a/packages/manager/src/queries/profile/securityQuestions.ts b/packages/manager/src/queries/profile/securityQuestions.ts new file mode 100644 index 00000000000..78109ac37f5 --- /dev/null +++ b/packages/manager/src/queries/profile/securityQuestions.ts @@ -0,0 +1,79 @@ +import { updateSecurityQuestions } from '@linode/api-v4'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { queryPresets } from '../base'; +import { profileQueries } from './profile'; + +import type { + APIError, + SecurityQuestionsData, + SecurityQuestionsPayload, +} from '@linode/api-v4'; + +export const useSecurityQuestions = ({ + enabled = true, +}: { + enabled?: boolean; +} = {}) => { + return useQuery({ + ...profileQueries.securityQuestions, + ...queryPresets.oneTimeFetch, + enabled, + }); +}; + +export const useMutateSecurityQuestions = () => { + const queryClient = useQueryClient(); + return useMutation< + SecurityQuestionsPayload, + APIError[], + SecurityQuestionsPayload + >({ + mutationFn: updateSecurityQuestions, + onSuccess: (response) => { + queryClient.setQueryData( + profileQueries.securityQuestions.queryKey, + (oldData) => { + if (oldData === undefined) { + return undefined; + } + + const newQuestions: SecurityQuestionsData['security_questions'] = oldData.security_questions.map( + (item) => ({ + ...item, + response: null, + }) + ); + + for (let i = 0; i < response.security_questions.length; i++) { + const index = oldData.security_questions.findIndex( + (question) => + question.id === response.security_questions[i].question_id + ); + + newQuestions[index].response = + response.security_questions[i].response; + } + + for (let i = 0; i < response.security_questions.length; i++) { + const index = newQuestions.findIndex( + (question) => + question.id === response.security_questions[i].question_id + ); + moveInArray(newQuestions, index, i); + } + + return { + security_questions: newQuestions, + }; + } + ); + }, + }); +}; + +function moveInArray(arr: any[], fromIndex: number, toIndex: number) { + const element = arr[fromIndex]; + arr.splice(fromIndex, 1); + arr.splice(toIndex, 0, element); +} diff --git a/packages/manager/src/queries/tokens.ts b/packages/manager/src/queries/profile/tokens.ts similarity index 100% rename from packages/manager/src/queries/tokens.ts rename to packages/manager/src/queries/profile/tokens.ts diff --git a/packages/manager/src/queries/regions/regions.ts b/packages/manager/src/queries/regions/regions.ts index 85330003adf..4016e6914c5 100644 --- a/packages/manager/src/queries/regions/regions.ts +++ b/packages/manager/src/queries/regions/regions.ts @@ -1,18 +1,18 @@ -import { - Region, - RegionAvailability, - getRegionAvailability, -} from '@linode/api-v4/lib/regions'; -import { APIError } from '@linode/api-v4/lib/types'; +import { getRegionAvailability } from '@linode/api-v4/lib/regions'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useQuery } from '@tanstack/react-query'; +import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; + import { queryPresets } from '../base'; import { getAllRegionAvailabilitiesRequest, getAllRegionsRequest, } from './requests'; +import type { Region, RegionAvailability } from '@linode/api-v4/lib/regions'; +import type { APIError } from '@linode/api-v4/lib/types'; + export const regionQueries = createQueryKeys('regions', { availability: { contextQueries: { @@ -33,10 +33,20 @@ export const regionQueries = createQueryKeys('regions', { }, }); -export const useRegionsQuery = () => +export const useRegionsQuery = (transformRegionLabel: boolean = false) => useQuery({ ...regionQueries.regions, ...queryPresets.longLived, + select: (regions: Region[]) => { + // Display Country, City instead of City, State + if (transformRegionLabel) { + return regions.map((region) => ({ + ...region, + label: getNewRegionLabel({ region }), + })); + } + return regions; + }, }); export const useRegionsAvailabilitiesQuery = (enabled: boolean = true) => diff --git a/packages/manager/src/queries/securityQuestions.ts b/packages/manager/src/queries/securityQuestions.ts deleted file mode 100644 index 7af77613dcc..00000000000 --- a/packages/manager/src/queries/securityQuestions.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - SecurityQuestionsData, - SecurityQuestionsPayload, - getSecurityQuestions, - updateSecurityQuestions, -} from '@linode/api-v4/lib/profile'; -import { APIError } from '@linode/api-v4/lib/types'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { queryPresets } from './base'; - -export const queryKey = 'securityQuestions'; - -export const useSecurityQuestions = ({ - enabled = true, -}: { - enabled?: boolean; -} = {}) => { - return useQuery( - [queryKey], - getSecurityQuestions, - { - ...queryPresets.oneTimeFetch, - enabled, - } - ); -}; - -export const useMutateSecurityQuestions = () => { - const queryClient = useQueryClient(); - return useMutation< - SecurityQuestionsPayload, - APIError[], - SecurityQuestionsPayload - >( - (data) => { - return updateSecurityQuestions(data); - }, - { - onSuccess: (response) => { - queryClient.setQueryData( - [queryKey], - (oldData) => { - if (oldData === undefined) { - return undefined; - } - - const newQuestions: SecurityQuestionsData['security_questions'] = oldData.security_questions.map( - (item) => ({ - ...item, - response: null, - }) - ); - - for (let i = 0; i < response.security_questions.length; i++) { - const index = oldData.security_questions.findIndex( - (question) => - question.id === response.security_questions[i].question_id - ); - - newQuestions[index].response = - response.security_questions[i].response; - } - - for (let i = 0; i < response.security_questions.length; i++) { - const index = newQuestions.findIndex( - (question) => - question.id === response.security_questions[i].question_id - ); - moveInArray(newQuestions, index, i); - } - - return { - security_questions: newQuestions, - }; - } - ); - }, - } - ); -}; - -function moveInArray(arr: any[], fromIndex: number, toIndex: number) { - const element = arr[fromIndex]; - arr.splice(fromIndex, 1); - arr.splice(toIndex, 0, element); -} diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index 5799751d14b..f1c1576faea 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -12,6 +12,7 @@ import { import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { getOneClickApps } from 'src/features/StackScripts/stackScriptUtils'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; @@ -23,14 +24,17 @@ export const getAllOCAsRequest = (passedParams: Params = {}) => getOneClickApps({ ...params, ...passedParams }) )().then((data) => data.data); -const stackscriptQueries = createQueryKeys('stackscripts', { +export const stackscriptQueries = createQueryKeys('stackscripts', { infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => getStackScripts({ page: pageParam, page_size: 25 }, filter), queryKey: [filter], }), marketplace: { - queryFn: () => getAllOCAsRequest(), + queryFn: async () => { + const stackscripts = await getAllOCAsRequest(); + return stackscripts.filter((s) => oneClickApps[s.id]); + }, queryKey: null, }, stackscript: (id: number) => ({ diff --git a/packages/manager/src/queries/support.ts b/packages/manager/src/queries/support.ts index 40bd2cf3961..5c4ea96838c 100644 --- a/packages/manager/src/queries/support.ts +++ b/packages/manager/src/queries/support.ts @@ -7,7 +7,10 @@ import { getTicket, getTicketReplies, getTickets, + createSupportTicket, + TicketRequest, } from '@linode/api-v4/lib/support'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useInfiniteQuery, useMutation, @@ -24,54 +27,113 @@ import type { ResourcePage, } from '@linode/api-v4/lib/types'; -const queryKey = `tickets`; +const supportQueries = createQueryKeys('support', { + ticket: (id: number) => ({ + contextQueries: { + replies: { + queryFn: ({ pageParam }) => + getTicketReplies(id, { page: pageParam, page_size: 25 }), + queryKey: null, + }, + }, + queryFn: () => getTicket(id), + queryKey: [id], + }), + tickets: (params: Params, filter: Filter) => ({ + queryFn: () => getTickets(params, filter), + queryKey: [params, filter], + }), +}); export const useSupportTicketsQuery = (params: Params, filter: Filter) => - useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getTickets(params, filter), - { keepPreviousData: true } - ); + useQuery, APIError[]>({ + ...supportQueries.tickets(params, filter), + keepPreviousData: true, + }); export const useSupportTicketQuery = (id: number) => - useQuery([queryKey, 'ticket', id], () => - getTicket(id) - ); + useQuery(supportQueries.ticket(id)); + +export const useCreateSupportTicketMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createSupportTicket, + onSuccess(ticket) { + queryClient.invalidateQueries({ queryKey: supportQueries.tickets._def }); + queryClient.setQueryData( + supportQueries.ticket(ticket.id).queryKey, + ticket + ); + }, + }); +}; export const useInfiniteSupportTicketRepliesQuery = (id: number) => - useInfiniteQuery, APIError[]>( - [queryKey, 'ticket', id, 'replies'], - ({ pageParam }) => getTicketReplies(id, { page: pageParam, page_size: 25 }), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + useInfiniteQuery, APIError[]>({ + ...supportQueries.ticket(id)._ctx.replies, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); export const useSupportTicketReplyMutation = () => { const queryClient = useQueryClient(); - return useMutation(createReply, { - onSuccess() { - queryClient.invalidateQueries([queryKey]); + return useMutation({ + mutationFn: createReply, + onSuccess(data, variables) { + queryClient.invalidateQueries({ + queryKey: supportQueries.tickets._def, + }); + queryClient.invalidateQueries({ + queryKey: supportQueries.ticket(variables.ticket_id).queryKey, + }); }, }); }; export const useSupportTicketCloseMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => closeSupportTicket(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => closeSupportTicket(id), onSuccess() { - queryClient.invalidateQueries([queryKey]); + queryClient.invalidateQueries({ + queryKey: supportQueries.tickets._def, + }); + queryClient.invalidateQueries({ + queryKey: supportQueries.ticket(id).queryKey, + }); }, }); }; export const supportTicketEventHandler = ({ + event, queryClient, }: EventHandlerData) => { - queryClient.invalidateQueries([queryKey]); + /** + * Ticket events have entities that look like this: + * + * "entity": { + * "label": "Great news! We're upgrading your Block Storage", + * "id": 3674063, + * "type": "ticket", + * "url": "/v4/support/tickets/3674063" + * } + */ + + // Invalidate paginated support tickets + queryClient.invalidateQueries({ + queryKey: supportQueries.tickets._def, + }); + + if (event.entity) { + // If there is an entity associated with the event, invalidate that ticket + queryClient.invalidateQueries({ + queryKey: supportQueries.ticket(event.entity.id).queryKey, + }); + } }; diff --git a/packages/manager/src/queries/volumes/volumes.ts b/packages/manager/src/queries/volumes/volumes.ts index d2b549100dc..0476d622e1d 100644 --- a/packages/manager/src/queries/volumes/volumes.ts +++ b/packages/manager/src/queries/volumes/volumes.ts @@ -28,7 +28,7 @@ import { import { accountQueries } from '../account/queries'; import { queryPresets } from '../base'; -import { profileQueries } from '../profile'; +import { profileQueries } from '../profile/profile'; import { getAllVolumeTypes, getAllVolumes } from './requests'; export const volumeQueries = createQueryKeys('volumes', { diff --git a/packages/manager/src/request.tsx b/packages/manager/src/request.tsx index 3e8afda8923..9dd64330715 100644 --- a/packages/manager/src/request.tsx +++ b/packages/manager/src/request.tsx @@ -8,7 +8,6 @@ import { } from 'axios'; import * as React from 'react'; -import { AccountActivationError } from 'src/components/AccountActivation'; import { MigrateError } from 'src/components/MigrateError'; import { VerificationError } from 'src/components/VerificationError'; import { ACCESS_TOKEN, API_ROOT, DEFAULT_ERROR_MESSAGE } from 'src/constants'; @@ -62,6 +61,17 @@ export const handleError = ( ); } + if ( + !!errors[0].reason.match(/account must be activated/i) && + status === 403 + ) { + store.dispatch( + setErrors({ + account_unactivated: true, + }) + ); + } + /** AxiosError contains the original POST data as stringified JSON */ let requestData; try { @@ -84,31 +94,6 @@ export const handleError = ( /> ), }, - { - callback: () => { - if (store && !store.getState().globalErrors.account_unactivated) { - store.dispatch( - setErrors({ - account_unactivated: true, - }) - ); - } - }, - condition: (e) => - !!e.reason.match(/account must be activated/i) && status === 403, - /** - * this component when rendered will set an account activation - * error in the globalErrors Redux state. The only issue here - * is that if a component is not rendering the actual error message - * that comes down, the Redux state will never be set. - * - * This means that we have 2 options - * - * 1. Dispatch the globalError Redux action somewhere in the interceptor. - * 2. Fix the Landing page components to display the actual error being passed. - */ - replacementText: , - }, { condition: (e) => { return ( 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/useSetupFeatureFlags.ts b/packages/manager/src/useSetupFeatureFlags.ts index bed7ff14e25..1898f3f340c 100644 --- a/packages/manager/src/useSetupFeatureFlags.ts +++ b/packages/manager/src/useSetupFeatureFlags.ts @@ -5,7 +5,7 @@ import { LAUNCH_DARKLY_API_KEY } from 'src/constants'; import { configureErrorReportingUser } from './exceptionReporting'; import { useAccount } from './queries/account/account'; -import { useProfile } from './queries/profile'; +import { useProfile } from './queries/profile/profile'; /** * This hook uses Linode account data to set Sentry and Launch Darkly context. diff --git a/packages/manager/src/utilities/analytics/customEventAnalytics.ts b/packages/manager/src/utilities/analytics/customEventAnalytics.ts index 7c794790ef4..b6e49e2e4a5 100644 --- a/packages/manager/src/utilities/analytics/customEventAnalytics.ts +++ b/packages/manager/src/utilities/analytics/customEventAnalytics.ts @@ -113,7 +113,7 @@ export const sendCreateNodeBalancerEvent = (eventLabel: string): void => { // LinodeCreateContainer.tsx export const sendCreateLinodeEvent = ( eventAction: string, - eventLabel: string, + eventLabel: string | undefined, eventData?: CustomAnalyticsData ): void => { sendEvent({ @@ -452,20 +452,12 @@ export const sendUpdateLinodeLabelEvent = ( }); }; -// GravatarByEmail.tsx -export const sendHasGravatarEvent = (hasGravatar: boolean) => { +// SelectLinodePanel.tsx +// LinodeSelectTable.tsx +export const sendLinodePowerOffEvent = (category: string) => { sendEvent({ - action: 'Load', - category: 'Gravatar', - label: hasGravatar ? 'Has Gravatar' : 'Does not have Gravatar', - }); -}; - -// DisplaySettings.tsx -export const sendManageGravatarEvent = () => { - sendEvent({ - action: 'Click:link', - category: 'Gravatar', - label: 'Manage photo', + action: 'Click:button', + category, + label: 'Power Off', }); }; diff --git a/packages/manager/src/utilities/analytics/utils.ts b/packages/manager/src/utilities/analytics/utils.ts index 991e605d3cd..bc0b994fe6b 100644 --- a/packages/manager/src/utilities/analytics/utils.ts +++ b/packages/manager/src/utilities/analytics/utils.ts @@ -57,8 +57,7 @@ export const sendFormEvent = ( } else if (eventType === 'formError' && 'formError' in eventPayload) { formEventPayload['formError'] = eventPayload.formError.replace(/\|/g, ''); } - - window._satellite.track(eventType, formEventPayload); + // window._satellite.track(eventType, formEventPayload); } }; 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/getEventsActionLink.test.ts b/packages/manager/src/utilities/getEventsActionLink.test.ts new file mode 100644 index 00000000000..8ec7541c3a1 --- /dev/null +++ b/packages/manager/src/utilities/getEventsActionLink.test.ts @@ -0,0 +1,9 @@ +import { getEngineFromDatabaseEntityURL } from './getEventsActionLink'; + +describe('getEngineFromDatabaseEntityURL', () => { + it('should return an engine from a URL returned by apiv4', () => { + expect( + getEngineFromDatabaseEntityURL('/v4/databases/postgresql/instances/2959') + ).toBe('postgresql'); + }); +}); diff --git a/packages/manager/src/utilities/mapIdsToDevices.test.ts b/packages/manager/src/utilities/mapIdsToDevices.test.ts index 1f8a43091a4..af30a9537d1 100644 --- a/packages/manager/src/utilities/mapIdsToDevices.test.ts +++ b/packages/manager/src/utilities/mapIdsToDevices.test.ts @@ -1,46 +1,51 @@ -import { Linode } from '@linode/api-v4'; -import { NodeBalancer } from '@linode/api-v4'; - -import { linodeFactory } from 'src/factories'; import { nodeBalancerFactory } from 'src/factories'; +import { linodeFactory } from 'src/factories'; import { mapIdsToDevices } from './mapIdsToDevices'; +import type { NodeBalancer } from '@linode/api-v4'; +import type { Linode } from '@linode/api-v4'; + describe('mapIdsToDevices', () => { const linodes = linodeFactory.buildList(5); const nodebalancers = nodeBalancerFactory.buildList(5); + it('works with a single Linode ID', () => { - expect(mapIdsToDevices(1, linodes)).toBe(linodes[1]); + expect(mapIdsToDevices(1, linodes)).toBe(linodes[0]); }); + it('works with a single NodeBalancer ID', () => { expect(mapIdsToDevices(1, nodebalancers)).toBe( - nodebalancers[1] + nodebalancers[0] ); }); + it('works with a multiple Linode IDs', () => { - expect(mapIdsToDevices([0, 1, 2], linodes)).toEqual([ + expect(mapIdsToDevices([1, 2, 3], linodes)).toEqual([ linodes[0], linodes[1], linodes[2], ]); }); + it('works with a multiple NodeBalancer IDs', () => { - expect(mapIdsToDevices([0, 1, 2], nodebalancers)).toEqual([ + expect(mapIdsToDevices([1, 2, 3], nodebalancers)).toEqual([ nodebalancers[0], nodebalancers[1], nodebalancers[2], ]); }); + it('omits missing IDs', () => { expect(mapIdsToDevices(99, linodes)).toBe(null); expect(mapIdsToDevices(99, nodebalancers)).toBe(null); - expect(mapIdsToDevices([0, 99, 2], linodes)).toEqual([ + expect(mapIdsToDevices([1, 99, 2], linodes)).toEqual([ linodes[0], - linodes[2], + linodes[1], ]); - expect(mapIdsToDevices([0, 99, 2], nodebalancers)).toEqual([ + expect(mapIdsToDevices([1, 99, 2], nodebalancers)).toEqual([ nodebalancers[0], - nodebalancers[2], + nodebalancers[1], ]); }); }); 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 4381a738abc..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,42 @@ 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', () => { + expect( + getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId: 'us-east', + type: mockNodeBalancerType, + }) + ).toBe('0.015'); + }); + + it('calculates dynamic pricing for a region with an increase on an hourly interval to the specified decimal', () => { + expect( + getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId: 'id-cgk', + type: mockNodeBalancerType, + }) + ).toBe('0.018'); }); it('calculates dynamic pricing for a volume based on size', () => { diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.ts b/packages/manager/src/utilities/pricing/dynamicPricing.ts index 90598a37e6f..b6e17da0b60 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.ts @@ -21,6 +21,16 @@ export interface DataCenterPricingOptions { } export interface DataCenterPricingByTypeOptions { + /** + * The number of decimal places to return for the price. + * @default 2 + */ + decimalPrecision?: number; + /** + * The time period for which to find pricing data for (hourly or monthly). + * @default monthly + */ + interval?: 'hourly' | 'monthly'; /** * The `id` of the region we intended to get the price for. * @example us-east @@ -43,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 @@ -94,6 +93,8 @@ export const getDCSpecificPrice = ({ * @returns a data center specific price or undefined if this cannot be calculated */ export const getDCSpecificPriceByType = ({ + decimalPrecision = 2, + interval = 'monthly', regionId, size, type, @@ -101,19 +102,18 @@ export const getDCSpecificPriceByType = ({ if (!regionId || !type) { return undefined; } - // Apply the DC-specific price if it exists; otherwise, use the base price. const price = type.region_prices.find((region_price: RegionPrice) => { return region_price.id === regionId; - })?.monthly ?? type.price.monthly; + })?.[interval] ?? type.price?.[interval]; // If pricing is determined by size of the entity if (size && price) { - return (size * price).toFixed(2); + return (size * price).toFixed(decimalPrecision); } - return price?.toFixed(2) ?? undefined; + return price?.toFixed(decimalPrecision) ?? undefined; }; export const renderMonthlyPriceToCorrectDecimalPlace = ( 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/scrollErrorIntoView.ts b/packages/manager/src/utilities/scrollErrorIntoView.ts index dc4bd139771..3ea821ac59e 100644 --- a/packages/manager/src/utilities/scrollErrorIntoView.ts +++ b/packages/manager/src/utilities/scrollErrorIntoView.ts @@ -1,3 +1,7 @@ +/** + * @deprecated + * Use `scrollErrorIntoViewV2` instead. + */ export const scrollErrorIntoView = ( errorGroup?: string, options?: ScrollIntoViewOptions diff --git a/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx b/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx new file mode 100644 index 00000000000..2797d737b59 --- /dev/null +++ b/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx @@ -0,0 +1,45 @@ +import { scrollErrorIntoViewV2 } from './scrollErrorIntoViewV2'; + +import type { Mock } from 'vitest'; + +describe('scrollErrorIntoViewV2', () => { + it('should scroll to the error element when it exists', () => { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + const errorElement = document.createElement('div'); + errorElement.classList.add('error-for-scroll'); + const formContainer = document.createElement('div'); + formContainer.appendChild(errorElement); + + const formContainerRef = { + current: formContainer, + }; + + const observeMock = vi.fn(); + const disconnectMock = vi.fn(); + const takeRecords = vi.fn(); + window.MutationObserver = vi.fn(() => ({ + disconnect: disconnectMock, + observe: observeMock, + takeRecords, + })); + + scrollErrorIntoViewV2(formContainerRef); + + expect(observeMock).toHaveBeenCalledWith(formContainer, { + attributes: true, + childList: true, + subtree: true, + }); + + const mutationCallback = (window.MutationObserver as Mock).mock.calls[0][0]; + mutationCallback([{ target: formContainer, type: 'childList' }]); + + expect(errorElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + expect(disconnectMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/utilities/scrollErrorIntoViewV2.ts b/packages/manager/src/utilities/scrollErrorIntoViewV2.ts new file mode 100644 index 00000000000..25c3250f041 --- /dev/null +++ b/packages/manager/src/utilities/scrollErrorIntoViewV2.ts @@ -0,0 +1,47 @@ +/** + * This utility is the version 2 of the scrollErrorIntoView utility. + * It should be the preferred utility in formik forms. + * It uses a MutationObserver to solve the issue of the form not always being + * fully rendered when the scrollErrorIntoView function is called, resulting in + * some instances in the error not being scrolled into view. + * + * If there are multiple form errors, the first one will be scrolled into view. + * + * @param formContainerRef A React ref to the form element (or a form container since we're not always semantically aligned on form markup) that contains a potential field error. + */ +export const scrollErrorIntoViewV2 = ( + formContainerRef: React.RefObject +) => { + if (!formContainerRef.current) { + return; + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + (mutation.type === 'childList' || mutation.type === 'attributes') && + formContainerRef.current + ) { + const errorElement = formContainerRef.current.querySelector( + '[class*="error-for-scroll"]' + ); + if (errorElement) { + errorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + observer.disconnect(); + } + } + }); + }); + + observer.observe(formContainerRef.current, { + attributes: true, + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); +}; 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..379bac12ab3 --- /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/src/utilities/storage.test.ts b/packages/manager/src/utilities/storage.test.ts index 46204e9b750..004c88dfb5f 100644 --- a/packages/manager/src/utilities/storage.test.ts +++ b/packages/manager/src/utilities/storage.test.ts @@ -15,9 +15,9 @@ describe('getLocalStorageOverrides', () => { }; describe('built for development mode', () => { - // Stub `DEV` environment variable to be truthy. + // Stub `DEV` environment variable to be true. beforeEach(() => { - vi.stubEnv('DEV', '1'); + vi.stubEnv('DEV', true); }); it('returns overrides if overrides are defined', () => { @@ -42,9 +42,9 @@ describe('getLocalStorageOverrides', () => { }); describe('not built for development mode', () => { - // Stub `DEV` environment variable to be falsy. + // Stub `DEV` environment variable to be false. beforeEach(() => { - vi.stubEnv('DEV', ''); + vi.stubEnv('DEV', false); }); it('returns `undefined` when overrides are defined', () => { diff --git a/packages/manager/src/utilities/theme.ts b/packages/manager/src/utilities/theme.ts index 322cf68e677..67f66422337 100644 --- a/packages/manager/src/utilities/theme.ts +++ b/packages/manager/src/utilities/theme.ts @@ -5,7 +5,7 @@ import { dark, light } from 'src/foundations/themes'; import type { ThemeName } from 'src/foundations/themes'; import { useAuthentication } from 'src/hooks/useAuthentication'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; export type ThemeChoice = 'dark' | 'light' | 'system'; 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 066503d84cd..4b1d85d1f14 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -1,6 +1,7 @@ import react from '@vitejs/plugin-react-swc'; import svgr from 'vite-plugin-svgr'; import { defineConfig } from 'vitest/config'; +import { URL } from 'url'; // ESM-friendly alternative to `__dirname`. const DIRNAME = new URL('.', import.meta.url).pathname; @@ -10,11 +11,6 @@ export default defineConfig({ outDir: 'build', }, envPrefix: 'REACT_APP_', - optimizeDeps: { - esbuildOptions: { - target: 'es5', - }, - }, plugins: [react(), svgr({ exportAsDefault: true })], resolve: { alias: { @@ -39,7 +35,7 @@ export default defineConfig({ 'src/**/*.utils.{js,jsx,ts,tsx}', ], }, - css: true, + pool: 'forks', environment: 'jsdom', globals: true, setupFiles: './src/testSetup.ts', diff --git a/packages/search/README.md b/packages/search/README.md new file mode 100644 index 00000000000..56980bfa80f --- /dev/null +++ b/packages/search/README.md @@ -0,0 +1,43 @@ +# Search + +Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). + +The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Connected Cloud Manager. + +## Example + +### Search Query +``` +label: my-volume and size >= 20 +``` +### Resulting `X-Filter` +```json +{ + "+and": [ + { + "label": { + "+contains": "my-volume" + } + }, + { + "size": { + "+gte": 20 + } + } + ] +} +``` + +## Supported Operations + +| Operation | Aliases | Example | Description | +|-----------|----------------|--------------------------------|-----------------------------------------------------------------| +| `and` | `&`, `&&` | `label: prod and size > 20` | Performs a boolean *and* on two expressions | +| `or` | `|`, `||` | `label: prod or size > 20` | Performs a boolean *or* on two expressions | +| `>` | None | `size > 20` | Greater than | +| `<` | None | `size < 20` | Less than | +| `>=` | None | `size >= 20` | Great than or equal to | +| `<=` | None | `size <= 20` | Less than or equal to | +| `!` | `-` | `!label = my-linode-1` | Not equal to (does not work as a *not* for boolean expressions) | +| `=` | None | `label = my-linode-1` | Equal to | +| `:` | `~` | `label: my-linode` | Contains | diff --git a/packages/search/package.json b/packages/search/package.json new file mode 100644 index 00000000000..7445271aa83 --- /dev/null +++ b/packages/search/package.json @@ -0,0 +1,25 @@ +{ + "name": "@linode/search", + "version": "0.0.1", + "description": "Search query parser for Linode API filtering", + "type": "module", + "main": "src/search.ts", + "module": "src/search.ts", + "types": "src/search.ts", + "license": "Apache-2.0", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "precommit": "tsc" + }, + "dependencies": { + "peggy": "^4.0.3" + }, + "peerDependencies": { + "@linode/api-v4": "*", + "vite": "*" + }, + "devDependencies": { + "vitest": "^1.6.0" + } +} diff --git a/packages/search/src/search.peggy b/packages/search/src/search.peggy new file mode 100644 index 00000000000..7a28192bd2b --- /dev/null +++ b/packages/search/src/search.peggy @@ -0,0 +1,99 @@ +start + = orQuery + +orQuery + = left:andQuery Or right:orQuery { return { "+or": [left, right] }; } + / andQuery + / DefaultQuery + +andQuery + = left:subQuery And right:andQuery { return { "+and": [left, right] }; } + / subQuery + +subQuery + = '(' ws* query:orQuery ws* ')' { return query; } + / EqualQuery + / ContainsQuery + / NotEqualQuery + / LessThanQuery + / LessThenOrEqualTo + / GreaterThanQuery + / GreaterThanOrEqualTo + +DefaultQuery + = input:String { + const keys = options.searchableFieldsWithoutOperator; + return { "+or": keys.map((key) => ({ [key]: { "+contains": input } })) }; + } + +EqualQuery + = key:FilterableField ws* Equal ws* value:Number { return { [key]: value }; } + / key:FilterableField ws* Equal ws* value:String { return { [key]: value }; } + +ContainsQuery + = key:FilterableField ws* Contains ws* value:String { return { [key]: { "+contains": value } }; } + +TagQuery + = "tag" ws* Equal ws* value:String { return { "tags": { "+contains": value } }; } + +NotEqualQuery + = Not key:FilterableField ws* Equal ws* value:String { return { [key]: { "+neq": value } }; } + +LessThanQuery + = key:FilterableField ws* Less ws* value:Number { return { [key]: { "+lt": value } }; } + +GreaterThanQuery + = key:FilterableField ws* Greater ws* value:Number { return { [key]: { "+gt": value } }; } + +GreaterThanOrEqualTo + = key:FilterableField ws* Gte ws* value:Number { return { [key]: { "+gte": value } }; } + +LessThenOrEqualTo + = key:FilterableField ws* Lte ws* value:Number { return { [key]: { "+lte": value } }; } + +Or + = ws+ 'or'i ws+ + / ws* '||' ws* + / ws* '|' ws* + +And + = ws+ 'and'i ws+ + / ws* '&&' ws* + / ws* '&' ws* + / ws + +Not + = '!' + / '-' + +Less + = '<' + +Greater + = '>' + +Gte + = '>=' + +Lte + = '<=' + +Equal + = "=" + +Contains + = "~" + / ":" + +FilterableField "filterable field" + = [a-zA-Z0-9\-\.]+ { return text(); } + +String "search value" + = [a-zA-Z0-9\-\.]+ { return text(); } + +Number "numeric search value" + = number:[0-9\.]+ { return parseFloat(number.join("")); } + / number:[0-9]+ { return parseInt(number.join(""), 10); } + +ws "whitespace" + = [ \t\r\n] \ No newline at end of file diff --git a/packages/search/src/search.test.ts b/packages/search/src/search.test.ts new file mode 100644 index 00000000000..0726cba626a --- /dev/null +++ b/packages/search/src/search.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { getAPIFilterFromQuery } from './search'; + +describe("getAPIFilterFromQuery", () => { + it("handles +contains", () => { + const query = "label: my-linode"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + label: { "+contains": "my-linode" }, + }, + error: null, + }); + }); + + it("handles +eq with strings", () => { + const query = "label = my-linode"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + label: "my-linode", + }, + error: null, + }); + }); + + it("handles +eq with numbers", () => { + const query = "id = 100"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + id: 100, + }, + error: null, + }); + }); + + it("handles +lt", () => { + const query = "size < 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+lt': 20 } + }, + error: null, + }); + }); + + it("handles +gt", () => { + const query = "size > 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+gt': 20 } + }, + error: null, + }); + }); + + it("handles +gte", () => { + const query = "size >= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+gte': 20 } + }, + error: null, + }); + }); + + it("handles +lte", () => { + const query = "size <= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+lte': 20 } + }, + error: null, + }); + }); + + it("handles an 'and' search", () => { + const query = "label: my-linode-1 and tags: production"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+and"]: [ + { label: { "+contains": "my-linode-1" } }, + { tags: { '+contains': "production" } }, + ], + }, + error: null, + }); + }); + + it("handles an 'or' search", () => { + const query = "label: prod or size >= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+or"]: [ + { label: { "+contains": "prod" } }, + { size: { '+gte': 20 } }, + ], + }, + error: null, + }); + }); + + it("handles nested queries", () => { + const query = "(label: prod and size >= 20) or (label: staging and size < 50)"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+or"]: [ + { ["+and"]: [{ label: { '+contains': 'prod' } }, { size: { '+gte': 20 } }] }, + { ["+and"]: [{ label: { '+contains': 'staging' } }, { size: { '+lt': 50 } }] }, + ], + }, + error: null, + }); + }); + + it("returns a default query based on the 'defaultSearchKeys' provided", () => { + const query = "my-linode-1"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: ['label', 'tags'] })).toEqual({ + filter: { + ["+or"]: [ + { label: { "+contains": "my-linode-1" } }, + { tags: { '+contains': "my-linode-1" } }, + ], + }, + error: null, + }); + }); + + it("returns an error for an incomplete search query", () => { + const query = "label: "; + + expect( + getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] }).error?.message + ).toEqual("Expected search value or whitespace but end of input found."); + }); +}); \ No newline at end of file diff --git a/packages/search/src/search.ts b/packages/search/src/search.ts new file mode 100644 index 00000000000..3cfb368c29a --- /dev/null +++ b/packages/search/src/search.ts @@ -0,0 +1,35 @@ +import { generate } from 'peggy'; +import type { Filter } from '@linode/api-v4'; +import grammar from './search.peggy?raw'; + +const parser = generate(grammar); + +interface Options { + /** + * Defines the API fields filtered against (currently using +contains) + * when the search query contains no operators. + * + * @example ['label', 'tags'] + */ + searchableFieldsWithoutOperator: string[]; +} + +/** + * Takes a search query and returns a valid X-Filter for Linode API v4 + */ +export function getAPIFilterFromQuery(query: string | null | undefined, options: Options) { + if (!query) { + return { filter: {}, error: null }; + } + + let filter: Filter = {}; + let error: SyntaxError | null = null; + + try { + filter = parser.parse(query, options); + } catch (e) { + error = e as SyntaxError; + } + + return { filter, error }; +} \ No newline at end of file diff --git a/packages/search/src/vite-env.d.ts b/packages/search/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/packages/search/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json new file mode 100644 index 00000000000..134d0055fe4 --- /dev/null +++ b/packages/search/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, + "incremental": true + }, + "include": ["src"], +} diff --git a/packages/validation/.changeset/pr-10471-added-1715780189054.md b/packages/validation/.changeset/pr-10471-added-1715780189054.md deleted file mode 100644 index baab53173bd..00000000000 --- a/packages/validation/.changeset/pr-10471-added-1715780189054.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Added ---- - -`tags` to `createImageSchema` ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/validation/.changeset/pr-10471-changed-1715780120037.md b/packages/validation/.changeset/pr-10471-changed-1715780120037.md deleted file mode 100644 index d107a1db4be..00000000000 --- a/packages/validation/.changeset/pr-10471-changed-1715780120037.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Changed ---- - -Improved Image `label` validation ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index ec68c31ee97..0f7aed6d817 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,15 +1,36 @@ -## [2024-05-13] - v0.46.0 +## [2024-07-08] - v0.49.0 + +### Added: + +- `createSMTPSupportTicketSchema` to support schemas ([#10557](https://github.com/linode/manager/pull/10557)) + +## [2024-06-10] - v0.48.0 + +### Added: + +- `tags` to `updateImageSchema` ([#10466](https://github.com/linode/manager/pull/10466)) +- `updateImageRegionsSchema` ([#10541](https://github.com/linode/manager/pull/10541)) + +## [2024-05-28] - v0.47.0 + +### Added: + +- `tags` to `createImageSchema` ([#10471](https://github.com/linode/manager/pull/10471)) + +### Changed: + +- Adjust DiskEncryptionSchema so it is not an object ([#10462](https://github.com/linode/manager/pull/10462)) +- Improve Image `label` validation ([#10471](https://github.com/linode/manager/pull/10471)) +## [2024-05-13] - v0.46.0 ### Changed: - Include disk_encryption in CreateLinodeSchema and RebuildLinodeSchema ([#10413](https://github.com/linode/manager/pull/10413)) - Allow `backup_id` to be nullable in `CreateLinodeSchema` ([#10421](https://github.com/linode/manager/pull/10421)) - ## [2024-04-29] - v0.45.0 - ### Changed: - Improved VPC `ip_ranges` validation in `LinodeInterfaceSchema` ([#10354](https://github.com/linode/manager/pull/10354)) @@ -31,12 +52,10 @@ ## [2024-03-18] - v0.42.0 - ### Changed: - Update TCP rules to not include a `match_condition` ([#10264](https://github.com/linode/manager/pull/10264)) - ## [2024-03-04] - v0.41.0 ### Upcoming Features: diff --git a/packages/validation/package.json b/packages/validation/package.json index 49e4a98a2f0..34d55b981b4 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.46.0", + "version": "0.49.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index bd5e64ab4da..c8b3b6f9a45 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -12,7 +12,7 @@ export const baseImageSchema = object({ label: labelSchema.notRequired(), description: string().notRequired().min(1).max(65000), cloud_init: boolean().notRequired(), - tags: array(string()).notRequired(), + tags: array(string().min(3).max(50)).max(500).notRequired(), }); export const createImageSchema = baseImageSchema.shape({ @@ -31,4 +31,11 @@ export const updateImageSchema = object({ description: string() .notRequired() .max(65000, 'Length must be 65000 characters or less.'), + tags: array(string()).notRequired(), +}); + +export const updateImageRegionsSchema = object({ + regions: array(string()) + .required('Regions are required.') + .min(1, 'Must specify at least one region.'), }); diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index f88834a0dd5..6553093ac9f 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -274,12 +274,10 @@ const PlacementGroupPayloadSchema = object({ id: number().notRequired().nullable(true), }); -const DiskEncryptionSchema = object({ - disk_encryption: string() - .oneOf(['enabled', 'disabled']) - .nullable() - .notRequired(), -}); +const DiskEncryptionSchema = string() + .oneOf(['enabled', 'disabled']) + .notRequired() + .nullable(true); export const CreateLinodeSchema = object({ type: string().ensure().required('Plan is required.'), diff --git a/packages/validation/src/support.schema.ts b/packages/validation/src/support.schema.ts index 92d60a870a7..1845fe64629 100644 --- a/packages/validation/src/support.schema.ts +++ b/packages/validation/src/support.schema.ts @@ -18,6 +18,19 @@ export const createSupportTicketSchema = object({ volume_id: number(), }); +export const createSMTPSupportTicketSchema = object({ + summary: string() + .required('Summary is required.') + .min(1, 'Summary must be between 1 and 64 characters.') + .max(64, 'Summary must be between 1 and 64 characters.') + .trim(), + description: string().trim(), + customerName: string().required('First and last name are required.'), + useCase: string().required('Use case is required.'), + emailDomains: string().required('Email domains are required.'), + publicInfo: string().required('Links to public information are required.'), +}); + export const createReplySchema = object({ description: string() .required('Description is required.') diff --git a/scripts/tod-payload/index.ts b/scripts/tod-payload/index.ts new file mode 100644 index 00000000000..7a337f4428e --- /dev/null +++ b/scripts/tod-payload/index.ts @@ -0,0 +1,67 @@ +/** + * @file Script to generate a TOD test results payload given a path containing JUnit XML files. + */ + +import { program } from 'commander'; +import * as fs from 'fs/promises'; +import { resolve } from 'path'; + +program + .name('tod-payload') + .description('Output TOD test result payload') + .version('0.1.0') + .arguments('') + .option('-n, --appName ', 'Application name') + .option('-b, --appBuild ', 'Application build identifier') + .option('-u, --appBuildUrl ', 'Application build URL') + .option('-v, --appVersion ', 'Application version') + .option('-t, --appTeam ', 'Application team name') + .option('-f, --fail', 'Treat payload as failure') + .option('-t, --tag ', 'Optional tag for run') + + .action((junitPath: string) => { + return main(junitPath); + }); + +const main = async (junitPath: string) => { + const resolvedJunitPath = resolve(junitPath); + + // Create an array of absolute file paths to JUnit XML report files. + // Account for cases where `resolvedJunitPath` is a path to a directory + // or a path to an individual JUnit file. + const junitFiles = await (async () => { + const stats = await fs.lstat(resolvedJunitPath); + if (stats.isDirectory()) { + return (await fs.readdir(resolvedJunitPath)) + .filter((dirItem: string) => { + return dirItem.endsWith('.xml') + }) + .map((dirItem: string) => { + return resolve(resolvedJunitPath, dirItem); + }); + } + return [resolvedJunitPath]; + })(); + + // Read all of the JUnit files. + const junitContents = await Promise.all(junitFiles.map((junitFile) => { + return fs.readFile(junitFile, 'utf8'); + })); + + const payload = JSON.stringify({ + team: program.opts()['appTeam'], + name: program.opts()['appName'], + buildName: program.opts()['appBuild'], + semanticVersion: program.opts()['appVersion'], + buildUrl: program.opts()['appBuildUrl'], + pass: !program.opts()['fail'], + tag: !!program.opts()['tag'] ? program.opts()['tag'] : undefined, + xunitResults: junitContents.map((junitContent) => { + return btoa(junitContent); + }), + }); + + console.log(payload); +}; + +program.parse(process.argv); diff --git a/yarn.lock b/yarn.lock index 555ff26ec05..c4094ed6d0a 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,35 +1397,28 @@ "@babel/parser" "^7.24.0" "@babel/types" "^7.24.0" -"@babel/traverse@^7.18.9", "@babel/traverse@^7.23.9", "@babel/traverse@^7.24.1", "@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== - 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" - debug "^4.3.1" - globals "^11.1.0" - -"@babel/traverse@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.5.tgz#972aa0bc45f16983bf64aa1f877b2dd0eea7e6f8" - integrity sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA== - dependencies: - "@babel/code-frame" "^7.24.2" - "@babel/generator" "^7.24.5" - "@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.24.5" - "@babel/parser" "^7.24.5" - "@babel/types" "^7.24.5" +"@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.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" @@ -1392,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" @@ -1456,6 +1522,40 @@ dependencies: cookie "^0.5.0" +"@bundled-es-modules/deepmerge@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz#e0ef866494125f64f6fb75adeffacedc25f2f31b" + integrity sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w== + dependencies: + deepmerge "^4.3.1" + +"@bundled-es-modules/glob@^10.3.13": + version "10.3.13" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/glob/-/glob-10.3.13.tgz#162af7285f224cbeacd8112754babf80adc0b732" + integrity sha512-eK+st/vwMmQy0pVvHLa2nzsS+p6NkNVR34e8qfiuzpzS1he4bMU3ODl0gbyv4r9INq5x41GqvRmFr8PtNw4yRA== + dependencies: + buffer "^6.0.3" + events "^3.3.0" + glob "^10.3.10" + patch-package "^8.0.0" + path "^0.12.7" + stream "^0.0.2" + string_decoder "^1.3.0" + url "^0.11.1" + +"@bundled-es-modules/memfs@^4.8.1": + version "4.8.1" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/memfs/-/memfs-4.8.1.tgz#0a37f5a7050eced8d03d3af81f44579548437fa6" + integrity sha512-9BodQuihWm3XJGKYuV/vXckK8Tkf9EDiT/au1NJeFUyBMe7EMYRtOqL9eLzrjqJSDJUFoGwQFHvraFHwR8cysQ== + dependencies: + assert "^2.0.0" + buffer "^6.0.3" + events "^3.3.0" + memfs "^4.8.1" + path "^0.12.7" + stream "^0.0.2" + util "^0.12.5" + "@bundled-es-modules/statuses@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz#761d10f44e51a94902c4da48675b71a76cc98872" @@ -1716,11 +1816,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9" integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg== -"@esbuild/android-arm@0.15.18": - version "0.15.18" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.18.tgz#266d40b8fdcf87962df8af05b76219bc786b4f80" - integrity sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw== - "@esbuild/android-arm@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" @@ -1811,11 +1906,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa" integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig== -"@esbuild/linux-loong64@0.15.18": - version "0.15.18" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz#128b76ecb9be48b60cf5cfc1c63a4f00691a3239" - integrity sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ== - "@esbuild/linux-loong64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" @@ -1936,18 +2026,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" @@ -2014,11 +2124,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" @@ -2057,18 +2181,6 @@ resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-1.2.1.tgz#fbc7ab3a2e5050d0c150642d5e8f5e88faa066b8" integrity sha512-xwMfkPAxeo8Ji/IxfUSqzRi0/+F2GIqJmpc5/thelgMGsjNZcjDDRBO9TLXT1s/hdx/mK5QbVIvgoLIFgXhTMQ== -"@isaacs/cliui@^8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" - integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== - dependencies: - string-width "^5.1.2" - string-width-cjs "npm:string-width@^4.2.0" - strip-ansi "^7.0.1" - strip-ansi-cjs "npm:strip-ansi@^6.0.1" - wrap-ansi "^8.1.0" - wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" - "@istanbuljs/schema@^0.1.2": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" @@ -2137,7 +2249,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== @@ -2145,7 +2257,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== @@ -2153,6 +2265,26 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jsonjoy.com/base64@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-1.1.2.tgz#cf8ea9dcb849b81c95f14fc0aaa151c6b54d2578" + integrity sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA== + +"@jsonjoy.com/json-pack@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.0.4.tgz#ab59c642a2e5368e8bcfd815d817143d4f3035d0" + integrity sha512-aOcSN4MeAtFROysrbqG137b7gaDDSmVrl5mpo6sT/w+kcXpWnzhMjmY/Fh/sDx26NBxyIE7MB1seqLeCAzy9Sg== + dependencies: + "@jsonjoy.com/base64" "^1.1.1" + "@jsonjoy.com/util" "^1.1.2" + hyperdyperid "^1.2.0" + thingies "^1.20.0" + +"@jsonjoy.com/util@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.1.3.tgz#75b1c3cf21b70e665789d1ad3eabeff8b7fd1429" + integrity sha512-g//kkF4kOwUjemValCtOc/xiYzmwMRmWq3Bn+YnzOzuZLHq2PpMOxxIayN3cKbo7Ko2Np65t6D9H81IvXbXhqg== + "@kwsites/file-exists@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" @@ -2165,6 +2297,16 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@linode/design-language-system@^2.3.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@linode/design-language-system/-/design-language-system-2.4.0.tgz#c405b98ec64adf73381e81bc46136aa8b07aab50" + integrity sha512-UNwmtYTCAC5w/Q4RbbWY/qY4dhqCbq231glWDfbacoMq3NRmT75y3MCwmsXSPt9XwkUJepGz6L/PV/Mm6MfTsA== + dependencies: + "@tokens-studio/sd-transforms" "^0.15.2" + react "^17.0.2" + react-dom "^17.0.2" + style-dictionary "4.0.0-prerelease.25" + "@linode/eslint-plugin-cloud-manager@^0.0.3": version "0.0.3" resolved "https://registry.yarnpkg.com/@linode/eslint-plugin-cloud-manager/-/eslint-plugin-cloud-manager-0.0.3.tgz#dcb78ab36065bf0fb71106a586c1f3f88dbf840a" @@ -2360,6 +2502,13 @@ dependencies: hi-base32 "^0.5.0" +"@peggyjs/from-mem@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@peggyjs/from-mem/-/from-mem-1.3.0.tgz#16470cf7dfa22fc75ca217a4e064a5f0c4e1111b" + integrity sha512-kzGoIRJjkg3KuGI4bopz9UvF3KguzfxalHRDEIdqEZUe45xezsQ6cx30e0RKuxPUexojQRBfu89Okn7f4/QXsw== + dependencies: + semver "7.6.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -3575,23 +3724,23 @@ "@tanstack/query-core" "4.36.1" use-sync-external-store "^1.2.0" -"@testing-library/cypress@^10.0.0": - version "10.0.1" - resolved "https://registry.yarnpkg.com/@testing-library/cypress/-/cypress-10.0.1.tgz#15abae0edb83237316ec6d07e152b71a50b38387" - integrity sha512-e8uswjTZIBhaIXjzEcrQQ8nHRWHgZH7XBxKuIWxZ/T7FxfWhCR48nFhUX5nfPizjVOKSThEfOSv67jquc1ASkw== +"@testing-library/cypress@^10.0.2": + version "10.0.2" + resolved "https://registry.yarnpkg.com/@testing-library/cypress/-/cypress-10.0.2.tgz#5d360f2aa43708c6c92e24765f892b09f3a58912" + integrity sha512-dKv95Bre5fDmNb9tOIuWedhGUryxGu1GWYWtXDqUsDPcr9Ekld0fiTb+pcBvSsFpYXAZSpmyEjhoXzLbhh06yQ== dependencies: "@babel/runtime" "^7.14.6" - "@testing-library/dom" "^9.0.0" + "@testing-library/dom" "^10.1.0" -"@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== +"@testing-library/dom@^10.1.0": + version "10.1.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.1.0.tgz#2d073e49771ad614da999ca48f199919e5176fb6" + integrity sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" "@types/aria-query" "^5.0.1" - aria-query "5.1.3" + aria-query "5.3.0" chalk "^4.1.0" dom-accessibility-api "^0.5.9" lz-string "^1.5.0" @@ -3611,20 +3760,37 @@ 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" resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== +"@tokens-studio/sd-transforms@^0.15.2": + version "0.15.2" + resolved "https://registry.yarnpkg.com/@tokens-studio/sd-transforms/-/sd-transforms-0.15.2.tgz#2cd374b89a1167d66a9c29c2779623103221fac7" + integrity sha512-0ryA1xdZ75cmneUZ/0UQIpzMFUyKPsfQgeu/jZguGFF7vB3/Yr+JsjGU/HFFvWtZfy0c4EQToCSHYwI0g13cBg== + dependencies: + "@tokens-studio/types" "^0.4.0" + color2k "^2.0.1" + colorjs.io "^0.4.3" + deepmerge "^4.3.1" + expr-eval-fork "^2.0.2" + is-mergeable-object "^1.1.1" + postcss-calc-ast-parser "^0.1.4" + style-dictionary "^4.0.0-prerelease.22" + +"@tokens-studio/types@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@tokens-studio/types/-/types-0.4.0.tgz#882088f22201e8f9112279f3ebacf8557213c615" + integrity sha512-rp5t0NP3Kai+Z+euGfHRUMn3AvPQ0bd9Dd2qbtfgnTvujxM5QYVr4psx/mwrVwA3NS9829mE6cD3ln+PIaptBA== + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -3867,7 +4033,7 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express@^4.17.14", "@types/express@^4.7.0": +"@types/express@^4.7.0": version "4.17.21" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== @@ -3937,11 +4103,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" @@ -3951,7 +4112,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== @@ -4078,20 +4239,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== -"@types/node@^18.0.0", "@types/node@^18.17.5": +"@types/node@^18.0.0": version "18.19.15" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.15.tgz#313a9d75435669a57fc28dc8694e7f4c4319f419" integrity sha512-AMZ2UWx+woHNfM11PyAEQmfSxi05jm9OlkxczuHeEqmvwPkYj6MWv44gbzDPefYOLysTOFyI3ziiy2ONmUZfpA== dependencies: undici-types "~5.26.4" -"@types/node@^18.11.3": - version "18.19.33" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.33.tgz#98cd286a1b8a5e11aa06623210240bcc28e95c48" - integrity sha512-NR9+KrpSajr2qBVp/Yt5TU/rp+b5Mayi3+OlMlcg2cVCfRmcG5PWZ7S4+MG9PZ5gWBoc9Pd0BKSRViuBCRPu0A== - dependencies: - undici-types "~5.26.4" - "@types/node@^20.11.26": version "20.11.27" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.27.tgz#debe5cfc8a507dd60fe2a3b4875b1604f215c2ac" @@ -4165,7 +4319,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== @@ -4269,6 +4423,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" @@ -4365,31 +4524,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== - 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== +"@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: - "@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" @@ -4402,23 +4552,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" @@ -4428,21 +4571,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" @@ -4457,19 +4618,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" @@ -4483,6 +4631,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" @@ -4504,14 +4679,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" @@ -4520,6 +4687,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" @@ -4532,72 +4707,65 @@ dependencies: "@swc/core" "^1.3.107" -"@vitest-preview/dev-utils@0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@vitest-preview/dev-utils/-/dev-utils-0.0.1.tgz#c6cbd97a37f331478e6bba5db23715d2a9fea0a1" - integrity sha512-KLr4IvFz73dMao1tCHWgwqNJfHEcGOqHaQ7SHYfumrMvs2BBD4PKMBtePO2AV7+gq4iEPuIJY8INR3Oq5EnTUw== - dependencies: - open "^8.4.0" - -"@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.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.2.2.tgz#39ea22e849bbf404b7e5272786551aa99e2663d0" - integrity sha512-3jpcdPAD7LwHUUiT2pZTj2U82I2Tcgg2oVPvKxhn6mDI2On6tfvPQTjAI4628GUGDZrCm4Zna9iQHm5cEexOAg== +"@vitest/expect@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.6.0.tgz#0b3ba0914f738508464983f4d811bc122b51fb30" + integrity sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ== dependencies: - "@vitest/spy" "1.2.2" - "@vitest/utils" "1.2.2" + "@vitest/spy" "1.6.0" + "@vitest/utils" "1.6.0" chai "^4.3.10" -"@vitest/runner@1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.2.2.tgz#8b060a56ecf8b3d607b044d79f5f50d3cd9fee2f" - integrity sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg== +"@vitest/runner@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.6.0.tgz#a6de49a96cb33b0e3ba0d9064a3e8d6ce2f08825" + integrity sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg== dependencies: - "@vitest/utils" "1.2.2" + "@vitest/utils" "1.6.0" p-limit "^5.0.0" pathe "^1.1.1" -"@vitest/snapshot@1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.2.2.tgz#f56fd575569774968f3eeba9382a166c26201042" - integrity sha512-SmGY4saEw1+bwE1th6S/cZmPxz/Q4JWsl7LvbQIky2tKE35US4gd0Mjzqfr84/4OD0tikGWaWdMja/nWL5NIPA== +"@vitest/snapshot@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.6.0.tgz#deb7e4498a5299c1198136f56e6e0f692e6af470" + integrity sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ== dependencies: magic-string "^0.30.5" pathe "^1.1.1" pretty-format "^29.7.0" -"@vitest/spy@1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.2.2.tgz#8fc2aeccb96cecbbdd192c643729bd5f97a01c86" - integrity sha512-k9Gcahssw8d7X3pSLq3e3XEu/0L78mUkCjivUqCQeXJm9clfXR/Td8+AP+VC1O6fKPIDLcHDTAmBOINVuv6+7g== +"@vitest/spy@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.6.0.tgz#362cbd42ccdb03f1613798fde99799649516906d" + integrity sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw== 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" @@ -4605,10 +4773,10 @@ 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== +"@vitest/utils@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.6.0.tgz#5c5675ca7d6f546a7b4337de9ae882e6c57896a1" + integrity sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw== dependencies: diff-sequences "^29.6.3" estree-walker "^3.0.3" @@ -4648,6 +4816,11 @@ resolved "https://registry.yarnpkg.com/@zeit/schemas/-/schemas-2.29.0.tgz#a59ae6ebfdf4ddc66a876872dd736baa58b6696c" integrity sha512-g5QiLIfbg3pLuYUJPlisNKY+epQJTcMDsOnVNkscrDP1oi7vmJnzOANYJI/1pZcVJ6umUkBv3aFtlg1UvUHGzA== +"@zip.js/zip.js@^2.7.44": + version "2.7.45" + resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.7.45.tgz#823fe2789401d8c1d836ce866578379ec1bd6f0b" + integrity sha512-Mm2EXF33DJQ/3GWWEWeP1UCqzpQ5+fiMvT3QWspsXY05DyqqxWu7a9awSzU4/spHMHVFrTjani1PR0vprgZpow== + abab@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -4676,12 +4849,12 @@ 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== -acorn@^8.10.0, acorn@^8.11.3, acorn@^8.4.1, acorn@^8.9.0: +acorn@^8.11.3, acorn@^8.4.1, acorn@^8.9.0: version "8.11.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== @@ -4726,6 +4899,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" @@ -4873,14 +5056,7 @@ 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.0.0, aria-query@^5.3.0: +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" integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== @@ -5363,7 +5539,7 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^5.5.0, buffer@^5.6.0: +buffer@^5.5.0, buffer@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -5371,6 +5547,14 @@ buffer@^5.5.0, buffer@^5.6.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + bundle-require@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-4.0.2.tgz#65fc74ff14eabbba36d26c9a6161bd78fff6b29e" @@ -5408,6 +5592,17 @@ call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6: get-intrinsic "^1.2.3" set-function-length "^1.2.0" +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + caller-callsite@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" @@ -5432,11 +5627,6 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camelcase@^5.0.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - camelcase@^6.2.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" @@ -5518,7 +5708,7 @@ chalk@5.0.1: resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.0.1.tgz#ca57d71e82bb534a296df63bbacc4a1c22b2a4b6" integrity sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w== -chalk@5.3.0, chalk@^5.0.1, chalk@^5.2.0: +chalk@5.3.0, chalk@^5.0.1, chalk@^5.2.0, chalk@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== @@ -5548,6 +5738,11 @@ chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +change-case@^5.3.0: + version "5.4.4" + resolved "https://registry.yarnpkg.com/change-case/-/change-case-5.4.4.tgz#0d52b507d8fb8f204343432381d1a6d7bff97a02" + integrity sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w== + change-emitter@^0.1.2: version "0.1.6" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" @@ -5789,11 +5984,21 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color2k@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.3.tgz#a771244f6b6285541c82aa65ff0a0c624046e533" + integrity sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog== + colorette@^2.0.16, colorette@^2.0.20: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorjs.io@^0.4.3: + version "0.4.5" + resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.4.5.tgz#7775f787ff90aca7a38f6edb7b7c0f8cce1e6418" + integrity sha512-yCtUNCmge7llyfd/Wou19PMAcf5yC3XXhgFoAh6zsO2pGswhUPBaaUh8jzgHnXtXuZyFKzXZNAnyF5i+apICow== + combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -5806,6 +6011,11 @@ commander@11.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + commander@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" @@ -5816,6 +6026,11 @@ commander@^6.2.1: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== +commander@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + common-tags@^1.8.0: version "1.8.2" resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" @@ -6101,20 +6316,20 @@ csstype@^3.0.2, csstype@^3.1.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== -cypress-axe@^1.0.0: +cypress-axe@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/cypress-axe/-/cypress-axe-1.5.0.tgz#95082734583da77b51ce9b7784e14a442016c7a1" integrity sha512-Hy/owCjfj+25KMsecvDgo4fC/781ccL+e8p+UUYoadGVM2ogZF9XIKbiM6KI8Y3cEaSreymdD6ZzccbI2bY0lQ== -cypress-file-upload@^5.0.7: +cypress-file-upload@^5.0.8: version "5.0.8" resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1" integrity sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g== -cypress-real-events@^1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.11.0.tgz#292fe5281c5b6e955524e766ab7fec46930c7763" - integrity sha512-4LXVRsyq+xBh5TmlEyO1ojtBXtN7xw720Pwb9rEE9rkJuXmeH3VyoR1GGayMGr+Itqf11eEjfDewtDmcx6PWPQ== +cypress-real-events@^1.12.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.12.0.tgz#ffeb2b23686ba5b16ac91dd9bc3b6785d36d38d3" + integrity sha512-oiy+4kGKkzc2PT36k3GGQqkGxNiVypheWjMtfyi89iIk6bYmTzeqxapaLHS3pnhZOX1IEbTDUVxh8T4Nhs1tyQ== cypress-vite@^1.5.0: version "1.5.0" @@ -6124,20 +6339,19 @@ cypress-vite@^1.5.0: chokidar "^3.5.3" debug "^4.3.4" -cypress@13.5.0: - version "13.5.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.5.0.tgz#8c149074186130972f08b2cdce6ded41f014bacd" - integrity sha512-oh6U7h9w8wwHfzNDJQ6wVcAeXu31DlIYlNOBvfd6U4CcB8oe4akawQmH+QJVOMZlM42eBoCne015+svVqdwdRQ== +cypress@13.11.0: + version "13.11.0" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.11.0.tgz#17097366390424cba5db6bf0ee5e97503f036e07" + integrity sha512-NXXogbAxVlVje4XHX+Cx5eMFZv4Dho/2rIcdBHg9CNPFUGZdM4cRdgIgM7USmNYsC12XY0bZENEQ+KBk72fl+A== dependencies: "@cypress/request" "^3.0.0" "@cypress/xvfb" "^1.2.4" - "@types/node" "^18.17.5" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" arch "^2.2.0" blob-util "^2.0.2" bluebird "^3.7.2" - buffer "^5.6.0" + buffer "^5.7.1" cachedir "^2.3.0" chalk "^4.1.0" check-more-types "^2.24.0" @@ -6155,7 +6369,7 @@ cypress@13.5.0: figures "^3.2.0" fs-extra "^9.1.0" getos "^3.2.1" - is-ci "^3.0.0" + is-ci "^3.0.1" is-installed-globally "~0.4.0" lazy-ass "^1.6.0" listr2 "^3.8.3" @@ -6325,30 +6539,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" @@ -6364,6 +6554,11 @@ deepmerge@^2.1.1: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + default-browser-id@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-3.0.0.tgz#bee7bbbef1f4e75d31f98f4d3f1556a14cea790c" @@ -6389,6 +6584,15 @@ define-data-property@^1.0.1, define-data-property@^1.1.2: gopd "^1.0.1" has-property-descriptors "^1.0.1" +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" @@ -6608,6 +6812,11 @@ electron-to-chromium@^1.4.668: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.767.tgz#b885cfefda5a2e7a7ee356c567602012294ed260" integrity sha512-nzzHfmQqBss7CE3apQHkHjXW77+8w3ubGCIoEijKCJebPufREaFETgGXWTkh32t259F3Kcq+R8MZdFdOJROgYw== +emitter-component@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.2.tgz#d65af5833dc7c682fd0ade35f902d16bc4bad772" + integrity sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw== + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -6628,13 +6837,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -encoding@^0.1.11: - version "0.1.13" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" - integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== - dependencies: - iconv-lite "^0.6.2" - end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -6642,7 +6844,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== @@ -6717,26 +6919,18 @@ es-abstract@^1.22.1, es-abstract@^1.22.3: unbox-primitive "^1.0.2" which-typed-array "^1.1.13" +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + es-errors@^1.0.0, es-errors@^1.1.0, es-errors@^1.2.1, es-errors@^1.3.0: version "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" @@ -6787,86 +6981,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -esbuild-android-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz#20a7ae1416c8eaade917fb2453c1259302c637a5" - integrity sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA== - -esbuild-android-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz#9cc0ec60581d6ad267568f29cf4895ffdd9f2f04" - integrity sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ== - -esbuild-darwin-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz#428e1730ea819d500808f220fbc5207aea6d4410" - integrity sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg== - -esbuild-darwin-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz#b6dfc7799115a2917f35970bfbc93ae50256b337" - integrity sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA== - -esbuild-freebsd-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz#4e190d9c2d1e67164619ae30a438be87d5eedaf2" - integrity sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA== - -esbuild-freebsd-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz#18a4c0344ee23bd5a6d06d18c76e2fd6d3f91635" - integrity sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA== - -esbuild-linux-32@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz#9a329731ee079b12262b793fb84eea762e82e0ce" - integrity sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg== - -esbuild-linux-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz#532738075397b994467b514e524aeb520c191b6c" - integrity sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw== - -esbuild-linux-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz#5372e7993ac2da8f06b2ba313710d722b7a86e5d" - integrity sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug== - -esbuild-linux-arm@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz#e734aaf259a2e3d109d4886c9e81ec0f2fd9a9cc" - integrity sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA== - -esbuild-linux-mips64le@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz#c0487c14a9371a84eb08fab0e1d7b045a77105eb" - integrity sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ== - -esbuild-linux-ppc64le@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz#af048ad94eed0ce32f6d5a873f7abe9115012507" - integrity sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w== - -esbuild-linux-riscv64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz#423ed4e5927bd77f842bd566972178f424d455e6" - integrity sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg== - -esbuild-linux-s390x@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz#21d21eaa962a183bfb76312e5a01cc5ae48ce8eb" - integrity sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ== - -esbuild-netbsd-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz#ae75682f60d08560b1fe9482bfe0173e5110b998" - integrity sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg== - -esbuild-openbsd-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz#79591a90aa3b03e4863f93beec0d2bab2853d0a8" - integrity sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ== - esbuild-plugin-alias@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/esbuild-plugin-alias/-/esbuild-plugin-alias-0.2.1.tgz#45a86cb941e20e7c2bc68a2bea53562172494fcb" @@ -6879,54 +6993,6 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -esbuild-sunos-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz#fd528aa5da5374b7e1e93d36ef9b07c3dfed2971" - integrity sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw== - -esbuild-windows-32@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz#0e92b66ecdf5435a76813c4bc5ccda0696f4efc3" - integrity sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ== - -esbuild-windows-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz#0fc761d785414284fc408e7914226d33f82420d0" - integrity sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw== - -esbuild-windows-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz#5b5bdc56d341d0922ee94965c89ee120a6a86eb7" - integrity sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ== - -esbuild@^0.15.9: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.18.tgz#ea894adaf3fbc036d32320a00d4d6e4978a2f36d" - integrity sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q== - optionalDependencies: - "@esbuild/android-arm" "0.15.18" - "@esbuild/linux-loong64" "0.15.18" - esbuild-android-64 "0.15.18" - esbuild-android-arm64 "0.15.18" - esbuild-darwin-64 "0.15.18" - esbuild-darwin-arm64 "0.15.18" - esbuild-freebsd-64 "0.15.18" - esbuild-freebsd-arm64 "0.15.18" - esbuild-linux-32 "0.15.18" - esbuild-linux-64 "0.15.18" - esbuild-linux-arm "0.15.18" - esbuild-linux-arm64 "0.15.18" - esbuild-linux-mips64le "0.15.18" - esbuild-linux-ppc64le "0.15.18" - esbuild-linux-riscv64 "0.15.18" - esbuild-linux-s390x "0.15.18" - esbuild-netbsd-64 "0.15.18" - esbuild-openbsd-64 "0.15.18" - esbuild-sunos-64 "0.15.18" - esbuild-windows-32 "0.15.18" - esbuild-windows-64 "0.15.18" - esbuild-windows-arm64 "0.15.18" - "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0": version "0.20.2" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.20.2.tgz#9d6b2386561766ee6b5a55196c6d766d28c87ea1" @@ -7190,21 +7256,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== @@ -7306,8 +7365,54 @@ eslint@^6.8.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^6.1.2: - version "6.2.1" +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" integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw== dependencies: @@ -7315,6 +7420,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" @@ -7329,7 +7443,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== @@ -7390,6 +7504,11 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" @@ -7470,7 +7589,12 @@ executable@^4.1.1: dependencies: pify "^2.2.0" -express@^4.17.3, express@^4.18.2: +expr-eval-fork@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expr-eval-fork/-/expr-eval-fork-2.0.2.tgz#97136ac0a8178522055500f55d3d3c5ad54f400d" + integrity sha512-NaAnObPVwHEYrODd7Jzp3zzT9pgTAlUUL4MZiZu9XAYPDpx89cPsfyEImFb2XY0vQNbrqg2CG7CLiI+Rs3seaQ== + +express@^4.17.3: version "4.19.2" resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== @@ -8060,20 +8184,13 @@ github-slugger@^2.0.0: resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a" integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== -glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@^6.0.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - glob-promise@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-4.2.2.tgz#15f44bcba0e14219cd93af36da6bb905ff007877" @@ -8128,7 +8245,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== @@ -8244,6 +8361,13 @@ has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.1: dependencies: get-intrinsic "^1.2.2" +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + has-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" @@ -8343,10 +8467,12 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react- dependencies: react-is "^16.7.0" -hosted-git-info@^2.1.4: - version "2.8.9" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" - integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== +hosted-git-info@^2.1.4, hosted-git-info@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-5.2.1.tgz#0ba1c97178ef91f3ab30842ae63d6a272341156f" + integrity sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw== + dependencies: + lru-cache "^7.5.1" html-encoding-sniffer@^3.0.0: version "3.0.0" @@ -8447,6 +8573,11 @@ husky@^3.0.1: run-node "^1.0.0" slash "^3.0.0" +hyperdyperid@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hyperdyperid/-/hyperdyperid-1.2.0.tgz#59668d323ada92228d2a869d3e474d5a33b69e6b" + integrity sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A== + iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -8454,14 +8585,14 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.6.3, iconv-lite@^0.6.2: +iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -8471,7 +8602,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== @@ -8520,6 +8651,11 @@ inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, i resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== + ini@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" @@ -8554,7 +8690,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== @@ -8600,7 +8736,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== @@ -8665,7 +8801,7 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-ci@^3.0.0: +is-ci@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" integrity sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ== @@ -8767,11 +8903,16 @@ 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== +is-mergeable-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-mergeable-object/-/is-mergeable-object-1.1.1.tgz#faaa3ed1cfce87d6f7d2f5885e92cc30af3e2ebf" + integrity sha512-CPduJfuGg8h8vW74WOxHtHmtQutyQBzR+3MjQ6iDHIYdbOnm1YC7jv43SqCoU8OPGTJD4nibmiryA4kmogbGrA== + is-nan@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" @@ -8812,7 +8953,7 @@ is-path-inside@^3.0.2, is-path-inside@^3.0.3: resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== -is-plain-obj@^4.0.0: +is-plain-obj@^4.0.0, is-plain-obj@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== @@ -8847,7 +8988,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== @@ -8859,7 +9000,7 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bind "^1.0.2" -is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== @@ -8989,14 +9130,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" @@ -9017,12 +9158,12 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" -jackspeak@^2.3.5: - version "2.3.6" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" - integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== +jackspeak@2.1.1, jackspeak@^2.3.5: + version "2.1.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.1.1.tgz#2a42db4cfbb7e55433c28b6f75d8b796af9669cd" + integrity sha512-juf9stUEwUaILepraGOWIJTLwg48bUnBmRqd2ln2Os1sW987zeoj/hzhbvRB95oMuS2ZTpjULmdwHNX4rzZIZw== dependencies: - "@isaacs/cliui" "^8.0.2" + cliui "^8.0.1" optionalDependencies: "@pkgjs/parseargs" "^0.11.0" @@ -9046,6 +9187,11 @@ joycon@^3.0.1: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-tokens@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.0.tgz#0f893996d6f3ed46df7f0a3b12a03f5fd84223c1" + integrity sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ== + js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -9166,6 +9312,16 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-stable-stringify@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz#52d4361b47d49168bcc4e564189a42e5a7439454" + integrity sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg== + dependencies: + call-bind "^1.0.5" + isarray "^2.0.5" + jsonify "^0.0.1" + object-keys "^1.1.1" + json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -9190,6 +9346,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonify@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" + integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== + jspdf-autotable@^3.5.14: version "3.8.1" resolved "https://registry.yarnpkg.com/jspdf-autotable/-/jspdf-autotable-3.8.1.tgz#e4d9b62356a412024e8f08e84fdeb5b85e1383b5" @@ -9246,7 +9407,7 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" -kind-of@^6.0.2: +kind-of@^6.0.2, kind-of@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -9512,6 +9673,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" @@ -9584,12 +9750,10 @@ 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" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== "lru-cache@^9.1.1 || ^10.0.0": version "10.2.0" @@ -9839,6 +10003,16 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" +memfs@^4.8.1: + version "4.9.2" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.9.2.tgz#42e7b48207268dad8c9c48ea5d4952c5d3840433" + integrity sha512-f16coDZlTG1jskq3mxarwB+fGRrd0uXWt+o1WIhRfOwbXQZqUDsTVxQBFK9JjRQHblg8eAG2JSbprDXKjc7ijQ== + dependencies: + "@jsonjoy.com/json-pack" "^1.0.3" + "@jsonjoy.com/util" "^1.1.2" + sonic-forest "^1.0.0" + tslib "^2.0.0" + memoize-one@^5.0.0, memoize-one@^5.1.1: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" @@ -10208,6 +10382,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" @@ -10215,14 +10396,7 @@ 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.5, minimist@^1.2.6, minimist@^1.2.8: +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" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -10417,15 +10591,7 @@ node-fetch-native@^1.6.1: resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.2.tgz#f439000d972eb0c8a741b65dcda412322955e1c6" integrity sha512-69mtXOFZ6hSkYiXAVB5SqaRvrbITC/NPyqv7yuu/qw0nmgPyYbIMYYNIDhNtwPrzk0ptrimrLz/hhjvm4w5Z+w== -node-fetch@^1.0.1: - version "1.7.3" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" - integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== - dependencies: - encoding "^0.1.11" - is-stream "^1.0.1" - -node-fetch@^2.0.0: +node-fetch@^1.0.1, node-fetch@^2.0.0, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -10656,6 +10822,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" @@ -10838,6 +11016,27 @@ patch-package@^7.0.0: tmp "^0.0.33" yaml "^2.2.2" +patch-package@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.0.tgz#d191e2f1b6e06a4624a0116bcb88edd6714ede61" + integrity sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^4.1.2" + ci-info "^3.7.0" + cross-spawn "^7.0.3" + find-yarn-workspace-root "^2.0.0" + fs-extra "^9.0.0" + json-stable-stringify "^1.0.2" + klaw-sync "^6.0.0" + minimist "^1.2.6" + open "^7.4.2" + rimraf "^2.6.3" + semver "^7.5.3" + slash "^2.0.0" + tmp "^0.0.33" + yaml "^2.2.2" + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -10925,6 +11124,19 @@ path-type@^5.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-5.0.0.tgz#14b01ed7aea7ddf9c7c3f46181d4d04f9c785bb8" integrity sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg== +path-unified@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/path-unified/-/path-unified-0.1.0.tgz#fd751e787ab019a88cdf5cecbd7e5e4711c66c7d" + integrity sha512-/Oaz9ZJforrkmFrwkR/AcvjVsCAwGSJHO0X6O6ISj8YeFbATjIEBXLDcZfnK3MO4uvCBrJTdVIxdOc79PMqSdg== + +path@^0.12.7: + version "0.12.7" + resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f" + integrity sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q== + dependencies: + process "^0.11.1" + util "^0.10.3" + pathe@^1.1.0, pathe@^1.1.1, pathe@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" @@ -10944,6 +11156,15 @@ peek-stream@^1.1.0: duplexify "^3.5.0" through2 "^2.0.3" +peggy@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/peggy/-/peggy-4.0.3.tgz#7bcd47718483ab405c960350c5250e3e487dec74" + integrity sha512-v7/Pt6kGYsfXsCrfb52q7/yg5jaAwiVaUMAPLPvy4DJJU6Wwr72t6nDIqIDkGfzd1B4zeVuTnQT0RGeOhe/uSA== + dependencies: + "@peggyjs/from-mem" "1.3.0" + commander "^12.1.0" + source-map-generator "0.8.0" + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -11038,6 +11259,13 @@ polished@^4.2.2: dependencies: "@babel/runtime" "^7.17.8" +postcss-calc-ast-parser@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.4.tgz#9aeee3650a91c0b2902789689bc044c9f83bc447" + integrity sha512-CebpbHc96zgFjGgdQ6BqBy6XIUgRx1xXWCAAk6oke02RZ5nxwo9KQejTg8y7uYEeI9kv8jKQPYjoe6REsY23vw== + dependencies: + postcss-value-parser "^3.3.1" + postcss-load-config@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3" @@ -11046,14 +11274,10 @@ postcss-load-config@^4.0.1: lilconfig "^3.0.0" yaml "^2.3.4" -postcss@^8.4.18: - version "8.4.38" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" - integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== - dependencies: - nanoid "^3.3.7" - picocolors "^1.0.0" - source-map-js "^1.2.0" +postcss-value-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" + integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== postcss@^8.4.35: version "8.4.35" @@ -11135,7 +11359,7 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -process@^0.11.10: +process@^0.11.1, process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== @@ -11232,7 +11456,7 @@ pumpify@^1.3.3: inherits "^2.0.3" pump "^2.0.0" -punycode@^1.3.2: +punycode@^1.3.2, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== @@ -11276,6 +11500,13 @@ qs@^6.10.0: dependencies: side-channel "^1.0.4" +qs@^6.11.2: + version "6.12.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.1.tgz#39422111ca7cbdb70425541cba20c7d7b216599a" + integrity sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ== + dependencies: + side-channel "^1.0.6" + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -11390,6 +11621,15 @@ react-docgen@^7.0.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-dom@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + react-dropzone@~11.2.0: version "11.2.4" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.2.4.tgz#391a8d2e41a8a974340f83524d306540192e3313" @@ -11609,6 +11849,14 @@ react-waypoint@^10.3.0: dependencies: loose-envify "^1.1.0" +react@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + read-pkg-up@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" @@ -12074,13 +12322,6 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup@^2.79.1: - version "2.79.1" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" - integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== - optionalDependencies: - fsevents "~2.3.2" - rollup@^4.0.2, rollup@^4.2.0: version "4.9.6" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.9.6.tgz#4515facb0318ecca254a2ee1315e22e09efc50a0" @@ -12206,6 +12447,14 @@ scheduler@^0.19.1: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" @@ -12223,22 +12472,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: - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - -semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, 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@7.6.0, 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" @@ -12317,6 +12554,18 @@ set-function-length@^1.2.0: gopd "^1.0.1" has-property-descriptors "^1.0.1" +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + set-function-name@^2.0.0, set-function-name@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" @@ -12382,6 +12631,16 @@ side-channel@^1.0.4: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" @@ -12478,16 +12737,23 @@ snake-case@^3.0.4: dot-case "^3.0.4" tslib "^2.0.3" +sonic-forest@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sonic-forest/-/sonic-forest-1.0.3.tgz#81363af60017daba39b794fce24627dc412563cb" + integrity sha512-dtwajos6IWMEWXdEbW1IkEkyL2gztCAgDplRIX+OT5aRKnEd5e7r7YCxRgXZdhRP1FBdOBf8axeTPhzDv8T4wQ== + dependencies: + tree-dump "^1.0.0" + +source-map-generator@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/source-map-generator/-/source-map-generator-0.8.0.tgz#10d5ca0651e2c9302ea338739cbd4408849c5d00" + integrity sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA== + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== -source-map-js@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" - integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== - source-map-support@^0.5.16, source-map-support@^0.5.19: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" @@ -12589,13 +12855,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" @@ -12627,6 +12886,13 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== +stream@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef" + integrity sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g== + dependencies: + emitter-component "^1.1.1" + strict-event-emitter@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" @@ -12637,15 +12903,6 @@ string-argv@0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -12672,6 +12929,15 @@ string-width@^3.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -12732,7 +12998,7 @@ string.prototype.trimstart@^1.0.7: define-properties "^1.2.0" es-abstract "^1.22.1" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== @@ -12746,13 +13012,6 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^3.0.0, strip-ansi@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" @@ -12774,6 +13033,13 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -12815,7 +13081,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== @@ -12825,12 +13091,48 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== -strip-literal@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-1.3.0.tgz#db3942c2ec1699e6836ad230090b84bb458e3a07" - integrity sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg== - dependencies: - acorn "^8.10.0" +strip-literal@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-2.1.0.tgz#6d82ade5e2e74f5c7e8739b6c84692bd65f0bd2a" + integrity sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw== + dependencies: + js-tokens "^9.0.0" + +style-dictionary@4.0.0-prerelease.25: + version "4.0.0-prerelease.25" + resolved "https://registry.yarnpkg.com/style-dictionary/-/style-dictionary-4.0.0-prerelease.25.tgz#df3d552e4324a277c13880e377f6be756db6db61" + integrity sha512-1dqKBBSvGbXPH2WFLUqqZBrmLnuNyXRkUOG1SEGJ0vDVrx+o4guOcx5aIBI9sLz2pyL7B8Yo0r4FizltFPi9WA== + dependencies: + "@bundled-es-modules/deepmerge" "^4.3.1" + "@bundled-es-modules/glob" "^10.3.13" + "@bundled-es-modules/memfs" "^4.8.1" + chalk "^5.3.0" + change-case "^5.3.0" + commander "^8.3.0" + is-plain-obj "^4.1.0" + json5 "^2.2.2" + lodash-es "^4.17.21" + patch-package "^8.0.0" + path-unified "^0.1.0" + tinycolor2 "^1.6.0" + +style-dictionary@^4.0.0-prerelease.22: + version "4.0.0-prerelease.35" + resolved "https://registry.yarnpkg.com/style-dictionary/-/style-dictionary-4.0.0-prerelease.35.tgz#3085de0d9212b56be2c9ed0c1c51de8005a4e68f" + integrity sha512-03e05St/a9XdorK0pN30zprI7J8rrRDnGCiga4Do2rjbR3jfKEKSvtUe6Inl/HQBZXm0RBFrMhVGX9MF1P2sdw== + dependencies: + "@bundled-es-modules/deepmerge" "^4.3.1" + "@bundled-es-modules/glob" "^10.3.13" + "@bundled-es-modules/memfs" "^4.8.1" + "@zip.js/zip.js" "^2.7.44" + chalk "^5.3.0" + change-case "^5.3.0" + commander "^8.3.0" + is-plain-obj "^4.1.0" + json5 "^2.2.2" + patch-package "^8.0.0" + path-unified "^0.1.0" + tinycolor2 "^1.6.0" stylis@4.2.0: version "4.2.0" @@ -12920,6 +13222,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" @@ -13018,6 +13331,11 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +thingies@^1.20.0: + version "1.21.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.21.0.tgz#e80fbe58fd6fdaaab8fad9b67bd0a5c943c445c1" + integrity sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g== + throttle-debounce@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2" @@ -13061,10 +13379,15 @@ tinybench@^2.5.1: resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.6.0.tgz#1423284ee22de07c91b3752c048d2764714b341b" integrity sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA== -tinypool@^0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.2.tgz#84013b03dc69dacb322563a475d4c0a9be00f82a" - integrity sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ== +tinycolor2@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" + integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== + +tinypool@^0.8.3: + version "0.8.4" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.4.tgz#e217fe1270d941b39e98c625dcecebb1408c9aa8" + integrity sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ== tinyspy@^2.2.0: version "2.2.1" @@ -13151,6 +13474,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +tree-dump@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.1.tgz#b448758da7495580e6b7830d6b7834fca4c45b96" + integrity sha512-WCkcRBVPSlHHq1dc/px9iOfqklvzCbdRwvlNfxGZsrHqf6aZttfPrd7DJTt6oR10dwUfpFFQeVTkPbBIZxX/YA== + tree-kill@^1.2.1, tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -13161,6 +13489,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" @@ -13373,15 +13706,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: - 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== +ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: + 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" @@ -13538,7 +13871,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== @@ -13553,6 +13886,14 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +url@^0.11.1: + version "0.11.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.3.tgz#6f495f4b935de40ce4a0a52faee8954244f3d3ad" + integrity sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw== + dependencies: + punycode "^1.4.1" + qs "^6.11.2" + use-callback-ref@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.2.tgz#6134c7f6ff76e2be0b56c809b17a650c942b1693" @@ -13583,6 +13924,13 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +util@^0.10.3: + version "0.10.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" + integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A== + dependencies: + inherits "2.0.3" + util@^0.12.4, util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" @@ -13626,15 +13974,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" @@ -13699,10 +14038,10 @@ victory-vendor@^36.6.8: d3-time "^3.0.0" d3-timer "^3.0.1" -vite-node@1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.2.2.tgz#f6d329b06f9032130ae6eac1dc773f3663903c25" - integrity sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg== +vite-node@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.6.0.tgz#2c7e61129bfecc759478fa592754fd9704aaba7f" + integrity sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw== dependencies: cac "^6.7.14" debug "^4.3.4" @@ -13719,18 +14058,6 @@ vite-plugin-svgr@^3.2.0: "@svgr/core" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0" -vite@^3.0.0: - version "3.2.10" - resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.10.tgz#7ac79fead82cfb6b5bf65613cd82fba6dcc81340" - integrity sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw== - dependencies: - esbuild "^0.15.9" - postcss "^8.4.18" - resolve "^1.22.1" - rollup "^2.79.1" - optionalDependencies: - fsevents "~2.3.2" - vite@^5.0.0, vite@^5.1.7: version "5.1.7" resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.7.tgz#9f685a2c4c70707fef6d37341b0e809c366da619" @@ -13742,29 +14069,17 @@ vite@^5.0.0, vite@^5.1.7: optionalDependencies: fsevents "~2.3.3" -vitest-preview@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/vitest-preview/-/vitest-preview-0.0.1.tgz#ae53da961082ff20a8dec5deba8484f36b120268" - integrity sha512-rKh+rzW54HYfgYjCU/9n8t0V8rnxYiH67uJGYUKKqW5L87Cl8NESDzNe2BbD6WmNvM4ojQdc0VqLXv6QsDt1Jw== - dependencies: - "@types/express" "^4.17.14" - "@types/node" "^18.11.3" - "@vitest-preview/dev-utils" "0.0.1" - express "^4.18.2" - vite "^3.0.0" - -vitest@^1.0.1, vitest@^1.2.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.2.2.tgz#9e29ad2a74a5df553c30c5798c57a062d58ce299" - integrity sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw== - dependencies: - "@vitest/expect" "1.2.2" - "@vitest/runner" "1.2.2" - "@vitest/snapshot" "1.2.2" - "@vitest/spy" "1.2.2" - "@vitest/utils" "1.2.2" +vitest@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.6.0.tgz#9d5ad4752a3c451be919e412c597126cffb9892f" + integrity sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA== + dependencies: + "@vitest/expect" "1.6.0" + "@vitest/runner" "1.6.0" + "@vitest/snapshot" "1.6.0" + "@vitest/spy" "1.6.0" + "@vitest/utils" "1.6.0" acorn-walk "^8.3.2" - cac "^6.7.14" chai "^4.3.10" debug "^4.3.4" execa "^8.0.1" @@ -13773,11 +14088,11 @@ vitest@^1.0.1, vitest@^1.2.0: pathe "^1.1.1" picocolors "^1.0.0" std-env "^3.5.0" - strip-literal "^1.3.0" + strip-literal "^2.0.0" tinybench "^2.5.1" - tinypool "^0.8.2" + tinypool "^0.8.3" vite "^5.0.0" - vite-node "1.2.2" + vite-node "1.6.0" why-is-node-running "^2.2.2" w3c-xmlserializer@^4.0.0: @@ -13960,7 +14275,7 @@ widest-line@^4.0.1: dependencies: string-width "^5.0.1" -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== @@ -13970,15 +14285,6 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" @@ -13996,6 +14302,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -14089,30 +14404,12 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" - integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== - -yaml@^1.10.0, yaml@^1.7.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== - -yaml@^2.2.2, yaml@^2.3.4: - version "2.4.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed" - integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg== - -yargs-parser@^11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" - integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" +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.5" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.5.tgz#60630b206dd6d84df97003d33fc1ddf6296cca5e" + integrity sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg== -yargs-parser@^21.1.1: +yargs-parser@^11.1.1, yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==