diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6508896b7a..4e2590d5c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: "linode-manager", "@linode/api-v4", "@linode/queries", + "@linode/shared", "@linode/ui", "@linode/utilities", "@linode/validation", @@ -233,6 +234,30 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/queries test + test-shared: + runs-on: ubuntu-latest + needs: build-sdk + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.17" + cache: "pnpm" + - uses: actions/download-artifact@v4 + with: + name: packages-api-v4-lib + path: packages/api-v4/lib + - uses: actions/download-artifact@v4 + with: + name: packages-validation-lib + path: packages/validation/lib + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/shared test + typecheck-ui: runs-on: ubuntu-latest needs: build-sdk @@ -289,6 +314,30 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/queries typecheck + typecheck-shared: + runs-on: ubuntu-latest + needs: build-sdk + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.17" + cache: "pnpm" + - uses: actions/download-artifact@v4 + with: + name: packages-api-v4-lib + path: packages/api-v4/lib + - uses: actions/download-artifact@v4 + with: + name: packages-validation-lib + path: packages/validation/lib + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/shared typecheck + typecheck-manager: runs-on: ubuntu-latest needs: build-sdk @@ -342,7 +391,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} - run: pnpm publish -r --filter @linode/api-v4 --filter @linode/validation --no-git-checks --access public - name: slack-notify uses: rtCamp/action-slack-notify@master diff --git a/.gitignore b/.gitignore index 7e58fb3e68e..fe569e46f54 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,7 @@ packages/manager/test-report.xml **/manager/cypress/downloads/ **/manager/cypress/results/ **/manager/cypress/screenshots/ +**/manager/cypress/reports/ packages/manager/cypress/fixtures/example.json diff --git a/README.md b/README.md index cc7d036486a..991b48cfd68 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ This repository is home to the Akamai Connected **[Cloud Manager](https://cloud. - [`@linode/api-v4`](packages/api-v4/) - [`@linode/queries`](packages/queries/) - [`@linode/search`](packages/search/) +- [`@linode/shared`](packages/shared/) - [`@linode/ui`](packages/ui/) - [`@linode/utilities`](packages/utilities/) - [`@linode/validation`](packages/validation/) diff --git a/docker-compose.yml b/docker-compose.yml index d7ccac0bb01..f37da3eaccd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,7 @@ x-e2e-env: # Cypress reporting. CY_TEST_JUNIT_REPORT: ${CY_TEST_JUNIT_REPORT} + CY_TEST_HTML_REPORT: ${CY_TEST_HTML_REPORT} CY_TEST_USER_REPORT: ${CY_TEST_USER_REPORT} # Cloud Manager build environment. diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 8d51f0c4208..ac2e8fe9db4 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -210,6 +210,7 @@ Environment variables related to Cypress logging and reporting, as well as repor |---------------------------------|----------------------------------------------------|------------------|----------------------------| | `CY_TEST_USER_REPORT` | Log test account information when tests begin | `1` | Unset; disabled by default | | `CY_TEST_JUNIT_REPORT` | Enable JUnit reporting | `1` | Unset; disabled by default | +| `CY_TEST_HTML_REPORT` | Generate html report containing E2E test results | `1` | Unset; disabled by default | | `CY_TEST_DISABLE_FILE_WATCHING` | Disable file watching in Cypress UI | `1` | Unset; disabled by default | | `CY_TEST_DISABLE_RETRIES` | Disable test retries on failure in CI | `1` | Unset; disabled by default | | `CY_TEST_FAIL_ON_MANAGED` | Fail affected tests when Managed is enabled | `1` | Unset; disabled by default | diff --git a/docs/tooling/analytics.md b/docs/tooling/analytics.md index e9d91cb86e2..3cd5f883d7b 100644 --- a/docs/tooling/analytics.md +++ b/docs/tooling/analytics.md @@ -14,7 +14,6 @@ Important notes: - Pendo is only loaded if the user has enabled Performance Cookies via OneTrust *and* if a valid `PENDO_API_KEY` is configured as an environment variable. In our development, staging, and production environments, `PENDO_API_KEY` is available at build time. See **Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo** for set up with local environments. - We load the Pendo agent from the CDN, rather than [self-hosting](https://support.pendo.io/hc/en-us/articles/360038969692-Self-hosting-the-Pendo-agent), and we have configured a [CNAME](https://support.pendo.io/hc/en-us/articles/360043539891-CNAME-for-Pendo). -- We are hashing account and visitor IDs in a way that is consistent with Akamai's standards. - At initialization, we do string transformation on select URL patterns to **remove sensitive data**. When new URL patterns are added to Cloud Manager, verify that existing transforms remove sensitive data; if not, update the transforms. - Pendo will respect OneTrust cookie preferences in development, staging, and production environments and does not check cookie preferences in the local environment. - Pendo makes use of the existing `data-testid` properties, used in our automated testing, for tagging elements. They are more persistent and reliable than CSS properties, which are liable to change. @@ -24,7 +23,7 @@ Important notes: 1. Set the `REACT_APP_PENDO_API_KEY` environment variable in `.env`. 2. Use the browser tools Network tab, filter requests by "psp.cloud", and check that successful network requests have been made to load Pendo scripts (also visible in the browser tools Sources tab). 3. In the browser console, type `pendo.validateEnvironment()`. -4. You should see command output in the console, and it should include a hashed `accountId` and hashed `visitorId`. Each page view change or custom event that fires should be visible as a request in the Network tab. +4. You should see command output in the console, and it should include an `accountId` and a `visitorId` that correspond with your APIv4 account `euuid` and profile `uid`, respectively. Each page view change or custom event that fires should be visible as a request in the Network tab. 5. If the console does not output the expected ids and instead outputs something like `Cookies are disabled in Pendo config. Is this expected?` in response to the above command, clear app storage with the browser tools. Once redirected back to Login, update the OneTrust cookie settings to enable cookies via "Manage Preferences" in the banner at the bottom of the screen. Log back into Cloud Manager and Pendo should load. ## Adobe Analytics diff --git a/package.json b/package.json index 63c9c5dd3f5..9a15c097c27 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "build:analyze": "pnpm run --filter linode-manager build:analyze", "bootstrap": "pnpm install:all && pnpm build:validation && pnpm build:sdk", "up:expose": "npm_config_package_import_method=clone-or-copy pnpm install:all && pnpm build:validation && pnpm build:sdk && pnpm start:all:expose", - "dev": "concurrently -n api-v4,validation,ui,utilities,queries,manager -c blue,yellow,magenta,cyan,gray,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter linode-manager start\"", - "start:all:expose": "concurrently -n api-v4,validation,ui,utilities,queries,manager -c blue,yellow,magenta,cyan,gray,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter linode-manager start:expose\"", + "dev": "concurrently -n api-v4,validation,ui,utilities,queries,shared,manager -c blue,yellow,magenta,cyan,gray,blue,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter @linode/shared start\" \"pnpm run --filter linode-manager start\"", + "start:all:expose": "concurrently -n api-v4,validation,ui,utilities,queries,shared,manager -c blue,yellow,magenta,cyan,gray,blue,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter @linode/shared start\" \"pnpm run --filter linode-manager start:expose\"", "start:manager": "pnpm --filter linode-manager start", "start:manager:ci": "pnpm run --filter linode-manager start:ci", "docs": "bunx vitepress@1.0.0-rc.44 dev docs", diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index da1d5adfb86..85e30c3389b 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,29 @@ +## [2025-04-08] - v0.137.0 + + +### Added: + +- DBaaS Advanced Configurations: Add `getDatabaseEngineConfig` request to fetch all advanced configurations and updated types for advanced configs ([#11812](https://github.com/linode/manager/pull/11812)) + +### Changed: + +- DBaaS Advanced Configurations: remove `engine_config` from the DatabaseEngineConfig type ([#11885](https://github.com/linode/manager/pull/11885)) + +### Fixed: + +- Remove trailing slash from outgoing Linode API GET request ([#11939](https://github.com/linode/manager/pull/11939)) + +### Removed: + +- DBaaS: unused functions getDatabaseType, getEngineDatabases, getDatabaseBackup ([#11909](https://github.com/linode/manager/pull/11909)) + +### Upcoming Features: + +- Add `/v4beta/nodebalancers` and `/v4/nodebalancers` endpoints for NB-VPC Integration ([#11832](https://github.com/linode/manager/pull/11832)) +- Update `ipv6` type in `CreateSubnetPayload` and rename `createSubnetSchema` to `createSubnetSchemaIPv4` ([#11896](https://github.com/linode/manager/pull/11896)) +- Update iam apis ([#11919](https://github.com/linode/manager/pull/11919)) +- Add support for IPv6 to `VPCIP` ([#11938](https://github.com/linode/manager/pull/11938)) + ## [2025-03-25] - v0.136.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index ac07b4fcfaf..839ef589f3a 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.136.0", + "version": "0.137.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index 374ed3c6519..08edf17bf50 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -23,6 +23,7 @@ import { SSLFields, UpdateDatabasePayload, DatabaseFork, + DatabaseEngineConfig, } from './types'; /** @@ -346,3 +347,15 @@ export const resumeDatabase = (engine: Engine, databaseID: number) => ), setMethod('POST') ); + +/** + * getConfig + * + * Return detailed list of all the configuration options + * + */ +export const getDatabaseEngineConfig = (engine: Engine) => + Request( + setURL(`${API_ROOT}/databases/${encodeURIComponent(engine)}/config`), + setMethod('GET') + ); diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 180cf098dc6..07048a3fb77 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -41,16 +41,38 @@ export type DatabaseStatus = | 'resuming' | 'suspended' | 'suspending'; - +/** @deprecated TODO (UIE-8214) remove after migration */ export type DatabaseBackupType = 'snapshot' | 'auto'; - +/** @deprecated TODO (UIE-8214) remove after migration */ export interface DatabaseBackup { id: number; type: DatabaseBackupType; label: string; created: string; } - +export interface ConfigurationItem { + description?: string; + example?: string | number | boolean; + minimum?: number; // min value for the number input + maximum?: number; // max value for the number input + maxLength?: number; // max length for the text input + minLength?: number; // min length for the text input + pattern?: string; + type?: string | [string, null] | string[]; + enum?: string[]; + restart_cluster?: boolean; +} + +export type ConfigValue = number | string | boolean; + +export type ConfigCategoryValues = Record; +export type DatabaseEngineConfig = Record< + string, + Record | ConfigurationItem +>; +export interface DatabaseInstanceAdvancedConfig { + [category: string]: ConfigCategoryValues | ConfigValue; +} export interface DatabaseFork { source: number; restore_time?: string; @@ -70,26 +92,7 @@ interface DatabaseHosts { export interface SSLFields { ca_certificate: string; } -// TODO: This will be changed in the next PR -export interface MySQLAdvancedConfig { - binlog_retention_period?: number; - advanced?: { - connect_timeout?: number; - default_time_zone?: string; - group_concat_max_len?: number; - information_schema_stats_expiry?: number; - innodb_print_all_deadlocks?: boolean; - sql_mode?: string; - }; -} -// TODO: This will be changed in the next PR -export interface PostgresAdvancedConfig { - advanced?: { - max_files_per_process?: number; - timezone?: string; - pg_stat_monitor_enable?: boolean; - }; -} + type MemberType = 'primary' | 'failover'; // DatabaseInstance is the interface for the shape of data returned by the /databases/instances endpoint. @@ -118,7 +121,7 @@ export interface DatabaseInstance { updated: string; updates: UpdatesSchedule; version: string; - engine_config?: MySQLAdvancedConfig | PostgresAdvancedConfig; + engine_config: DatabaseInstanceAdvancedConfig; } export type ClusterSize = 1 | 2 | 3; @@ -231,4 +234,5 @@ export interface UpdateDatabasePayload { updates?: UpdatesSchedule; type?: string; version?: string; + engine_config?: DatabaseInstanceAdvancedConfig; } diff --git a/packages/api-v4/src/entities/entities.ts b/packages/api-v4/src/entities/entities.ts new file mode 100644 index 00000000000..56ea45db5a6 --- /dev/null +++ b/packages/api-v4/src/entities/entities.ts @@ -0,0 +1,17 @@ +import { ResourcePage } from 'src/types'; +import { BETA_API_ROOT } from '../constants'; +import Request, { setMethod, setURL } from '../request'; +import { AccountEntity } from './types'; + +/** + * getAccountEntities + * + * Return all entities for account. + * + */ +export const getAccountEntities = () => { + return Request>( + setURL(`${BETA_API_ROOT}/entities`), + setMethod('GET') + ); +}; diff --git a/packages/api-v4/src/entities/index.ts b/packages/api-v4/src/entities/index.ts new file mode 100644 index 00000000000..de6289aa660 --- /dev/null +++ b/packages/api-v4/src/entities/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; + +export * from './types'; diff --git a/packages/api-v4/src/entities/types.ts b/packages/api-v4/src/entities/types.ts new file mode 100644 index 00000000000..bad27588967 --- /dev/null +++ b/packages/api-v4/src/entities/types.ts @@ -0,0 +1,17 @@ +export type EntityType = + | 'database' + | 'domain' + | 'firewall' + | 'image' + | 'linode' + | 'longview' + | 'nodebalancer' + | 'stackscript' + | 'volume' + | 'vpc'; + +export interface AccountEntity { + label: string; + type: EntityType; + id: number; +} diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 4f5eca2dbce..ec3ce8dea40 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -48,7 +48,7 @@ export interface FirewallRuleType { export interface FirewallDeviceEntity { id: number; type: FirewallDeviceEntityType; - label: string; + label: string | null; url: string; } @@ -89,10 +89,10 @@ export interface FirewallDevicePayload { } export interface DefaultFirewallIDs { - public_interface: number; - vpc_interface: number; - linode: number; - nodebalancer: number; + public_interface: number | null; + vpc_interface: number | null; + linode: number | null; + nodebalancer: number | null; } export interface FirewallSettings { diff --git a/packages/api-v4/src/iam/iam.ts b/packages/api-v4/src/iam/iam.ts index 31c5be6db83..27940e4ba5a 100644 --- a/packages/api-v4/src/iam/iam.ts +++ b/packages/api-v4/src/iam/iam.ts @@ -14,9 +14,9 @@ import { IamUserPermissions, IamAccountPermissions } from './types'; export const getUserPermissions = (username: string) => Request( setURL( - `${BETA_API_ROOT}/iam/role-permissions/users/${encodeURIComponent( + `${BETA_API_ROOT}/iam/users/${encodeURIComponent( username - )}` + )}/role-permissions` ), setMethod('GET') ); diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 9f8d7353483..6d821d0f352 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -1,39 +1,39 @@ -export type ResourceTypePermissions = - | 'linode' +export type EntityTypePermissions = + | 'account' + | 'database' + | 'domain' | 'firewall' - | 'nodebalancer' + | 'image' + | 'linode' | 'longview' - | 'domain' + | 'nodebalancer' | 'stackscript' - | 'image' | 'volume' - | 'database' - | 'account' | 'vpc'; export type AccountAccessType = - | 'account_linode_admin' - | 'linode_creator' - | 'linode_contributor' | 'account_admin' + | 'account_linode_admin' | 'account_viewer' - | 'firewall_creator'; + | 'firewall_creator' + | 'linode_contributor' + | 'linode_creator'; export type RoleType = - | 'linode_contributor' - | 'linode_viewer' | 'firewall_admin' + | 'firewall_creator' + | 'linode_contributor' | 'linode_creator' - | 'update_firewall' - | 'firewall_creator'; + | 'linode_viewer' + | 'update_firewall'; export interface IamUserPermissions { account_access: AccountAccessType[]; - resource_access: ResourceAccess[]; + entity_access: EntityAccess[]; } -export interface ResourceAccess { - resource_id: number; - resource_type: ResourceTypePermissions; +export interface EntityAccess { + id: number; + type: EntityTypePermissions; roles: RoleType[]; } @@ -196,11 +196,11 @@ export type PermissionType = export interface IamAccountPermissions { account_access: IamAccess[]; - resource_access: IamAccess[]; + entity_access: IamAccess[]; } export interface IamAccess { - resource_type: ResourceTypePermissions; + type: EntityTypePermissions; roles: Roles[]; } diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index c8eea9ea812..a1a181f55cf 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -56,7 +56,7 @@ export * from './betas'; export * from './iam'; -export * from './resources'; +export * from './entities'; export { baseRequest, diff --git a/packages/api-v4/src/linodes/linodes.ts b/packages/api-v4/src/linodes/linodes.ts index 57cf449c934..c69795ffad7 100644 --- a/packages/api-v4/src/linodes/linodes.ts +++ b/packages/api-v4/src/linodes/linodes.ts @@ -72,7 +72,7 @@ export const getLinodeVolumes = ( */ export const getLinodes = (params?: Params, filter?: Filter) => Request>( - setURL(`${API_ROOT}/linode/instances/`), + setURL(`${API_ROOT}/linode/instances`), setMethod('GET'), setXFilter(filter), setParams(params) diff --git a/packages/api-v4/src/nodebalancers/nodebalancer-configs.ts b/packages/api-v4/src/nodebalancers/nodebalancer-configs.ts index a690d580c41..2a2806e0712 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancer-configs.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancer-configs.ts @@ -8,9 +8,13 @@ import { ResourcePage as Page, Params } from '../types'; import { CreateNodeBalancerConfig, NodeBalancerConfig, + RebuildNodeBalancerConfig, UpdateNodeBalancerConfig, } from './types'; -import { combineConfigNodeAddressAndPort } from './utils'; +import { + combineConfigNodeAddressAndPort, + combineConfigNodeAddressAndPortBeta, +} from './utils'; /** * getNodeBalancerConfigs @@ -96,6 +100,33 @@ export const createNodeBalancerConfigBeta = ( nodeBalancerId )}/configs` ), + setData( + data, + createNodeBalancerConfigSchema, + combineConfigNodeAddressAndPortBeta + ) + ); + +/** + * rebuildNodeBalancerConfig + * + * Rebuilds a NodeBalancer Config and its Nodes that you have permission to modify. + * + * @param nodeBalancerId { number } The NodeBalancer to receive the new config. + * @param configId { number } The ID of the configuration profile to be updated + */ +export const rebuildNodeBalancerConfig = ( + nodeBalancerId: number, + configId: number, + data: RebuildNodeBalancerConfig +) => + Request( + setMethod('POST'), + setURL( + `${API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/configs/${encodeURIComponent(configId)}/rebuild` + ), setData( data, createNodeBalancerConfigSchema, @@ -103,6 +134,33 @@ export const createNodeBalancerConfigBeta = ( ) ); +/** + * rebuildNodeBalancerConfigBeta + * + * Rebuilds a NodeBalancer Config and its Nodes that you have permission to modify. + * + * @param nodeBalancerId { number } The NodeBalancer to receive the new config. + * @param configId { number } The ID of the configuration profile to be updated + */ +export const rebuildNodeBalancerConfigBeta = ( + nodeBalancerId: number, + configId: number, + data: RebuildNodeBalancerConfig +) => + Request( + setMethod('POST'), + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/configs/${encodeURIComponent(configId)}/rebuild` + ), + setData( + data, + createNodeBalancerConfigSchema, + combineConfigNodeAddressAndPortBeta + ) + ); + /** * updateNodeBalancerConfig * diff --git a/packages/api-v4/src/nodebalancers/nodebalancers.ts b/packages/api-v4/src/nodebalancers/nodebalancers.ts index f9469565dd8..8560eafe0eb 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancers.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancers.ts @@ -15,8 +15,12 @@ import type { CreateNodeBalancerPayload, NodeBalancer, NodeBalancerStats, + NodebalancerVpcConfig, } from './types'; -import { combineNodeBalancerConfigNodeAddressAndPort } from './utils'; +import { + combineNodeBalancerConfigNodeAddressAndPort, + combineNodeBalancerConfigNodeAddressAndPortBeta, +} from './utils'; import type { Firewall } from '../firewalls/types'; /** @@ -107,7 +111,7 @@ export const createNodeBalancerBeta = (data: CreateNodeBalancerPayload) => setData( data, NodeBalancerSchema, - combineNodeBalancerConfigNodeAddressAndPort + combineNodeBalancerConfigNodeAddressAndPortBeta ) ); @@ -174,3 +178,45 @@ export const getNodeBalancerTypes = (params?: Params) => setMethod('GET'), setParams(params) ); + +/** + * getNodeBalancerVPCConfigsBeta + * + * View all VPC Config information for this NodeBalancer + * + * @param nodeBalancerId { number } The ID of the NodeBalancer to view vpc config info for. + */ +export const getNodeBalancerVPCConfigsBeta = ( + nodeBalancerId: number, + params?: Params, + filter?: Filter +) => + Request>( + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/vpcs` + ), + setMethod('GET'), + setXFilter(filter), + setParams(params) + ); +/** + * getNodeBalancerVPCConfigBeta + * + * View VPC Config information for this NodeBalancer and VPC Config id + * + * @param nodeBalancerId { number } The ID of the NodeBalancer to view vpc config info for. + */ +export const getNodeBalancerVPCConfigBeta = ( + nodeBalancerId: number, + nbVpcConfigId: number +) => + Request( + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/vpcs/${encodeURIComponent(nbVpcConfigId)}` + ), + setMethod('GET') + ); diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index 2e782011d09..0ce208c81fa 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -134,6 +134,15 @@ export interface NodeBalancerStats { }; } +export interface NodebalancerVpcConfig { + id: number; + nodebalancer_id: number; + vpc_id: number; + subnet_id: number; + ipv4_range: string | null; + ipv6_range: string | null; +} + export interface CreateNodeBalancerConfig { port?: number; /** @@ -186,6 +195,8 @@ export interface CreateNodeBalancerConfig { export type UpdateNodeBalancerConfig = CreateNodeBalancerConfig; +export type RebuildNodeBalancerConfig = CreateNodeBalancerConfig; + export interface CreateNodeBalancerConfigNode { address: string; label: string; @@ -235,7 +246,7 @@ export interface CreateNodeBalancerPayload { configs: CreateNodeBalancerConfig[]; firewall_id?: number; tags?: string[]; - vpc?: { + vpcs?: { subnet_id: number; ipv4_range: string; ipv6_range?: string; diff --git a/packages/api-v4/src/nodebalancers/utils.ts b/packages/api-v4/src/nodebalancers/utils.ts index 42f81e54700..17d4fdab160 100644 --- a/packages/api-v4/src/nodebalancers/utils.ts +++ b/packages/api-v4/src/nodebalancers/utils.ts @@ -10,6 +10,17 @@ export const combineConfigNodeAddressAndPort = (data: any) => ({ })), }); +export const combineConfigNodeAddressAndPortBeta = (data: any) => ({ + ...data, + nodes: data.nodes.map((n: any) => ({ + address: `${n.address}:${n.port}`, + label: n.label, + mode: n.mode, + weight: n.weight, + subnet_id: n.subnet_id, + })), +}); + export const combineNodeBalancerConfigNodeAddressAndPort = (data: any) => ({ ...data, configs: data.configs.map((c: any) => ({ @@ -23,6 +34,20 @@ export const combineNodeBalancerConfigNodeAddressAndPort = (data: any) => ({ })), }); +export const combineNodeBalancerConfigNodeAddressAndPortBeta = (data: any) => ({ + ...data, + configs: data.configs.map((c: any) => ({ + ...c, + nodes: c.nodes.map((n: any) => ({ + address: `${n.address}:${n.port}`, + label: n.label, + mode: n.mode, + weight: n.weight, + subnet_id: n.subnet_id, + })), + })), +}); + export const mergeAddressAndPort = (node: NodeBalancerConfigNodeWithPort) => ({ ...node, address: `${node.address}:${node.port}`, diff --git a/packages/api-v4/src/resources/index.ts b/packages/api-v4/src/resources/index.ts deleted file mode 100644 index b8a322debd8..00000000000 --- a/packages/api-v4/src/resources/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './resources'; - -export * from './types'; diff --git a/packages/api-v4/src/resources/resources.ts b/packages/api-v4/src/resources/resources.ts deleted file mode 100644 index 55a576694bd..00000000000 --- a/packages/api-v4/src/resources/resources.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BETA_API_ROOT } from '../constants'; -import Request, { setMethod, setURL } from '../request'; -import { IamAccountResource } from './types'; - -/** - * getAccountResources - * - * Return all resources for account. - * - */ -export const getAccountResources = () => { - return Request( - setURL(`${BETA_API_ROOT}/resources`), - setMethod('GET') - ); -}; diff --git a/packages/api-v4/src/resources/types.ts b/packages/api-v4/src/resources/types.ts deleted file mode 100644 index 680aa4c03a5..00000000000 --- a/packages/api-v4/src/resources/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type ResourceType = - | 'linode' - | 'firewall' - | 'nodebalancer' - | 'longview' - | 'domain' - | 'stackscript' - | 'image' - | 'volume' - | 'database' - | 'vpc'; - -export interface IamAccountResource { - resource_type: ResourceType; - resources: Resource[]; -} - -export interface Resource { - name: string; - id: number; -} diff --git a/packages/api-v4/src/vpcs/types.ts b/packages/api-v4/src/vpcs/types.ts index 28ce606fdc8..2626c8de526 100644 --- a/packages/api-v4/src/vpcs/types.ts +++ b/packages/api-v4/src/vpcs/types.ts @@ -2,7 +2,7 @@ interface VPCIPv6 { range?: string; } -interface CreateVPCIPV6 extends VPCIPv6 { +interface CreateVPCIPv6 extends VPCIPv6 { allocation_class?: string; } @@ -21,7 +21,7 @@ export interface CreateVPCPayload { label: string; description?: string; region: string; - ipv6?: CreateVPCIPV6[]; + ipv6?: CreateVPCIPv6[]; subnets?: CreateSubnetPayload[]; } @@ -30,10 +30,14 @@ export interface UpdateVPCPayload { description?: string; } +interface VPCIPv6Subnet { + range: string; +} + export interface CreateSubnetPayload { label: string; ipv4?: string; - ipv6?: string; + ipv6?: VPCIPv6Subnet[]; } export interface Subnet extends CreateSubnetPayload { @@ -62,6 +66,11 @@ export interface VPCIP { active: boolean; address: string | null; address_range: string | null; + ipv6_range: string | null; + ipv6_is_public: boolean | null; + ipv6_addresses: { + slaac_address: string; + }[]; config_id: number | null; gateway: string | null; interface_id: number; diff --git a/packages/api-v4/src/vpcs/vpcs.ts b/packages/api-v4/src/vpcs/vpcs.ts index 30254700f6e..88daa6015bf 100644 --- a/packages/api-v4/src/vpcs/vpcs.ts +++ b/packages/api-v4/src/vpcs/vpcs.ts @@ -1,5 +1,5 @@ import { - createSubnetSchema, + createSubnetSchemaIPv4, createVPCSchema, modifySubnetSchema, updateVPCSchema, @@ -129,7 +129,7 @@ export const createSubnet = (vpcID: number, data: CreateSubnetPayload) => Request( setURL(`${API_ROOT}/vpcs/${encodeURIComponent(vpcID)}/subnets`), setMethod('POST'), - setData(data, createSubnetSchema) + setData(data, createSubnetSchemaIPv4) ); /** diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 2ad77920b50..fd5c9ac21e0 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -146,6 +146,7 @@ module.exports = { 'src/features/Firewalls/**/*', 'src/features/Images/**/*', 'src/features/Longview/**/*', + 'src/features/NodeBalancers/**/*', 'src/features/PlacementGroups/**/*', 'src/features/StackScripts/**/*', 'src/features/Volumes/**/*', @@ -241,6 +242,7 @@ module.exports = { rules: { '@linode/cloud-manager/deprecate-formik': 'warn', '@linode/cloud-manager/no-createLinode': 'off', + '@linode/cloud-manager/no-mui-theme-spacing': 'warn', '@typescript-eslint/consistent-type-imports': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', diff --git a/packages/manager/.storybook/main.ts b/packages/manager/.storybook/main.ts index 09b512e1a99..b1270f5ba05 100644 --- a/packages/manager/.storybook/main.ts +++ b/packages/manager/.storybook/main.ts @@ -5,6 +5,7 @@ const config: StorybookConfig = { stories: [ '../src/components/**/*.@(mdx|stories.@(js|ts|jsx|tsx))', '../src/features/**/*.@(mdx|stories.@(js|ts|jsx|tsx))', + '../../shared/src/**/*.@(mdx|stories.@(js|ts|jsx|tsx))', '../../ui/src/components/**/*.@(mdx|stories.@(js|ts|jsx|tsx))', ], addons: [ diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 36ce832bd33..5ce97db0236 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,103 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-04-08] - v1.139.0 + + +### Added: + +- Add cache update logic on edit alert query ([#11917](https://github.com/linode/manager/pull/11917)) + +### Changed: + +- Update Breadcrumb component to conform to Akamai Design System specs ([#11841](https://github.com/linode/manager/pull/11841)) +- Display interface type first in Linode Network IP Addresses table ([#11865](https://github.com/linode/manager/pull/11865)) +- Update Radio Button component to conform to Akamai Design System specs ([#11878](https://github.com/linode/manager/pull/11878)) +- Change `GlobalFilters.tsx` and `Zoomer.tsx` to add color on hover of icon ([#11883](https://github.com/linode/manager/pull/11883)) +- Update styles to CDS for profile menu ([#11884](https://github.com/linode/manager/pull/11884)) +- Update BetaChip styles, its usage and updated BetaChip component tests ([#11965](https://github.com/linode/manager/pull/11965)) +- Disable form fields on Firewall Create page for restricted users ([#11954](https://github.com/linode/manager/pull/11954)) +- Update 'Learn more' docs link for Accelerated Compute plans ([#11970](https://github.com/linode/manager/pull/11970)) + +### Fixed: + +- Database action menu incorrectly enabled with `read-only` grant and `Delete Cluster` button incorrectly disabled with `read/write` grant ([#11890](https://github.com/linode/manager/pull/11890)) +- Tabs keyboard navigation on some Tanstack rerouted features ([#11894](https://github.com/linode/manager/pull/11894)) +- Console errors on create menu & Linode create flow ([#11933](https://github.com/linode/manager/pull/11933)) +- PAT Token drawer logic when Child Account Access is hidden ([#11935](https://github.com/linode/manager/pull/11935)) +- Profile Menu Icon Size Inconsistency ([#11946](https://github.com/linode/manager/pull/11946)) +- Unclearable ACL IP addresses for LKE clusters ([#11947](https://github.com/linode/manager/pull/11947)) + +### Removed: + +- Ramda from `Utilities` package ([#11861](https://github.com/linode/manager/pull/11861)) +- Move `ListItemOption` from `manager` to `ui` package ([#11790](https://github.com/linode/manager/pull/11790)) +- Move `regionsData` from `manager` to `utilities` package ([#11790](https://github.com/linode/manager/pull/11790)) +- Move `LinodeCreateType` to `utilities` package ([#11790](https://github.com/linode/manager/pull/11790)) +- Move `LinodeSelect` to new `shared` package ([#11844](https://github.com/linode/manager/pull/11844)) +- Legacy BetaChip component ([#11872](https://github.com/linode/manager/pull/11872)) +- Move `doesRegionSupportFeature` from `manager` to `utilities` package ([#11891](https://github.com/linode/manager/pull/11891)) +- Move Tags-related queries and dependencies to shares `queries` package ([#11897](https://github.com/linode/manager/pull/11897)) +- Move Support-related queries and dependencies to shared `queries` package ([#11904](https://github.com/linode/manager/pull/11904)) +- Move `luxon` dependent utils from `manager` to `utilities` package ([#11905](https://github.com/linode/manager/pull/11905)) +- Move ramda dependent utils ([#11913](https://github.com/linode/manager/pull/11913)) +- Move `useIsGeckoEnabled` hook out of `RegionSelect` to `@linode/shared` package ([#11918](https://github.com/linode/manager/pull/11918)) +- Remove region selector from Edit VPC drawer since data center assignment cannot be changed. ([#11929](https://github.com/linode/manager/pull/11929)) +- DBaaS: deprecated types, outdated and unused code in DatabaseCreate and DatabaseSummary ([#11909](https://github.com/linode/manager/pull/11909)) +- Move `useFormattedDate` from `manager` to `utilities` package ([#11931](https://github.com/linode/manager/pull/11931)) +- Move stackscripts-related queries and dependencies to shared `queries` package ([#11949](https://github.com/linode/manager/pull/11949)) + +### Tech Stories: + +- Make `RegionSelect` and `RegionMultiSelect` pure ([#11790](https://github.com/linode/manager/pull/11790)) +- Nodebalancer routing (Tanstack) ([#11858](https://github.com/linode/manager/pull/11858)) +- Add `FirewallSelect` component ([#11887](https://github.com/linode/manager/pull/11887)) +- Add eslint rule for deprecating mui theme.spacing ([#11889](https://github.com/linode/manager/pull/11889)) +- Resolve Path Traversal Vulnerabilities detected from semgrep ([#11914](https://github.com/linode/manager/pull/11914)) +- Move feature flag code out of Kubernetes queries file ([#11922](https://github.com/linode/manager/pull/11922)) +- Fix incorrect secret in `publish-packages` Github Action ([#11923](https://github.com/linode/manager/pull/11923)) +- Remove hashing on Pendo account and visitor ids ([#11950](https://github.com/linode/manager/pull/11950)) + +### Tests: + +- Add HTML report generation for Cypress test results ([#11795](https://github.com/linode/manager/pull/11795)) +- Add `env:premiumPlans` test tag for tests which require premium plan availability ([#11886](https://github.com/linode/manager/pull/11886)) +- Fix Linode create end-to-end test failures against alternative environments ([#11886](https://github.com/linode/manager/pull/11886)) +- Delete redundant Linode create SSH key test ([#11886](https://github.com/linode/manager/pull/11886)) +- Add test for Add Linode Interface drawer ([#11887](https://github.com/linode/manager/pull/11887)) +- Prevent legacy regions from being used by Cypress tests ([#11892](https://github.com/linode/manager/pull/11892)) +- Temporarily skip Firewall end-to-end tests ([#11898](https://github.com/linode/manager/pull/11898)) +- Add tests for restricted user on database page ([#11912](https://github.com/linode/manager/pull/11912)) +- Allow Cypress Volume tests to pass against alternative environments ([#11939](https://github.com/linode/manager/pull/11939)) +- Fix create-linode-view-code-snippet.spec.ts test broken in devcloud ([#11948](https://github.com/linode/manager/pull/11948)) +- Improve stability of Linode config Cypress tests ([#11951](https://github.com/linode/manager/pull/11951)) + +### Upcoming Features: + +- DBaaS Advanced Configurations: Add UI for existing engine options in the drawer ([#11812](https://github.com/linode/manager/pull/11812)) +- Add Default Firewalls paper to Account Settings ([#11828](https://github.com/linode/manager/pull/11828)) +- Add functionality to support the 'Assign New Roles' drawer for a single user in IAM ([#11834](https://github.com/linode/manager/pull/11834)) +- Update Firewall Devices Linode landing table to account for new interface devices ([#11842](https://github.com/linode/manager/pull/11842)) +- Add Quotas Tab Beta Chip ([#11872](https://github.com/linode/manager/pull/11872)) +- Add AlertListNoticeMessages component for handling multiple API error messages, update AddChannelListing and MetricCriteria components to display these errors, add handleMultipleError util method for aggregating, mapping the errors to fields ([#11874](https://github.com/linode/manager/pull/11874)) +- Disable query to get Linode Interface when Interface Delete dialog is closed ([#11881](https://github.com/linode/manager/pull/11881)) +- Update title for Delete Interface dialog ([#11881](https://github.com/linode/manager/pull/11881)) +- Add VPC support to the Add Network Interface Drawer ([#11887](https://github.com/linode/manager/pull/11887)) +- Add Interface Details drawer for Linode Interfaces ([#11888](https://github.com/linode/manager/pull/11888)) +- Add a new confirmation dialog for the unassigning role flow in IAM ([#11893](https://github.com/linode/manager/pull/11893)) +- Add VPC & Firewall section to LKE-E create flow ([#11901](https://github.com/linode/manager/pull/11901)) +- Update success message for create/edit/enable/disable alert at `CreateAlertDefinition.tsx`, `EditAlertDefinition.tsx`, and `AlertListTable.tsx` ([#11903](https://github.com/linode/manager/pull/11903)) +- Update Firewall Landing table to account for Linode Interface devices and Default Firewalls ([#11920](https://github.com/linode/manager/pull/11920)) +- Add Default Firewall chips to Firewall Detail page ([#11920](https://github.com/linode/manager/pull/11920)) +- Remove preselected role from Change Role drawer ([#11926](https://github.com/linode/manager/pull/11926)) +- Adjust logic for displaying encryption status on Linode Details page and encryption copy on LKE Create page ([#11930](https://github.com/linode/manager/pull/11930)) +- DBaaS Advanced Configurations: set up Autocomplete to display categorized options, add/remove configs, and implement a dynamic validation schema for all field types ([#11885](https://github.com/linode/manager/pull/11885)) +- Support more VPC features when using Linode Interfaces on the Linode Create page ([#11915](https://github.com/linode/manager/pull/11915)) +- Pre-select default firewalls on the Linode Create flow ([#11915](https://github.com/linode/manager/pull/11915)) +- Update mock data and tests according to IAM backend response updates ([#11919](https://github.com/linode/manager/pull/11919)) +- Update `vpcIPFactory` to support IPv6 ([#11938](https://github.com/linode/manager/pull/11938)) +- Add a 2-minute refetch interval in alerts.ts, add isLoading and remove isFetching in AlertDetail.tsx. ([#11945](https://github.com/linode/manager/pull/11945)) + ## [2025-03-26] - v1.138.1 ### Fixed: diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index b8596bed4d9..d3c582dff3b 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -3,7 +3,10 @@ import { defineConfig } from 'cypress'; import { setupPlugins } from './cypress/support/plugins'; import { configureBrowser } from './cypress/support/plugins/configure-browser'; import { configureFileWatching } from './cypress/support/plugins/configure-file-watching'; -import { configureTestSuite } from './cypress/support/plugins/configure-test-suite'; +import { + enableJunitE2eReport, + enableJunitComponentReport, +} from './cypress/support/plugins/junit-report'; import { discardPassedTestRecordings } from './cypress/support/plugins/discard-passed-test-recordings'; import { loadEnvironmentConfig } from './cypress/support/plugins/load-env-config'; import { nodeVersionCheck } from './cypress/support/plugins/node-version-check'; @@ -13,14 +16,15 @@ import { configureApi } from './cypress/support/plugins/configure-api'; 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 { generateTestWeights } from './cypress/support/plugins/generate-weights'; import { logTestTagInfo } from './cypress/support/plugins/test-tagging-info'; import cypressViteConfig from './cypress/vite.config'; import { featureFlagOverrides } from './cypress/support/plugins/feature-flag-override'; import { postRunCleanup } from './cypress/support/plugins/post-run-cleanup'; import { resetUserPreferences } from './cypress/support/plugins/reset-user-preferences'; - +import { enableHtmlReport } from './cypress/support/plugins/html-report'; +import { configureMultiReporters } from './cypress/support/plugins/configure-multi-reporters'; +import cypressOnFix from 'cypress-on-fix'; /** * Exports a Cypress configuration object. * @@ -62,11 +66,14 @@ export default defineConfig({ viewportWidth: 500, viewportHeight: 500, - setupNodeEvents(on, config) { + setupNodeEvents(cypressOn, config) { + const on = cypressOnFix(cypressOn); return setupPlugins(on, config, [ loadEnvironmentConfig, discardPassedTestRecordings, - enableJunitReport('Component', true), + enableJunitComponentReport, + enableHtmlReport, + configureMultiReporters, ]); }, }, @@ -76,18 +83,15 @@ export default defineConfig({ // This can be overridden using `CYPRESS_BASE_URL`. baseUrl: 'http://localhost:3000', - - // This is overridden when `CY_TEST_SUITE` is defined. - // See `cypress/support/plugins/configure-test-suite.ts`. specPattern: 'cypress/e2e/core/**/*.spec.{ts,tsx}', - setupNodeEvents(on, config) { + setupNodeEvents(cypressOn, config) { + const on = cypressOnFix(cypressOn); return setupPlugins(on, config, [ loadEnvironmentConfig, nodeVersionCheck, configureApi, configureFileWatching, - configureTestSuite, configureBrowser, vitePreprocess, discardPassedTestRecordings, @@ -98,8 +102,10 @@ export default defineConfig({ featureFlagOverrides, logTestTagInfo, splitCypressRun, - enableJunitReport(), generateTestWeights, + enableJunitE2eReport, + enableHtmlReport, + configureMultiReporters, postRunCleanup, ]); }, diff --git a/packages/manager/cypress/component/components/beta-chip.spec.tsx b/packages/manager/cypress/component/components/beta-chip.spec.tsx index 58641a6e63b..dff3a4aa0f7 100644 --- a/packages/manager/cypress/component/components/beta-chip.spec.tsx +++ b/packages/manager/cypress/component/components/beta-chip.spec.tsx @@ -1,28 +1,17 @@ +import { BetaChip } from '@linode/ui'; import * as React from 'react'; import { checkComponentA11y } from 'support/util/accessibility'; import { componentTests, visualTests } from 'support/util/components'; -import { BetaChip } from 'src/components/BetaChip/BetaChip'; - componentTests('BetaChip', () => { visualTests((mount) => { - it('renders "BETA" text indicator with primary color', () => { - mount(); - cy.findByText('beta').should('be.visible'); - }); - - it('renders "BETA" text indicator with default color', () => { - mount(); + it('renders "BETA" text indicator', () => { + mount(); cy.findByText('beta').should('be.visible'); }); - it('passes aXe check with primary color', () => { - mount(); - checkComponentA11y(); - }); - - it('passes aXe check with default color', () => { - mount(); + it('passes aXe accessibility', () => { + mount(); checkComponentA11y(); }); }); diff --git a/packages/manager/cypress/component/components/region-select.spec.tsx b/packages/manager/cypress/component/components/region-select.spec.tsx index 656d5dacaf3..91d5200a39c 100644 --- a/packages/manager/cypress/component/components/region-select.spec.tsx +++ b/packages/manager/cypress/component/components/region-select.spec.tsx @@ -1,3 +1,4 @@ +import { accountAvailabilityFactory, regionFactory } from '@linode/utilities'; import * as React from 'react'; import { mockGetAccountAvailability } from 'support/intercepts/account'; import { ui } from 'support/ui'; @@ -6,7 +7,6 @@ import { createSpy } from 'support/util/components'; import { componentTests, visualTests } from 'support/util/components'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { accountAvailabilityFactory, regionFactory } from 'src/factories'; componentTests('RegionSelect', (mount) => { beforeEach(() => { @@ -26,6 +26,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={[region]} value={undefined} @@ -54,6 +55,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={[region]} value={undefined} @@ -83,6 +85,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={[region]} value={undefined} @@ -112,6 +115,7 @@ componentTests('RegionSelect', (mount) => { Other Element {}} regions={[region]} value={undefined} @@ -145,6 +149,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={undefined} @@ -179,6 +184,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={regionToPreselect.id} @@ -214,6 +220,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={regionToSelect.id} @@ -241,6 +248,7 @@ componentTests('RegionSelect', (mount) => { {}} regions={regions} value={regionToSelect.id} @@ -260,6 +268,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={undefined} @@ -277,6 +286,7 @@ componentTests('RegionSelect', (mount) => { mount( { mount( { mount( {}} regions={regions} value={undefined} @@ -379,6 +391,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={undefined} @@ -408,6 +421,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={undefined} @@ -438,6 +452,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={undefined} @@ -450,6 +465,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={selectedRegion.id} @@ -462,6 +478,7 @@ componentTests('RegionSelect', (mount) => { mount( {}} regions={regions} value={selectedRegion.id} diff --git a/packages/manager/cypress/component/components/tabs.spec.tsx b/packages/manager/cypress/component/components/tabs.spec.tsx new file mode 100644 index 00000000000..130b8072555 --- /dev/null +++ b/packages/manager/cypress/component/components/tabs.spec.tsx @@ -0,0 +1,155 @@ +import { createRoute } from '@tanstack/react-router'; +import * as React from 'react'; +import { ui } from 'support/ui'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests, visualTests } from 'support/util/components'; + +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useTabs } from 'src/hooks/useTabs'; + +const CustomTabs = () => { + const { handleTabChange, tabIndex, tabs } = useTabs([ + { + title: 'Tab 1', + to: '/tab-1', + }, + { + title: 'Tab 2', + to: '/tab-2', + }, + { + title: 'Tab 3', + to: '/tab-3', + }, + ]); + + return ( + + + }> + + +
Tab 1 content
+
+ +
Tab 2 content
+
+ +
Tab 3 content
+
+
+
+
+ ); +}; + +componentTests( + 'Tabs', + (mount) => { + describe('Tabs', () => { + it('should render all tabs and default to the first tab', () => { + mount(); + ui.tabList + .findTabByTitle('Tab 1') + .should('exist') + .should('have.attr', 'aria-selected', 'true'); + ui.tabList + .findTabByTitle('Tab 2') + .should('exist') + .should('have.attr', 'aria-selected', 'false'); + ui.tabList + .findTabByTitle('Tab 3') + .should('exist') + .should('have.attr', 'aria-selected', 'false'); + + cy.get('[data-reach-tab-panels]').should('have.text', 'Tab 1 content'); + }); + + it('should render the correct tab content when a tab is clicked', () => { + mount(); + + ui.tabList.findTabByTitle('Tab 2').click(); + ui.tabList + .findTabByTitle('Tab 2') + .should('exist') + .should('have.attr', 'aria-selected', 'true'); + cy.get('[data-reach-tab-panels]').should('have.text', 'Tab 2 content'); + + ui.tabList.findTabByTitle('Tab 3').click(); + ui.tabList + .findTabByTitle('Tab 3') + .should('exist') + .should('have.attr', 'aria-selected', 'true'); + cy.get('[data-reach-tab-panels]').should('have.text', 'Tab 3 content'); + }); + + it('should handle keyboard navigation', () => { + mount(); + + ui.tabList.findTabByTitle('Tab 1').focus(); + cy.get('body').type('{rightArrow}'); + ui.tabList + .findTabByTitle('Tab 2') + .should('exist') + .should('have.attr', 'aria-selected', 'true'); + cy.get('[data-reach-tab-panels]').should('have.text', 'Tab 2 content'); + + cy.get('body').type('{rightArrow}'); + ui.tabList + .findTabByTitle('Tab 3') + .should('exist') + .should('have.attr', 'aria-selected', 'true'); + cy.get('[data-reach-tab-panels]').should('have.text', 'Tab 3 content'); + + cy.get('body').type('{leftArrow}'); + ui.tabList + .findTabByTitle('Tab 2') + .should('exist') + .should('have.attr', 'aria-selected', 'true'); + cy.get('[data-reach-tab-panels]').should('have.text', 'Tab 2 content'); + + cy.get('body').type('{leftArrow}'); + ui.tabList + .findTabByTitle('Tab 1') + .should('exist') + .should('have.attr', 'aria-selected', 'true'); + cy.get('[data-reach-tab-panels]').should('have.text', 'Tab 1 content'); + }); + }); + }, + { + routeTree: (parentRoute) => [ + createRoute({ + getParentRoute: () => parentRoute, + path: '/tab-1', + }), + createRoute({ + getParentRoute: () => parentRoute, + path: '/tab-2', + }), + createRoute({ + getParentRoute: () => parentRoute, + path: '/tab-3', + }), + ], + useTanstackRouter: true, + } +); + +visualTests( + (mount) => { + describe('Accessibility checks', () => { + it('passes aXe check when menu is closed without an item selected', () => { + mount(); + checkComponentA11y(); + }); + }); + }, + { + useTanstackRouter: true, + } +); diff --git a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts index ba7e837cebc..118eb70095d 100644 --- a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts @@ -2,6 +2,7 @@ * @file Integration tests for Cloud Manager account enable Linode Managed flows. */ +import { linodeFactory } from '@linode/utilities'; import { visitUrlWithManagedDisabled, visitUrlWithManagedEnabled, @@ -21,7 +22,6 @@ import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; import { accountFactory } from 'src/factories/account'; -import { linodeFactory } from 'src/factories/linodes'; import { profileFactory } from 'src/factories/profile'; import type { Linode } from '@linode/api-v4'; 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 81d1f357097..c133b491d55 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 @@ -179,6 +179,72 @@ describe('Personal access tokens', () => { }); }); + it('sends scope as "*" when all permissions are set to read/write', () => { + const token = appTokenFactory.build({ + label: randomLabel(), + token: randomString(64), + }); + + mockCreatePersonalAccessToken(token).as('createToken'); + + cy.visitWithLogin('/profile/tokens'); + + // Click create button, fill out and submit PAT create form. + ui.button + .findByTitle('Create a Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Add Personal Access Token') + .should('be.visible') + .within(() => { + // Confirm that the “Child account access” grant is not visible in the list of permissions. + cy.findAllByText('Child Account Access').should('not.exist'); + + // Confirm submit button is disabled without specifying scopes. + ui.buttonGroup.findButtonByTitle('Create Token').scrollIntoView(); + ui.buttonGroup.findButtonByTitle('Create Token').should('be.disabled'); + + // Select "Read/Write" for all scopes. + cy.get( + '[aria-label="Personal Access Token Permissions"] tr:gt(1)' + ).each((row) => + cy.wrap(row).within(() => { + cy.get('[type="radio"]').eq(2).click(); + }) + ); + + // Verify "Select All" radio for "Read/Write" is active + cy.get('[data-qa-perm-rw-radio]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + + // Specify a label and submit. + cy.findByLabelText('Label').scrollIntoView(); + cy.findByLabelText('Label') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByLabelText('Label').type(token.label); + + ui.buttonGroup.findButtonByTitle('Create Token').scrollIntoView(); + ui.buttonGroup + .findButtonByTitle('Create Token') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that new PAT's scopes are '*' + cy.wait('@createToken').then((xhr) => { + expect(xhr.request.body.scopes).to.equal('*'); + }); + }); + /* * - Uses mocked API requests to confirm UI flow when renaming and revoking tokens * - Confirms that list shows the correct label after renaming a token 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 ddb33dc2639..be0c71fc660 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -3,6 +3,7 @@ */ import { getProfile } from '@linode/api-v4/lib/profile'; +import { createLinodeRequestFactory, linodeFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { visitUrlWithManagedEnabled } from 'support/api/managed'; import { @@ -21,13 +22,15 @@ import { pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomUuid } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { linodeFactory } from 'src/factories'; import { entityTransferFactory } from 'src/factories/entityTransfers'; -import { createLinodeRequestFactory } from 'src/factories/linodes'; import { formatDate } from 'src/utilities/formatDate'; -import type { EntityTransferStatus } from '@linode/api-v4'; -import type { EntityTransfer, Linode, Profile } from '@linode/api-v4'; +import type { + EntityTransfer, + EntityTransferStatus, + Linode, + Profile, +} from '@linode/api-v4'; // Service transfer empty state message. const serviceTransferEmptyState = 'No data to display.'; diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts index cc1eb5f9dbf..5dfa6cbc1c5 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts @@ -4,7 +4,7 @@ * This file contains Cypress tests that validate the display and content of the Alerts Show Detail Page in the CloudPulse application. * It ensures that all alert details, criteria, and resource information are displayed correctly. */ -import { capitalize } from '@linode/utilities'; +import { capitalize, regionFactory } from '@linode/utilities'; import { aggregationTypeMap, dimensionOperatorTypeMap, @@ -28,7 +28,6 @@ import { alertRulesFactory, databaseFactory, notificationChannelFactory, - regionFactory, } from 'src/factories'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index 02392ba5670..90d9498dd7b 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -13,7 +13,10 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { accountFactory, alertFactory } from 'src/factories'; -import { alertStatuses } from 'src/features/CloudPulse/Alerts/constants'; +import { + UPDATE_ALERT_SUCCESS_MESSAGE, + alertStatuses, +} from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; import type { Alert } from '@linode/api-v4'; @@ -330,7 +333,7 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { 'Alert-1', 'Disable', '@getFirstAlertDefinitions', - 'Alert disabled' + UPDATE_ALERT_SUCCESS_MESSAGE ); // Enable "Alert-2" @@ -339,7 +342,7 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { 'Alert-2', 'Enable', '@getSecondAlertDefinitions', - 'Alert enabled' + UPDATE_ALERT_SUCCESS_MESSAGE ); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts index 663108e3b42..24571e6df3d 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts @@ -1,6 +1,7 @@ /** * @file Error Handling Tests for CloudPulse Dashboard. */ +import { regionFactory } from '@linode/utilities'; import { widgetDetails } from 'support/constants/widgets'; import { mockGetAccount } from 'support/intercepts/account'; import { @@ -32,7 +33,6 @@ import { dashboardFactory, dashboardMetricFactory, databaseFactory, - regionFactory, widgetFactory, } from 'src/factories'; diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index 7b5fdff0cb1..6545e59d9c5 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -2,6 +2,7 @@ * @fileoverview Cypress test suite for the "Create Alert" functionality. */ +import { regionFactory } from '@linode/utilities'; import { statusMap } from 'support/constants/alert'; import { widgetDetails } from 'support/constants/widgets'; import { mockGetAccount } from 'support/intercepts/account'; @@ -26,9 +27,9 @@ import { databaseFactory, memoryRulesFactory, notificationChannelFactory, - regionFactory, triggerConditionFactory, } from 'src/factories'; +import { CREATE_ALERT_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; import type { Flags } from 'src/featureFlags'; @@ -405,7 +406,7 @@ describe('Create Alert', () => { // Verify URL redirection and toast notification cy.url().should('endWith', '/alerts/definitions'); - ui.toast.assertMessage('Alert successfully created'); + ui.toast.assertMessage(CREATE_ALERT_SUCCESS_MESSAGE); // Confirm that Alert is listed on landing page with expected configuration. cy.findByText(label) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 6b88891e6c4..1b877d9f04b 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -1,6 +1,7 @@ /** * @file Integration Tests for CloudPulse Dbass Dashboard. */ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { widgetDetails } from 'support/constants/widgets'; import { mockGetAccount } from 'support/intercepts/account'; import { @@ -26,8 +27,6 @@ import { dashboardMetricFactory, databaseFactory, kubeLinodeFactory, - linodeFactory, - regionFactory, widgetFactory, } from 'src/factories'; import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidgetUtils'; diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts index b0f28d636c0..de0def5b4b4 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts @@ -4,6 +4,7 @@ * This file contains Cypress tests for the Edit Alert page of the CloudPulse application. * It ensures that users can navigate to the Edit Alert Page and that alerts are correctly displayed and interactive on the Edit page. */ +import { regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetAlertDefinitions, @@ -15,12 +16,7 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { - accountFactory, - alertFactory, - databaseFactory, - regionFactory, -} from 'src/factories'; +import { accountFactory, alertFactory, databaseFactory } from 'src/factories'; import type { Alert, Database } from '@linode/api-v4'; import type { Flags } from 'src/featureFlags'; diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index a39d248f5c6..bb695e4f2df 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -5,6 +5,7 @@ * It verifies that alert details are correctly displayed, interactive, and editable. */ +import { regionFactory } from '@linode/utilities'; import { EVALUATION_PERIOD_DESCRIPTION, METRIC_DESCRIPTION_DATA_FIELD, @@ -36,9 +37,9 @@ import { databaseFactory, memoryRulesFactory, notificationChannelFactory, - regionFactory, triggerConditionFactory, } from 'src/factories'; +import { UPDATE_ALERT_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; import type { Database } from '@linode/api-v4'; @@ -362,7 +363,7 @@ describe('Integration Tests for Edit Alert', () => { // Verify URL redirection and toast notification cy.url().should('endWith', 'alerts/definitions'); - ui.toast.assertMessage('Alert successfully updated.'); + ui.toast.assertMessage(UPDATE_ALERT_SUCCESS_MESSAGE); // Confirm that Alert is listed on landing page with expected configuration. cy.findByText('Alert-2') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 640d5b22b87..1b76d04fc66 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -1,6 +1,7 @@ /** * @file Integration Tests for CloudPulse Linode Dashboard. */ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { widgetDetails } from 'support/constants/widgets'; import { mockGetAccount } from 'support/intercepts/account'; import { @@ -24,8 +25,6 @@ import { dashboardFactory, dashboardMetricFactory, kubeLinodeFactory, - linodeFactory, - regionFactory, widgetFactory, } from 'src/factories'; import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidgetUtils'; diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts index 8dab09b4141..17d5a0d5f84 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -1,6 +1,7 @@ /** * @file Integration Tests for CloudPulse Custom and Preset Verification */ +import { regionFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import { widgetDetails } from 'support/constants/widgets'; import { mockGetAccount } from 'support/intercepts/account'; @@ -29,7 +30,6 @@ import { dashboardMetricFactory, databaseFactory, profileFactory, - regionFactory, widgetFactory, } from 'src/factories'; import { convertToGmt } from 'src/features/CloudPulse/Utils/CloudPulseDateTimePickerUtils'; diff --git a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts index db6a3dee064..d053ff241fb 100644 --- a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts @@ -3,7 +3,7 @@ import { mockDatabaseEngineTypes, mockDatabaseNodeTypes, } from 'support/constants/databases'; -import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetAccount, mockGetUser } from 'support/intercepts/account'; import { mockCreateDatabase, mockGetDatabaseEngines, @@ -11,10 +11,22 @@ import { mockGetDatabases, } from 'support/intercepts/databases'; import { mockGetEvents } from 'support/intercepts/events'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; import { getRegionById } from 'support/util/regions'; -import { accountFactory, databaseFactory, eventFactory } from 'src/factories'; +import { + accountFactory, + accountUserFactory, + databaseFactory, + eventFactory, + grantsFactory, + profileFactory, +} from 'src/factories'; import type { Database } from '@linode/api-v4'; import type { databaseClusterConfiguration } from 'support/constants/databases'; @@ -158,3 +170,80 @@ describe('create a database cluster, mocked data', () => { } ); }); + +describe('restricted user cannot create database', () => { + beforeEach(() => { + // Mock setup for user profile, account user, and user grants with restricted permissions, + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_databases: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + mockGetDatabases([]).as('getDatabases'); + }); + it('cannot create database on landing page', () => { + // Login and wait for application to load + cy.visitWithLogin('/databases'); + cy.wait('@getDatabases'); + // Assert that Create Database button is visible and disabled + ui.button + .findByTitle('Create Database Cluster') + .should('be.visible') + .and('be.disabled') + .trigger('mouseover'); + + // Assert that tooltip is visible with message + ui.tooltip + .findByText( + "You don't have permissions to create Databases. Please contact your account administrator to request the necessary permissions." + ) + .should('be.visible'); + + // table not present for restricted user + cy.get('table[aria-label="Database Clusters"]').should('not.exist'); + // link to Docs should exist + cy.findByText('Getting Started Guides').should('be.visible'); + cy.findByText('Video Playlist').should('be.visible'); + }); + + it('cannot create database from Create menu', () => { + // Login and wait for application to load + cy.visitWithLogin('/databases/create'); + + // table present for restricted user but its inputs will be disabled + cy.get('table[aria-label="List of Linode Plans"]').should('exist'); + // Assert that Create Database button is visible and disabled + ui.button + .findByTitle('Create Database Cluster') + .should('be.visible') + .and('be.disabled') + .trigger('mouseover'); + + // Info message is visible + cy.findByText( + "You don't have permissions to create this Database. Please contact your account administrator to request the necessary permissions." + ); + + // all form inputs are disabled + cy.get('[data-testid="db-create-form"]').within(() => { + cy.get('input').each((input) => { + cy.wrap(input).should('be.disabled'); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts index fd998f69319..1ed3176d81b 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -17,7 +17,6 @@ import { mockUpdateDatabase, mockUpdateProvisioningDatabase, } from 'support/intercepts/databases'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { randomIp, @@ -144,33 +143,45 @@ const resetRootPassword = () => { }); }; -describe('Update database clusters', () => { - beforeEach(() => { - const mockAccount = accountFactory.build({ - capabilities: [ - 'Akamai Cloud Pulse', - 'Block Storage', - 'Cloud Firewall', - 'Disk Encryption', - 'Kubernetes', - 'Linodes', - 'LKE HA Control Planes', - 'Machine Images', - 'Managed Databases', - 'NodeBalancers', - 'Object Storage Access Key Regions', - 'Object Storage Endpoint Types', - 'Object Storage', - 'Placement Group', - 'Vlans', - ], - }); - mockAppendFeatureFlags({ - dbaasV2: { beta: false, enabled: false }, +/** + * Updates engine version if applicable and maintenance window for a given day and time. + * + * This requires that the 'Summary' or 'Settings' tab is currently active. + * + * @param engine - database engine for version upgrade. + * @param version - current database engine version to be upgraded. + */ +const upgradeEngineVersion = (engine: string, version: string) => { + const dbEngine = engine == 'mysql' ? 'MySQL' : 'PostgreSQL'; + cy.get('[data-qa-settings-section="Maintenance"]') + .should('be.visible') + .within(() => { + cy.findByText('Maintenance'); + cy.findByText('Version'); + cy.findByText(`${dbEngine} v${version}`); + ui.button.findByTitle('Upgrade Version').should('be.visible'); }); - mockGetAccount(mockAccount); - }); +}; +/** + * Updates maintenance window for a given day and time. + * + * This requires that the 'Summary' or 'Settings' tab is currently active. + * Assertion is made on the toast thrown while updating maintenance window. + * + * @param label - type of window (day/time) to update + * @param windowValue - maintenance window value to update + */ +const modifyMaintenanceWindow = (label: string, windowValue: string) => { + cy.findByText('Set a Weekly Maintenance Window'); + cy.findByTitle('Save Changes').should('be.visible').should('be.disabled'); + + ui.autocomplete.findByLabel(label).should('be.visible').type(windowValue); + cy.contains(windowValue).should('be.visible').click(); + ui.button.findByTitle('Save Changes').should('be.visible').click(); +}; + +describe('Update database clusters', () => { databaseConfigurations.forEach( (configuration: databaseClusterConfiguration) => { describe(`updates a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { @@ -192,12 +203,14 @@ describe('Update database clusters', () => { engine: configuration.dbType, id: randomNumber(1, 1000), label: initialLabel, - platform: 'rdbms-legacy', + platform: 'rdbms-default', region: configuration.region.id, status: 'active', type: configuration.linodeType, + version: configuration.version, }); + mockGetAccount(accountFactory.build()).as('getAccount'); mockGetDatabase(database).as('getDatabase'); mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); mockResetPassword(database.id, database.engine).as( @@ -212,27 +225,29 @@ describe('Update database clusters', () => { cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); cy.wait(['@getDatabase', '@getDatabaseTypes']); - cy.get('[data-qa-cluster-config]').within(() => { - cy.findByText(configuration.region.label).should('be.visible'); - cy.findByText(database.used_disk_size_gb + ' GB').should( - 'be.visible' - ); - cy.findByText(database.total_disk_size_gb + ' GB').should( - 'be.visible' - ); - }); + cy.findByText('Cluster Configuration'); + cy.findByText(configuration.region.label).should('be.visible'); + cy.findByText(database.total_disk_size_gb + ' GB').should( + 'be.visible' + ); + + cy.findByText('Connection Details'); + // "Show" button should be enabled to reveal password when DB is active. + ui.button + .findByTitle('Show') + .should('be.visible') + .should('be.enabled') + .click(); - cy.get('[data-qa-connection-details]').within(() => { - // "Show" button should be enabled to reveal password when DB is active. - cy.findByText('Show') - .closest('button') - .should('be.visible') - .should('be.enabled') - .click(); + cy.wait('@getCredentials'); + cy.findByText(`${initialPassword}`); - cy.wait('@getCredentials'); - cy.findByText(`= ${initialPassword}`); - }); + // "Hide" button should be enabled to hide password when password is revealed. + ui.button + .findByTitle('Hide') + .should('be.visible') + .should('be.enabled') + .click(); mockUpdateDatabase(database.id, database.engine, { ...database, @@ -244,6 +259,13 @@ describe('Update database clusters', () => { .should('be.visible') .should('have.text', updatedLabel); + // Navigate to "Settings" tab. + ui.tabList.findTabByTitle('Settings').click(); + + // Reset root password. + resetRootPassword(); + cy.wait('@resetRootPassword'); + // Remove allowed IP, manage IP access control. mockUpdateDatabase(database.id, database.engine, { ...database, @@ -262,25 +284,19 @@ describe('Update database clusters', () => { cy.findByText(newAllowedIp).should('be.visible'); }); - // Navigate to "Settings" tab. - ui.tabList.findTabByTitle('Settings').click(); - - // Reset root password. - resetRootPassword(); - cy.wait('@resetRootPassword'); - - // Change maintenance. + // Change maintenance window and databe version upgrade. mockUpdateDatabase(database.id, database.engine, database).as( 'updateDatabaseMaintenance' ); - cy.findByText('Monthly').should('be.visible').click(); + upgradeEngineVersion(database.engine, database.version); - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.enabled') - .click(); + modifyMaintenanceWindow('Day of Week', 'Wednesday'); + cy.wait('@updateDatabaseMaintenance'); + ui.toast.assertMessage( + 'Maintenance Window settings saved successfully.' + ); + modifyMaintenanceWindow('Time', '12:00'); cy.wait('@updateDatabaseMaintenance'); ui.toast.assertMessage( 'Maintenance Window settings saved successfully.' @@ -308,7 +324,7 @@ describe('Update database clusters', () => { }, id: randomNumber(1, 1000), label: initialLabel, - platform: 'rdbms-legacy', + platform: 'rdbms-default', region: configuration.region.id, status: 'provisioning', type: configuration.linodeType, @@ -346,37 +362,15 @@ describe('Update database clusters', () => { .should('be.enabled') .click(); - cy.get('[data-qa-connection-details]').within(() => { - // DBaaS hostnames are not available until database/cluster has provisioned. - cy.findByText(hostnameRegex).should('be.visible'); + cy.findByText('Connection Details'); + // DBaaS hostnames are not available until database/cluster has provisioned. + cy.findByText(hostnameRegex).should('be.visible'); - // DBaaS passwords cannot be revealed until database/cluster has provisioned. - cy.findByText('Show') - .closest('button') - .should('be.visible') - .should('be.disabled'); - }); - - // Cannot add or remove allowed IPs before database/cluster has provisioned. - removeAllowedIp(allowedIp); - cy.wait('@updateDatabase'); - ui.dialog - .findByTitle(`Remove IP Address ${allowedIp}`) + // DBaaS passwords cannot be revealed until database/cluster has provisioned. + ui.button + .findByTitle('Show') .should('be.visible') - .within(() => { - cy.findByText(errorMessage).should('be.visible'); - ui.buttonGroup - .findButtonByTitle('Cancel') - .should('be.visible') - .click(); - }); - - manageAccessControl([randomIp()], 1); - cy.wait('@updateDatabase'); - ui.drawer.findByTitle('Manage Access').within(() => { - cy.findByText(errorMessage).should('be.visible'); - ui.drawerCloseButton.find().click(); - }); + .should('be.disabled'); // Navigate to "Settings" tab. ui.tabList.findTabByTitle('Settings').click(); @@ -397,15 +391,29 @@ describe('Update database clusters', () => { .click(); }); - // Cannot change maintenance schedule before database/cluster has provisioned. - cy.findByText('Monthly').should('be.visible').click(); - - ui.button - .findByTitle('Save Changes') + // Cannot add or remove allowed IPs before database/cluster has provisioned. + removeAllowedIp(allowedIp); + cy.wait('@updateDatabase'); + ui.dialog + .findByTitle(`Remove IP Address ${allowedIp}`) .should('be.visible') - .should('be.enabled') - .click(); + .within(() => { + cy.findByText(errorMessage).should('be.visible'); + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.visible') + .click(); + }); + + manageAccessControl([randomIp()], 1); + cy.wait('@updateDatabase'); + ui.drawer.findByTitle('Manage Access').within(() => { + cy.findByText(errorMessage).should('be.visible'); + ui.drawerCloseButton.find().click(); + }); + // Cannot change maintenance schedule before database/cluster has provisioned. + modifyMaintenanceWindow('Day of Week', 'Wednesday'); cy.wait('@updateDatabase'); cy.findByText(errorMessage).should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts index 314bc91aebf..6e2956c4c98 100644 --- a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts @@ -1,3 +1,4 @@ +import { createLinodeRequestFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { interceptCreateFirewall } from 'support/intercepts/firewalls'; import { ui } from 'support/ui'; @@ -5,11 +6,8 @@ import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; - -import { createLinodeRequestFactory } from 'src/factories/linodes'; - authenticate(); -describe('create firewall', () => { +describe.skip('create firewall', () => { before(() => { cleanUp(['lke-clusters', 'linodes', 'firewalls']); }); 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 99bbabeb1a2..ce59d0b311c 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -9,7 +9,7 @@ import { firewallFactory } from 'src/factories/firewalls'; import type { Firewall } from '@linode/api-v4'; authenticate(); -describe('delete firewall', () => { +describe.skip('delete firewall', () => { before(() => { cleanUp('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 7ad3575f76e..0070a252647 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 @@ -1,10 +1,10 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { createLinodeRequestFactory, - firewallFactory, linodeFactory, regionFactory, -} from '@src/factories'; +} from '@linode/utilities'; +import { firewallFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { interceptCreateFirewall, @@ -21,8 +21,7 @@ import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomNumber } from 'support/util/random'; -import { extendRegion } from 'support/util/regions'; -import { chooseRegions } from 'support/util/regions'; +import { chooseRegions, extendRegion } from 'support/util/regions'; import type { Linode, Region } from '@linode/api-v4'; @@ -144,7 +143,7 @@ describe('Migrate Linode With Firewall', () => { /* * - Uses real API data to create a Firewall, attach a Linode to it, then migrate the Linode. */ - it('migrates linode with firewall - real data', () => { + it.skip('migrates linode with firewall - real data', () => { cy.tag('method:e2e', 'purpose:dcTesting'); const [migrationRegionStart, migrationRegionEnd] = chooseRegions(2); const firewallLabel = randomLabel(); 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 930a90bf216..e574a469199 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -1,4 +1,5 @@ import { createFirewall, createLinode } from '@linode/api-v4'; +import { createLinodeRequestFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { interceptUpdateFirewallLinodes, @@ -10,7 +11,6 @@ import { randomItem, randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { - createLinodeRequestFactory, firewallFactory, firewallRuleFactory, firewallRulesFactory, @@ -167,7 +167,7 @@ const createLinodeAndFirewall = async ( }; authenticate(); -describe('update firewall', () => { +describe.skip('update firewall', () => { before(() => { cleanUp('firewalls'); }); diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index 69f9ff92b38..18755ff73db 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -1,4 +1,4 @@ -import { linodeFactory, regionFactory } from '@src/factories'; +import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockGetAccountAgreements } from 'support/intercepts/account'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; 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 2efb32ab3d2..5d81c61c9d8 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,5 +1,6 @@ // must turn off sort-objects rule in this file bc mockTicket.description is set by formatDescription fn in which attribute order is nonalphabetical and affects test result /* eslint-disable perfectionist/sort-objects */ +import { linodeFactory } from '@linode/utilities'; /* eslint-disable sonarjs/no-duplicate-string */ import 'cypress-file-upload'; import { mockGetAccount } from 'support/intercepts/account'; @@ -33,7 +34,6 @@ import { chooseRegion } from 'support/util/regions'; import { accountFactory, domainFactory, - linodeFactory, supportTicketFactory, } from 'src/factories'; import { diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts index d1dbab44d9b..48f2f3c4250 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts @@ -1,4 +1,4 @@ -import { linodeConfigInterfaceFactory } from '@linode/utilities'; +import { linodeConfigInterfaceFactory, linodeFactory } from '@linode/utilities'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { @@ -22,7 +22,6 @@ import { import { entityFactory, linodeConfigFactory, - linodeFactory, supportTicketFactory, volumeFactory, } from 'src/factories'; 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 efcf8d1224f..22e776ed49e 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -1,4 +1,5 @@ -import { imageFactory, linodeFactory } from '@src/factories'; +import { linodeFactory } from '@linode/utilities'; +import { imageFactory } from '@src/factories'; import { mockGetAllImages } from 'support/intercepts/images'; import { ui } from 'support/ui'; import { apiMatcher } from 'support/util/intercepts'; diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index 215ebb55219..c4139b628ad 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { mockGetCustomImages, mockGetImage, @@ -8,7 +9,7 @@ import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { extendRegion } from 'support/util/regions'; -import { imageFactory, regionFactory } from 'src/factories'; +import { imageFactory } from 'src/factories'; import type { Image, Region } from '@linode/api-v4'; 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 1c469ca07a4..00d9f4c319a 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory } from '@linode/utilities'; import { mockGetUser } from 'support/intercepts/account'; import { mockGetEvents } from 'support/intercepts/events'; import { mockCreateImage } from 'support/intercepts/images'; @@ -13,7 +14,6 @@ import { accountUserFactory, eventFactory, grantsFactory, - linodeFactory, profileFactory, } from 'src/factories'; import { linodeDiskFactory } from 'src/factories/disk'; 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 5a3b3490b3e..ebe1e5a933a 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -1,7 +1,12 @@ /** * @file LKE creation end-to-end tests. */ -import { pluralize } from '@linode/utilities'; +import { + dedicatedTypeFactory, + linodeTypeFactory, + pluralize, + regionFactory, +} from '@linode/utilities'; import { dcPricingDocsLabel, dcPricingDocsUrl, @@ -38,21 +43,18 @@ import { } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { randomItem, randomLabel, randomNumber } from 'support/util/random'; -import { getRegionById } from 'support/util/regions'; -import { chooseRegion } from 'support/util/regions'; +import { chooseRegion, getRegionById } from 'support/util/regions'; -import { accountBetaFactory, lkeEnterpriseTypeFactory } from 'src/factories'; import { + accountBetaFactory, accountFactory, - dedicatedTypeFactory, kubeLinodeFactory, kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, - linodeTypeFactory, + lkeEnterpriseTypeFactory, lkeHighAvailabilityTypeFactory, nodePoolFactory, - regionFactory, } from 'src/factories'; import { CLUSTER_TIER_DOCS_LINK, @@ -1306,6 +1308,7 @@ describe('LKE Cluster Creation with LKE-E', () => { * - Confirms an LKE-E supported region can be selected * - Confirms an LKE-E supported k8 version can be selected * - Confirms the APL section is disabled while it remains unsupported + * - Confirms the VPC & Firewall placeholder section displays with correct copy * - Confirms ACL is enabled by default * - Confirms the checkout bar displays the correct LKE-E info * - Confirms an enterprise cluster can be created with the correct chip, version, and price @@ -1465,6 +1468,12 @@ describe('LKE Cluster Creation with LKE-E', () => { cy.findByRole('radio').should('be.disabled').should('be.checked'); }); + // Confirm the VPC/Firewall section displays. + cy.findByText('VPC & Firewall').should('be.visible'); + cy.findByText( + 'A VPC and Firewall are automatically generated for LKE Enterprise customers.' + ).should('be.visible'); + // Confirm the expected available plans display. validEnterprisePlanTabs.forEach((tab) => { ui.tabList.findTabByTitle(tab).should('be.visible'); 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 0f87c101f01..4830646d369 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory, linodeTypeFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { latestKubernetesVersion } from 'support/constants/lke'; @@ -32,8 +33,7 @@ import { } from 'support/intercepts/lke'; import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; -import { randomString } from 'support/util/random'; -import { randomIp, randomLabel } from 'support/util/random'; +import { randomIp, randomLabel, randomString } from 'support/util/random'; import { getRegionById } from 'support/util/regions'; import { @@ -42,8 +42,6 @@ import { kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, - linodeFactory, - linodeTypeFactory, nodePoolFactory, } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; @@ -2648,28 +2646,32 @@ describe('LKE ACL updates', () => { /** * - Confirms ACL can be disabled from the summary page (for standard tier only) - * - Confirms both IPv4 and IPv6 can be updated and that drawer updates as a result */ - it('can disable ACL on a standard tier cluster and edit IPs', () => { + it('can disable ACL on a standard tier cluster', () => { const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ - addresses: { ipv4: undefined, ipv6: undefined }, + addresses: { + ipv4: [], + ipv6: [], + }, enabled: true, }); - const mockUpdatedACLOptions1 = kubernetesControlPlaneACLOptionsFactory.build( + + const mockDisabledACLOptions = kubernetesControlPlaneACLOptionsFactory.build( { addresses: { - ipv4: ['10.0.0.0/24'], - ipv6: ['8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'], + ipv4: [''], + ipv6: [''], }, enabled: false, + 'revision-id': '', } ); const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ acl: mockACLOptions, }); - const mockUpdatedControlPlaneACL1 = kubernetesControlPlaneACLFactory.build( + const mockUpdatedControlPlaneACL = kubernetesControlPlaneACLFactory.build( { - acl: mockUpdatedACLOptions1, + acl: mockDisabledACLOptions, } ); @@ -2677,7 +2679,7 @@ describe('LKE ACL updates', () => { mockGetControlPlaneACL(mockCluster.id, mockControlPaneACL).as( 'getControlPlaneACL' ); - mockUpdateControlPlaneACL(mockCluster.id, mockUpdatedControlPlaneACL1).as( + mockUpdateControlPlaneACL(mockCluster.id, mockUpdatedControlPlaneACL).as( 'updateControlPlaneACL' ); @@ -2721,27 +2723,16 @@ describe('LKE ACL updates', () => { // confirm Revision ID section cy.findByLabelText('Revision ID').should( 'have.value', - mockACLOptions['revision-id'] + mockDisabledACLOptions['revision-id'] ); - // Addresses Section: update IPv4 + // confirm IPv4 and IPv6 address sections cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') - .click(); - cy.focused().type('10.0.0.0/24'); - cy.findByText('Add IPv4 Address') - .should('be.visible') - .should('be.enabled') - .click(); - // update IPv6 + .should('have.value', mockDisabledACLOptions.addresses?.ipv4?.[0]); cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click(); - cy.focused().type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); - cy.findByText('Add IPv6 Address') - .should('be.visible') - .should('be.enabled') - .click(); + .should('have.value', mockDisabledACLOptions.addresses?.ipv6?.[0]); // submit ui.button @@ -2756,7 +2747,7 @@ describe('LKE ACL updates', () => { // confirm summary panel updates cy.contains('Control Plane ACL').should('be.visible'); - cy.findByText('Enabled (O IP Addresses)').should('not.exist'); + cy.findByText('Enabled (0 IP Addresses)').should('not.exist'); ui.button .findByTitle('Enable') .should('be.visible') @@ -2774,11 +2765,19 @@ describe('LKE ACL updates', () => { .should('have.attr', 'data-qa-toggle', 'false') .should('be.visible'); - // confirm updated IP addresses display - cy.findByDisplayValue('10.0.0.0/24').should('be.visible'); - cy.findByDisplayValue( - '8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e' - ).should('be.visible'); + // confirm Revision ID section remains empty + cy.findByLabelText('Revision ID').should( + 'have.value', + mockDisabledACLOptions['revision-id'] + ); + + // confirm IPv4 and IPv6 address sections remain empty + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') + .should('be.visible') + .should('have.value', mockDisabledACLOptions.addresses?.ipv4?.[0]); + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') + .should('be.visible') + .should('have.value', mockDisabledACLOptions.addresses?.ipv6?.[0]); }); }); 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 ab5ea1349d0..ada334e759f 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -1,10 +1,10 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { - accountSettingsFactory, createLinodeRequestFactory, linodeBackupsFactory, linodeFactory, -} from '@src/factories'; +} from '@linode/utilities'; +import { accountSettingsFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { expectManagedDisabled } from 'support/api/managed'; import { dcPricingMockLinodeTypesForBackups } from 'support/constants/dc-specific-pricing'; 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 883fc888add..5bd831eddaf 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -1,9 +1,11 @@ -import { linodeConfigInterfaceFactory } from '@linode/utilities'; import { - VLANFactory, createLinodeRequestFactory, - linodeConfigFactory, + linodeConfigInterfaceFactory, linodeFactory, +} from '@linode/utilities'; +import { + VLANFactory, + linodeConfigFactory, volumeFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts index 7c82a1177d0..df59f00ebe3 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { @@ -8,8 +9,6 @@ import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomString } from 'support/util/random'; -import { linodeFactory, regionFactory } from 'src/factories'; - describe('Create Linode in a Core Region', () => { /* * - Confirms Linode create flow can be completed with a core region diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts index 746117f2952..8f1ccfd6833 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts @@ -1,3 +1,8 @@ +import { + linodeFactory, + linodeTypeFactory, + regionFactory, +} from '@linode/utilities'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateLinode, @@ -12,8 +17,6 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomString } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; -import { linodeFactory, linodeTypeFactory, regionFactory } from 'src/factories'; - import type { Region } from '@linode/api-v4'; describe('Create Linode in Distributed Region', () => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts index cc1fd3f1dda..ffc2939ce95 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts @@ -2,6 +2,7 @@ * @file Smoke tests for Linode Create flow across common mobile viewport sizes. */ +import { linodeFactory } from '@linode/utilities'; import { MOBILE_VIEWPORTS } from 'support/constants/environment'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; @@ -9,8 +10,6 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { linodeFactory } from 'src/factories'; - describe('Linode create mobile smoke', () => { MOBILE_VIEWPORTS.forEach((viewport) => { /* diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts index 914b1dc8d3a..aa4275e4f34 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts @@ -1,4 +1,4 @@ -import { regionFactory } from '@src/factories'; +import { regionFactory } from '@linode/utilities'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { extendRegion } from 'support/util/regions'; diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts index 2ec04d6fcc7..b74046af2fc 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts @@ -6,6 +6,7 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; describe('Create Linode flow to validate code snippet modal', () => { beforeEach(() => { @@ -20,13 +21,15 @@ describe('Create Linode flow to validate code snippet modal', () => { it(`view code snippets in create linode flow`, () => { const linodeLabel = randomLabel(); const rootPass = randomString(32); - + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes'], + }); cy.visitWithLogin('/linodes/create'); // Set Linode label, distribution, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); linodeCreatePage.selectImage('Debian 12'); - linodeCreatePage.selectRegionById('us-east'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(rootPass); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts index 589c9e76465..125d3673317 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory } from '@linode/utilities'; import { mockCreateLinode, mockGetLinodeDetails, @@ -7,8 +8,6 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { linodeFactory } from 'src/factories'; - describe('Create Linode with Add-ons', () => { /* * - Confirms UI flow to create a Linode with backups using mock API data. diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts index ed5ee08e51d..8a7efe8c078 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts @@ -1,4 +1,4 @@ -import { linodeFactory } from '@src/factories'; +import { linodeFactory } from '@linode/utilities'; import { dcPricingDocsLabel, dcPricingDocsUrl, diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts index 0c858da8a0a..8f2e413fc3f 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts @@ -1,9 +1,9 @@ import { - accountFactory, linodeFactory, linodeTypeFactory, regionFactory, -} from '@src/factories'; +} from '@linode/utilities'; +import { accountFactory } from '@src/factories'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts index 504e0acbb64..35865e33a6e 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory } from '@linode/utilities'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateFirewall, @@ -14,11 +15,7 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { - firewallFactory, - firewallTemplateFactory, - linodeFactory, -} from 'src/factories'; +import { firewallFactory, firewallTemplateFactory } from 'src/factories'; describe('Create Linode with Firewall', () => { beforeEach(() => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts index 1deaa3b4f09..f3812319191 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory } from '@linode/utilities'; import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockCreateSSHKey } from 'support/intercepts/profile'; @@ -6,11 +7,7 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { - accountUserFactory, - linodeFactory, - sshKeyFactory, -} from 'src/factories'; +import { accountUserFactory, sshKeyFactory } from 'src/factories'; describe('Create Linode with SSH Key', () => { /* diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts index d112ad97449..9fb53cba649 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; import { mockCreateLinode, @@ -9,7 +10,7 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; +import { imageFactory } from 'src/factories'; describe('Create Linode with user data', () => { /* diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index 7515c251c16..3a3be95d502 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; @@ -12,7 +13,7 @@ import { } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { VLANFactory, linodeFactory, regionFactory } from 'src/factories'; +import { VLANFactory } from 'src/factories'; describe('Create Linode with VLANs', () => { beforeEach(() => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts index 7cf424d3faf..b7ffa62f9d2 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -1,4 +1,8 @@ -import { linodeConfigInterfaceFactoryWithVPC } from '@linode/utilities'; +import { + linodeConfigInterfaceFactoryWithVPC, + linodeFactory, + regionFactory, +} from '@linode/utilities'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { @@ -24,13 +28,7 @@ import { } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { - linodeConfigFactory, - linodeFactory, - regionFactory, - subnetFactory, - vpcFactory, -} from 'src/factories'; +import { linodeConfigFactory, subnetFactory, vpcFactory } from 'src/factories'; import { WARNING_ICON_UNRECOMMENDED_CONFIG } from 'src/features/VPCs/constants'; describe('Create Linode with VPCs', () => { 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 fb5bc50cea9..357cb202f2b 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -3,56 +3,40 @@ */ import { - linodeConfigInterfaceFactory, - linodeConfigInterfaceFactoryWithVPC, + linodeFactory, + linodeTypeFactory, + regionFactory, } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; -import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetUser } from 'support/intercepts/account'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockGetAccount, mockGetUser } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptCreateLinode, mockCreateLinode, mockCreateLinodeError, - mockGetLinodeDisks, - mockGetLinodeType, mockGetLinodeTypes, - mockGetLinodeVolumes, } from 'support/intercepts/linodes'; -import { interceptGetProfile } from 'support/intercepts/profile'; import { + interceptGetProfile, mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { getRegionById } from 'support/util/regions'; +import { skip } from 'support/util/skip'; import { - VLANFactory, accountFactory, accountUserFactory, grantsFactory, - linodeConfigFactory, - linodeFactory, - linodeTypeFactory, profileFactory, - regionFactory, - subnetFactory, - vpcFactory, } from 'src/factories'; -import type { Config, Disk, Region, VLAN } from '@linode/api-v4'; - let username: string; authenticate(); @@ -89,11 +73,6 @@ describe('Create Linode', () => { planLabel: 'Linode 24 GB', planType: 'High Memory', }, - { - planId: 'g7-premium-2', - planLabel: 'Premium 4 GB', - planType: 'Premium CPU', - }, // TODO Include GPU plan types. // TODO Include Accelerated plan types (when they're no longer as restricted) ].forEach((planConfig) => { @@ -103,8 +82,9 @@ describe('Create Linode', () => { */ it(`creates a ${planConfig.planType} Linode`, () => { const linodeRegion = chooseRegion({ - capabilities: ['Linodes', 'Premium Plans', 'Vlans'], + capabilities: ['Linodes', 'Vlans'], }); + const linodeLabel = randomLabel(); interceptGetProfile().as('getProfile'); @@ -177,6 +157,84 @@ describe('Create Linode', () => { }); }); }); + + /* + * - Confirms Premium Plan Linode can be created end-to-end. + * - Confirms creation flow, that Linode boots, and that UI reflects status. + */ + it(`creates a Premium CPU Linode`, () => { + cy.tag('env:premiumPlans'); + + // TODO Allow `chooseRegion` to be configured not to throw. + const linodeRegion = (() => { + try { + return chooseRegion({ + capabilities: ['Linodes', 'Premium Plans', 'Vlans'], + }); + } catch { + skip(); + } + return; + })()!; + + const linodeLabel = randomLabel(); + const planId = 'g7-premium-2'; + const planLabel = 'Premium 4 GB'; + const planType = 'Premium CPU'; + + interceptGetProfile().as('getProfile'); + interceptCreateLinode().as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Set Linode label, OS, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 12'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan(planType, planLabel); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm information in summary is shown as expected. + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Debian 12').should('be.visible'); + cy.findByText(linodeRegion.label).should('be.visible'); + cy.findByText(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(planId); + + expect(responsePayload['label']).to.equal(linodeLabel); + expect(responsePayload['region']).to.equal(linodeRegion.id); + expect(responsePayload['type']).to.equal(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; + }); + + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + }); }); }); @@ -280,181 +338,6 @@ describe('Create Linode', () => { }); }); - it('adds an SSH key to the linode during create flow', () => { - const rootpass = randomString(32); - const sshPublicKeyLabel = randomLabel(); - const randomKey = randomString(400, { - lowercase: true, - numbers: true, - spaces: false, - symbols: false, - uppercase: true, - }); - const sshPublicKey = `ssh-rsa e2etestkey${randomKey} e2etest@linode`; - 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({ - capabilities: ['Linodes', 'VPCs', 'Vlans'], - id: region.id, - label: region.label, - }); - const mockPublicConfigInterface = linodeConfigInterfaceFactory.build({ - ipam_address: null, - purpose: 'public', - }); - const mockVlanConfigInterface = linodeConfigInterfaceFactory.build(); - const mockVpcConfigInterface = linodeConfigInterfaceFactoryWithVPC.build({ - active: true, - purpose: 'vpc', - vpc_id: mockVPC.id, - }); - 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[] = [ - { - created: '2020-08-21T17:26:14', - filesystem: 'ext4', - id: 44311273, - label: diskLabel, - size: 81408, - status: 'ready', - updated: '2020-08-21T17:26:30', - }, - { - created: '2020-08-21T17:26:14', - filesystem: 'swap', - id: 44311274, - label: '512 MB Swap Image', - size: 512, - status: 'ready', - updated: '2020-08-21T17:26:31', - }, - ]; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - 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'); - - 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}`); - - // Verify VPCs get fetched once a region is selected - cy.wait('@getVPCs'); - - cy.findByText('Shared CPU').click(); - cy.get(`[id="${dcPricingMockLinodeTypes[0].id}"]`).click(); - - // the "VPC" section is present, and the VPC in the same region of - // the linode can be selected. - cy.get('[data-testid="vpc-panel"]') - .should('be.visible') - .within(() => { - cy.contains('Assign this Linode to an existing VPC.').should( - 'be.visible' - ); - // select VPC - cy.findByLabelText('Assign VPC').should('be.visible').focus(); - cy.focused().type(`${mockVPC.label}{downArrow}{enter}`); - // select subnet - cy.findByPlaceholderText('Select Subnet') - .should('be.visible') - .type(`${mockSubnet.label}{downArrow}{enter}`); - }); - - // The drawer opens when clicking "Add an SSH Key" button - ui.button - .findByTitle('Add an SSH Key') - .should('be.visible') - .should('be.enabled') - .click(); - ui.drawer - .findByTitle('Add SSH Key') - .should('be.visible') - .within(() => { - cy.get('[id="label"]').clear(); - cy.focused().type(sshPublicKeyLabel); - - // An alert displays when the format of SSH key is incorrect - cy.get('[id="ssh-public-key"]').clear(); - cy.focused().type('WrongFormatSshKey'); - ui.button - .findByTitle('Add Key') - .should('be.visible') - .should('be.enabled') - .click(); - cy.findAllByText( - 'SSH Key key-type must be ssh-dss, ssh-rsa, ecdsa-sha2-nistp, ssh-ed25519, or sk-ecdsa-sha2-nistp256.' - ).should('be.visible'); - - // Create a new ssh key - cy.get('[id="ssh-public-key"]').clear(); - cy.focused().type(sshPublicKey); - ui.button - .findByTitle('Add Key') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // 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.findByText(sshPublicKeyLabel, { exact: false }).should('be.visible'); - - cy.get('#linode-label').clear(); - cy.focused().type(linodeLabel); - cy.focused().click(); - cy.get('#root-password').type(rootpass); - - ui.button.findByTitle('Create Linode').click(); - - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - cy.findByText(linodeLabel).should('be.visible'); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - /* * - Confirms error message can show up during Linode create flow. * - Confirms Linode can be created after retry. 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 4668a9ed2e1..e496a549c68 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -1,12 +1,12 @@ import { linodeConfigInterfaceFactory, linodeConfigInterfaceFactoryWithVPC, + linodeFactory, } from '@linode/utilities'; import { VLANFactory, kernelFactory, linodeConfigFactory, - linodeFactory, subnetFactory, vpcFactory, } from '@src/factories'; @@ -26,6 +26,7 @@ import { interceptRebootLinode, mockGetLinodeDetails, mockGetLinodeDisks, + mockGetLinodeFirewalls, mockGetLinodeKernel, mockGetLinodeKernels, mockGetLinodeVolumes, @@ -35,8 +36,7 @@ import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { fetchAllKernels, findKernelById } from 'support/util/kernels'; -import { fetchLinodeConfigs } from 'support/util/linodes'; -import { createTestLinode } from 'support/util/linodes'; +import { createTestLinode, fetchLinodeConfigs } from 'support/util/linodes'; import { randomIp, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; @@ -316,7 +316,7 @@ describe('Linode Config management', () => { ), createTestLinode( { booted: true }, - { securityMethod: 'vlan_no_internet' } + { securityMethod: 'vlan_no_internet', waitForBoot: true } ), ]); }; @@ -395,7 +395,8 @@ describe('Linode Config management', () => { // Confirm toast message and that UI updates to reflect clone in progress. ui.toast.assertMessage( - `Linode ${sourceLinode.label} has been cloned to ${destLinode.label}.` + `Linode ${sourceLinode.label} has been cloned to ${destLinode.label}.`, + { timeout: LINODE_CLONE_TIMEOUT } ); cy.findByText(/CLONING \(\d+%\)/).should('be.visible'); }); @@ -730,13 +731,15 @@ describe('Linode Config management', () => { // Mock a Linode with no existing configs, then visit its details page. mockGetLinodeKernel(mockKernel.id, mockKernel); - mockGetLinodeKernels([mockKernel]); + mockGetLinodeKernels([mockKernel]).as('getKernels'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); mockGetLinodeDisks(mockLinode.id, []).as('getDisks'); mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); mockGetLinodeConfigs(mockLinode.id, []).as('getConfigs'); + mockGetLinodeFirewalls(mockLinode.id, []); mockGetVPC(mockVPC).as('getVPC'); mockGetVPCs([mockVPC]).as('getVPCs'); + mockGetVLANs([]).as('getVLANs'); cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); cy.wait(['@getConfigs', '@getDisks', '@getLinode', '@getVolumes']); @@ -754,8 +757,10 @@ describe('Linode Config management', () => { 'getLinodeConfigs' ); - // Create new config. + // Create new config. Wait for VLAN GET response before interacting with form. cy.findByText('Add Configuration').click(); + cy.wait('@getVLANs'); + ui.dialog .findByTitle('Add Configuration') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts index 9df12ab3fa8..74a9894178b 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -1,20 +1,30 @@ +import { linodeFactory } from '@linode/utilities'; +import { + linodeInterfaceFactoryPublic, + linodeInterfaceFactoryVPC, +} from '@linode/utilities'; import { firewallDeviceFactory, firewallFactory, ipAddressFactory, - linodeFactory, + subnetFactory, + vpcFactory, } from '@src/factories'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockAddFirewallDevice, mockGetFirewalls, + mockGetLinodeInterfaceFirewalls, } from 'support/intercepts/firewalls'; import { + mockCreateLinodeInterface, mockGetLinodeDetails, mockGetLinodeFirewalls, mockGetLinodeIPAddresses, + mockGetLinodeInterfaces, } from 'support/intercepts/linodes'; import { mockUpdateIPAddress } from 'support/intercepts/networking'; +import { mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; import type { IPRange } from '@linode/api-v4'; @@ -73,7 +83,7 @@ describe('IP Addresses', () => { * - Confirms the success toast message after editing RDNS */ it('checks for the toast message upon editing an RDNS', () => { - cy.findByLabelText('IPv4 Addresses') + cy.findByLabelText('Linode IP Addresses') .should('be.visible') .within(() => { // confirm table headers @@ -89,7 +99,7 @@ describe('IP Addresses', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('IPv4 – Public').should('be.visible'); + cy.findByText('Public – IPv4').should('be.visible'); cy.findByText(mockRDNS).should('be.visible'); // open up the edit RDNS drawer @@ -126,7 +136,7 @@ describe('IP Addresses', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('IPv4 – Public').should('be.visible'); + cy.findByText('Public – IPv4').should('be.visible'); ui.actionMenu .findByTitle(`Action menu for IP Address ${linodeIPv4}`) .should('be.visible'); @@ -137,7 +147,7 @@ describe('IP Addresses', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('IPv6 – Range').should('be.visible'); + cy.findByText('Range – IPv6').should('be.visible'); ui.actionMenu .findByTitle(`Action menu for IP Address ${_ipv6Range.range}`) .should('be.visible'); @@ -235,3 +245,168 @@ describe('Firewalls', () => { .should('be.disabled'); }); }); + +describe('Linode Interfaces', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: true }, + }); + }); + + it('allows the user to add a public network interface with a firewall', () => { + const linode = linodeFactory.build({ interface_generation: 'linode' }); + const firewalls = firewallFactory.buildList(3); + const linodeInterface = linodeInterfaceFactoryPublic.build(); + + const selectedFirewall = firewalls[1]; + + mockGetLinodeDetails(linode.id, linode).as('getLinode'); + mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as('getInterfaces'); + mockGetFirewalls(firewalls).as('getFirewalls'); + mockCreateLinodeInterface(linode.id, linodeInterface).as('createInterface'); + mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [ + selectedFirewall, + ]).as('getInterfaceFirewalls'); + + cy.visitWithLogin(`/linodes/${linode.id}/networking`); + + cy.wait(['@getLinode', '@getInterfaces']); + + ui.button.findByTitle('Add Network Interface').scrollIntoView().click(); + + ui.drawer.findByTitle('Add Network Interface').within(() => { + // Verify firewalls fetch + cy.wait('@getFirewalls'); + + // Try submitting the form + ui.button.findByAttribute('type', 'submit').should('be.enabled').click(); + + // Verify a validation error shows + cy.findByText('You must selected an Interface type.').should( + 'be.visible' + ); + + // Select the public interface type + cy.findByLabelText('Public').click(); + + // Verify a validation error goes away + cy.findByText('You must selected an Interface type.').should('not.exist'); + + // Select a Firewall + ui.autocomplete.findByLabel('Firewall').click(); + ui.autocompletePopper.findByTitle(selectedFirewall.label).click(); + + mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] }); + + ui.button.findByAttribute('type', 'submit').should('be.enabled').click(); + }); + + cy.wait('@createInterface').then((xhr) => { + const requestPayload = xhr.request.body; + + // Confirm that request payload includes a Public interface only + expect(requestPayload['public']).to.be.an('object'); + expect(requestPayload['vpc']).to.equal(null); + expect(requestPayload['vlan']).to.equal(null); + }); + + ui.toast.assertMessage('Successfully added network interface.'); + + // Verify the interface row shows upon creation + cy.findByText(linodeInterface.mac_address) + .closest('tr') + .within(() => { + // Verify we fetch the interfaces firewalls and the label shows + cy.wait('@getInterfaceFirewalls'); + cy.findByText(selectedFirewall.label).should('be.visible'); + + // Verify the interface type shows + cy.findByText('Public').should('be.visible'); + }); + }); + + it('allows the user to add a VPC network interface with a firewall', () => { + const linode = linodeFactory.build({ interface_generation: 'linode' }); + const firewalls = firewallFactory.buildList(3); + const subnets = subnetFactory.buildList(3); + const vpcs = vpcFactory.buildList(3, { subnets }); + const linodeInterface = linodeInterfaceFactoryVPC.build(); + + const selectedFirewall = firewalls[1]; + const selectedVPC = vpcs[1]; + const selectedSubnet = selectedVPC.subnets[0]; + + mockGetLinodeDetails(linode.id, linode).as('getLinode'); + mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as('getInterfaces'); + mockGetFirewalls(firewalls).as('getFirewalls'); + mockGetVPCs(vpcs).as('getVPCs'); + mockCreateLinodeInterface(linode.id, linodeInterface).as('createInterface'); + mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [ + selectedFirewall, + ]).as('getInterfaceFirewalls'); + + cy.visitWithLogin(`/linodes/${linode.id}/networking`); + + cy.wait(['@getLinode', '@getInterfaces']); + + ui.button.findByTitle('Add Network Interface').scrollIntoView().click(); + + ui.drawer.findByTitle('Add Network Interface').within(() => { + // Verify firewalls fetch + cy.wait('@getFirewalls'); + + cy.findByLabelText('VPC').click(); + + // Verify VPCs fetch + cy.wait('@getVPCs'); + + // Select a VPC + ui.autocomplete.findByLabel('VPC').click(); + ui.autocompletePopper.findByTitle(selectedVPC.label).click(); + + // Select a Firewall + ui.autocomplete.findByLabel('Firewall').click(); + ui.autocompletePopper.findByTitle(selectedFirewall.label).click(); + + // Submit the form + ui.button.findByAttribute('type', 'submit').should('be.enabled').click(); + + // Verify an error shows because a subnet is not selected + cy.findByText('Subnet is required.').should('be.visible'); + + // Select a Subnet + ui.autocomplete.findByLabel('Subnet').click(); + ui.autocompletePopper.findByTitle(selectedSubnet.label).click(); + + // Verify the error goes away + cy.findByText('Subnet is required.').should('not.exist'); + + mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] }); + + ui.button.findByAttribute('type', 'submit').should('be.enabled').click(); + }); + + cy.wait('@createInterface').then((xhr) => { + const requestPayload = xhr.request.body; + + // Confirm that request payload includes VPC interface only + expect(requestPayload['public']).to.be.null; + expect(requestPayload['vpc']['subnet_id']).to.equal(selectedSubnet.id); + expect(requestPayload['vlan']).to.null; + }); + + ui.toast.assertMessage('Successfully added network interface.'); + + // Verify the interface row shows upon creation + cy.findByText(linodeInterface.mac_address) + .closest('tr') + .within(() => { + // Verify we fetch the interfaces firewalls and the label shows + cy.wait('@getInterfaceFirewalls'); + cy.findByText(selectedFirewall.label).should('be.visible'); + + // Verify the interface type shows + cy.findByText('VPC').should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts index 36abe9cc6b9..a9c45bc260f 100644 --- a/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts @@ -1,18 +1,18 @@ +import { linodeFactory } from '@linode/utilities'; import { linodeDiskFactory } from '@src/factories'; -import { linodeFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { dcPricingCurrentPriceLabel, dcPricingMockLinodeTypes, dcPricingNewPriceLabel, } from 'support/constants/dc-specific-pricing'; -import { mockGetLinodeDetails } from 'support/intercepts/linodes'; import { + mockGetLinodeDetails, mockGetLinodeDisks, + mockGetLinodeType, mockGetLinodeVolumes, mockMigrateLinode, } from 'support/intercepts/linodes'; -import { mockGetLinodeType } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { apiMatcher } from 'support/util/intercepts'; import { getRegionById } from 'support/util/regions'; 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 f4c539c1afd..07ef3c82d45 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -1,10 +1,10 @@ // TODO: Cypress import { - accountFactory, linodeTypeFactory, regionAvailabilityFactory, regionFactory, -} from '@src/factories'; +} from '@linode/utilities'; +import { accountFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; 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 17bf313023c..e3a4d5fa827 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -1,10 +1,10 @@ import { createStackScript } from '@linode/api-v4/lib'; import { createLinodeRequestFactory, - imageFactory, linodeFactory, regionFactory, -} from '@src/factories'; +} from '@linode/utilities'; +import { imageFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; 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 fed76acfa20..7d3cb5354f9 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -1,4 +1,4 @@ -import { createLinodeRequestFactory, linodeFactory } from '@src/factories'; +import { createLinodeRequestFactory, linodeFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { 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 2286564fb84..61ecc709e97 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 { createLinodeRequestFactory } from '@linode/utilities'; import { accountSettingsFactory } from '@src/factories/accountSettings'; -import { createLinodeRequestFactory } from '@src/factories/linodes'; import { authenticate } from 'support/api/authentication'; import { mockGetAccountSettings } from 'support/intercepts/account'; import { interceptDeleteLinode } from 'support/intercepts/linodes'; diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index ba4affc1c48..b67e42ff902 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -1,9 +1,9 @@ /* eslint-disable sonarjs/no-duplicate-string */ +import { linodeFactory } from '@linode/utilities'; import { profileFactory, userPreferencesFactory } from '@src/factories'; import { accountSettingsFactory } from '@src/factories/accountSettings'; import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; -import { linodeFactory } from '@src/factories/linodes'; import { makeResourcePage } from '@src/mocks/serverHandlers'; import { authenticate } from 'support/api/authentication'; import { mockGetUser } from 'support/intercepts/account'; @@ -74,7 +74,7 @@ describe('linode landing checks', () => { req.reply(mockAccountSettings); }).as('getAccountSettings'); cy.intercept('GET', apiMatcher('profile')).as('getProfile'); - cy.intercept('GET', apiMatcher('linode/instances/*'), (req) => { + cy.intercept('GET', apiMatcher('linode/instances*'), (req) => { req.reply(mockLinodesData); }).as('getLinodes'); cy.visitWithLogin('/', { preferenceOverrides }); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts index 32675f582fe..5090e7004eb 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts @@ -1,3 +1,4 @@ +import { nodeBalancerFactory } from '@linode/utilities'; import { mockAddFirewallDevice, mockGetFirewalls, @@ -8,11 +9,7 @@ import { } from 'support/intercepts/nodebalancers'; import { ui } from 'support/ui'; -import { - firewallDeviceFactory, - firewallFactory, - nodeBalancerFactory, -} from 'src/factories'; +import { firewallDeviceFactory, firewallFactory } from 'src/factories'; describe('Firewalls', () => { it('allows the user to assign a Firewall from the NodeBalancer settings page', () => { diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts index 0579597a751..dd797581165 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts @@ -1,3 +1,4 @@ +import { nodeBalancerFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { entityTag } from 'support/constants/cypress'; import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; @@ -7,8 +8,6 @@ import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { nodeBalancerFactory } from 'src/factories'; - import type { Linode } from '@linode/api-v4'; authenticate(); 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 12471265c8d..2cd99a49615 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 @@ -16,15 +16,14 @@ const deployNodeBalancer = () => { cy.get('[data-qa-deploy-nodebalancer]').click(); }; -import { mockGetLinodes } from 'support/intercepts/linodes'; -import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; -import { mockGetRegions } from 'support/intercepts/regions'; - import { linodeFactory, nodeBalancerFactory, regionFactory, -} from 'src/factories'; +} from '@linode/utilities'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; +import { mockGetRegions } from 'support/intercepts/regions'; const createNodeBalancerWithUI = ( nodeBal: NodeBalancer, 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 78150cba226..78dd831ccd1 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -1,13 +1,13 @@ /** * @file Cypress integration tests for OBJ enrollment and cancellation. */ +import { regionFactory } from '@linode/utilities'; import { accountFactory, accountSettingsFactory, objectStorageClusterFactory, objectStorageKeyFactory, profileFactory, - regionFactory, } from '@src/factories'; import { mockGetAccount, @@ -17,10 +17,10 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCancelObjectStorage, mockCreateAccessKey, + mockGetAccessKeys, mockGetBuckets, mockGetClusters, } from 'support/intercepts/object-storage'; -import { mockGetAccessKeys } from 'support/intercepts/object-storage'; import { mockGetProfile } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts index f7cf9eccc6b..02d531df972 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { @@ -19,7 +20,6 @@ import { accountFactory, objectStorageBucketFactoryGen2, objectStorageEndpointsFactory, - regionFactory, } from 'src/factories'; import { profileFactory } from 'src/factories/profile'; diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts index 6ac187e0c66..ba58475cd86 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { @@ -10,7 +11,6 @@ import { accountFactory, objectStorageBucketFactoryGen2, objectStorageEndpointsFactory, - regionFactory, } from 'src/factories'; import type { ACLType, ObjectStorageEndpointTypes } from '@linode/api-v4'; diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts index dd1feeda2b4..6d10cf1b60a 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import 'cypress-file-upload'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; @@ -22,7 +23,6 @@ import { accountFactory, objectStorageBucketFactoryGen2, objectStorageEndpointsFactory, - regionFactory, } from 'src/factories'; import type { ObjectStorageEndpoint } from '@linode/api-v4'; diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts index df03e62632c..1b3db135ab7 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { @@ -21,7 +22,6 @@ import { accountFactory, objectStorageBucketFactory, objectStorageKeyFactory, - regionFactory, } from 'src/factories'; import type { ObjectStorageKeyBucketAccess } from '@linode/api-v4'; diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts index a6ba75cd979..3c597b9def5 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { @@ -10,11 +11,7 @@ import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; -import { - accountFactory, - objectStorageBucketFactory, - regionFactory, -} from 'src/factories'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; describe('Object Storage Multicluster Bucket create', () => { /* diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts index d6769154a3a..253b54a4596 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetBucket } from 'support/intercepts/object-storage'; @@ -5,11 +6,7 @@ import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; -import { - accountFactory, - objectStorageBucketFactory, - regionFactory, -} from 'src/factories'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; describe('Object Storage Multicluster Bucket Details Tabs', () => { beforeEach(() => { 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 428858ef6ea..c1e069cec3f 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory } from '@linode/utilities'; import { mockGetAllImages } from 'support/intercepts/images'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { @@ -10,7 +11,7 @@ import { getRandomOCAId } from 'support/util/one-click-apps'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { imageFactory, linodeFactory } from 'src/factories'; +import { imageFactory } from 'src/factories'; import { stackScriptFactory } from 'src/factories/stackscripts'; import { getMarketplaceAppLabel } from 'src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities'; import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts index 3acbd05ace0..aaef31b0af9 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockCreateLinode, @@ -13,12 +14,7 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomNumber, randomString } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; -import { - accountFactory, - linodeFactory, - placementGroupFactory, -} from 'src/factories'; -import { regionFactory } from 'src/factories'; +import { accountFactory, placementGroupFactory } from 'src/factories'; import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/PlacementGroups/constants'; const mockAccount = accountFactory.build(); 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 a56e60fe1e9..8a42a655912 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockCreatePlacementGroup, @@ -9,7 +10,6 @@ import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { accountFactory, placementGroupFactory } from 'src/factories'; -import { regionFactory } from 'src/factories'; import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/PlacementGroups/constants'; const mockAccount = accountFactory.build(); 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 93e8c050573..1d7911d1280 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -2,6 +2,7 @@ * @file Cypress integration tests for VM Placement Groups deletion flows. */ +import { linodeFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { @@ -17,11 +18,7 @@ import { buildArray } from 'support/util/arrays'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { - accountFactory, - linodeFactory, - placementGroupFactory, -} from 'src/factories'; +import { accountFactory, placementGroupFactory } from 'src/factories'; import { headers as emptyStatePageHeaders } from 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyStateData'; // Mock an account with 'Placement Group' capability. diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts index 6e910ff4cd2..efe8709cf31 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetPlacementGroups } from 'support/intercepts/placement-groups'; @@ -5,11 +6,7 @@ import { ui } from 'support/ui'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { - accountFactory, - linodeFactory, - placementGroupFactory, -} from 'src/factories'; +import { accountFactory, placementGroupFactory } from 'src/factories'; const mockAccount = accountFactory.build(); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts index 46c161c0882..d328c6444bb 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts @@ -1,3 +1,4 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetLinodeDetails, @@ -17,12 +18,7 @@ import { buildArray } from 'support/util/arrays'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { - accountFactory, - linodeFactory, - placementGroupFactory, - regionFactory, -} from 'src/factories'; +import { accountFactory, placementGroupFactory } from 'src/factories'; import type { Linode } from '@linode/api-v4'; 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 6cef52543ac..a0e95b46b21 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -1,4 +1,5 @@ import { createImage, getLinodeDisks, resizeLinodeDisk } from '@linode/api-v4'; +import { createLinodeRequestFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { interceptGetAccountAvailability } from 'support/intercepts/account'; import { interceptGetAllImages } from 'support/intercepts/images'; @@ -20,7 +21,6 @@ import { randomLabel, randomPhrase, randomString } from 'support/util/random'; import { chooseRegion, getRegionByLabel } from 'support/util/regions'; import { getFilteredImagesForImageSelect } from 'src/components/ImageSelect/utilities'; -import { createLinodeRequestFactory } from 'src/factories'; import type { Image } from '@linode/api-v4'; 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 e52fe6cc512..17d552e1620 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,5 @@ import { createVolume } from '@linode/api-v4/lib/volumes'; +import { createLinodeRequestFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { interceptGetLinodeConfigs } from 'support/intercepts/configs'; import { @@ -11,7 +12,6 @@ import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { createLinodeRequestFactory } from 'src/factories/linodes'; import { volumeRequestPayloadFactory } from 'src/factories/volume'; import type { Linode, Volume } from '@linode/api-v4'; diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume-encryption.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume-encryption.spec.ts new file mode 100644 index 00000000000..bfd3af1a891 --- /dev/null +++ b/packages/manager/cypress/e2e/core/volumes/create-volume-encryption.spec.ts @@ -0,0 +1,331 @@ +/** + * @file UI tests involving Volume creation with Block Storage Encryption functionality. + */ +import { linodeFactory, regionFactory } from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeDetails, + mockGetLinodeDisks, + mockGetLinodeVolumes, + mockGetLinodes, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetVolume, mockGetVolumes } from 'support/intercepts/volumes'; +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; + +import { + accountFactory, + linodeDiskFactory, + volumeFactory, +} from 'src/factories'; + +import type { Linode } from '@linode/api-v4'; + +/** + * Notice text that is expected to appear upon attempting to attach encrypted Volume to Linode without BSE capability. + */ +const CLIENT_LIBRARY_UPDATE_COPY = + 'This Linode requires a client library update and will need to be rebooted prior to attaching an encrypted volume.'; + +describe('Volume creation with Block Storage Encryption', () => { + describe('Reboot notice', () => { + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Block Storage Encryption'], + }); + + const mockRegion = regionFactory.build({ + capabilities: ['Linodes', 'Block Storage Encryption', 'Block Storage'], + }); + + const mockLinodeWithoutCapability = linodeFactory.build({ + capabilities: [], + label: randomLabel(), + region: mockRegion.id, + }); + + const mockLinodeWithCapability: Linode = { + ...mockLinodeWithoutCapability, + capabilities: ['Block Storage Encryption'], + }; + + const mockVolumeEncrypted = volumeFactory.build({ + encryption: 'enabled', + label: randomLabel(), + region: mockRegion.id, + }); + + /* + * Tests that confirm that the Linode reboot notice appears when expected. + * + * Some Linodes lack the capability to support Block Storage Encryption. The + * capability can be added, however, by rebooting the affected Linode(s) to + * allow a client library update to take place that enables support for + * encryption. + * + * These tests confirm that users are informed of this requirement and + * are prevented from completing Volume create/attach flows when this + * requirement is not met. + */ + describe('Notice is shown when expected', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + blockStorageEncryption: true, + }); + mockGetAccount(mockAccount); + mockGetRegions([mockRegion]); + mockGetLinodes([mockLinodeWithoutCapability]); + mockGetLinodeDetails( + mockLinodeWithoutCapability.id, + mockLinodeWithoutCapability + ); + mockGetLinodeVolumes(mockLinodeWithoutCapability.id, []); + mockGetLinodeDisks(mockLinodeWithoutCapability.id, [ + linodeDiskFactory.build(), + ]); + }); + + /* + * - Confirms notice appears when creating and attaching a new Volume via the Volume create page. + * - Confirms submit button is disabled while notice is present. + */ + it('shows notice on Volume create page when attaching new Volume to Linode without BSE', () => { + mockGetVolumes([]); + cy.visitWithLogin('/volumes/create'); + + // Select a region, then select a Linode that does not have the BSE capability. + ui.autocomplete.findByLabel('Region').type(mockRegion.label); + + ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click(); + + ui.autocomplete + .findByLabel('Linode') + .type(mockLinodeWithoutCapability.label); + + ui.autocompletePopper.find().within(() => { + cy.findByText(mockLinodeWithoutCapability.label).click(); + }); + + // Confirm that reboot notice is absent before clicking the "Encrypt Volume" checkbox. + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + + cy.findByText('Encrypt Volume').should('be.visible').click(); + + // Confirm that reboot notice appears after clicking the "Encrypt Volume" checkbox, + // and that Volume create submit button remains disabled. + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); + + ui.button + .findByTitle('Create Volume') + .scrollIntoView() + .should('be.visible') + .should('be.disabled'); + }); + + /* + * - Confirms notice appears when attaching an existing Volume via the Linode details page. + * - Confirms submit button is disabled while notice is present. + */ + it('shows notice on Linode details page when attaching existing Volume to Linode without BSE', () => { + mockGetVolumes([mockVolumeEncrypted]); + mockGetVolume(mockVolumeEncrypted); + cy.visitWithLogin(`/linodes/${mockLinodeWithoutCapability.id}/storage`); + + ui.button + .findByTitle('Add Volume') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Create Volume for ${mockLinodeWithoutCapability.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Attach Existing Volume') + .should('be.visible') + .click(); + + // Confirm that reboot notice is absent before Volume is selected. + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + + ui.autocomplete + .findByLabel('Volume') + .type(mockVolumeEncrypted.label); + + ui.autocompletePopper.find().within(() => { + cy.findByText(mockVolumeEncrypted.label) + .should('be.visible') + .click(); + }); + + // Confirm that selecting an encrypted Volume triggers reboot notice to appear. + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); + ui.button + .findByTitle('Attach Volume') + .scrollIntoView() + .should('be.disabled'); + }); + }); + + /* + * - Confirms notice appears when creating and attaching a new Volume via the Linode details page. + * - Confirms submit button is disabled while notice is present. + */ + it('shows notice on Linode details page when creating new Volume and attaching to Linode without BSE', () => { + mockGetVolumes([]); + cy.visitWithLogin(`/linodes/${mockLinodeWithoutCapability.id}/storage`); + + ui.button + .findByTitle('Add Volume') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Create Volume for ${mockLinodeWithoutCapability.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Create and Attach Volume').should('be.checked'); + + // Confirm that reboot notice is absent before encryption is selected. + cy.findByLabelText('Encrypt Volume').should('not.be.checked'); + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + + // Click the "Encrypt Volume" checkbox and confirm that notice appears. + cy.findByText('Encrypt Volume').should('be.visible').click(); + + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); + ui.button + .findByTitle('Create Volume') + .scrollIntoView() + .should('be.disabled'); + }); + }); + }); + + /* + * Tests that confirm that the Linode reboot notice is not shown when it shouldn't be. + * + * These tests confirm that users are not shown the Linode reboot notice when attaching + * encrypted Volumes to Linodes that already have the block storage encryption capability, + * and they are not prevented from attaching Volumes to Linodes in these cases. + */ + describe('Reboot notice is absent when expected', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + blockStorageEncryption: true, + }); + mockGetAccount(mockAccount); + mockGetRegions([mockRegion]); + mockGetLinodes([mockLinodeWithCapability]); + mockGetLinodeDetails( + mockLinodeWithCapability.id, + mockLinodeWithCapability + ); + mockGetLinodeVolumes(mockLinodeWithCapability.id, []); + mockGetLinodeDisks(mockLinodeWithCapability.id, [ + linodeDiskFactory.build(), + ]); + }); + + /* + * - Confirms notice appears is absent when creating and attaching a new Volume via the Volume create page. + */ + it('does not show notice on Volume create page when attaching new Volume to Linode with BSE', () => { + mockGetVolumes([]); + cy.visitWithLogin('/volumes/create'); + + // Select a region, then select a Linode that has the BSE capability. + ui.autocomplete.findByLabel('Region').type(mockRegion.label); + + ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click(); + + ui.autocomplete + .findByLabel('Linode') + .type(mockLinodeWithCapability.label); + + ui.autocompletePopper.find().within(() => { + cy.findByText(mockLinodeWithCapability.label).click(); + }); + + cy.findByText('Encrypt Volume').should('be.visible').click(); + + // Confirm that reboot notice is absent after checking "Encrypt Volume", + // and the "Create Volume" button is enabled. + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + + ui.button.findByTitle('Create Volume').should('be.enabled'); + }); + + /* + * - Confirms notice is absent when attaching an existing Volume via the Linode details page. + */ + it('does not show notice on Linode details page when attaching existing Volume to Linode with BSE', () => { + mockGetVolumes([mockVolumeEncrypted]); + mockGetVolume(mockVolumeEncrypted); + cy.visitWithLogin(`/linodes/${mockLinodeWithCapability.id}/storage`); + + ui.button + .findByTitle('Add Volume') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Create Volume for ${mockLinodeWithCapability.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Attach Existing Volume') + .should('be.visible') + .click(); + + ui.autocomplete + .findByLabel('Volume') + .type(mockVolumeEncrypted.label); + + ui.autocompletePopper.find().within(() => { + cy.findByText(mockVolumeEncrypted.label) + .should('be.visible') + .click(); + }); + + // Confirm that reboot notice is absent and submit button is enabled. + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + ui.button.findByTitle('Attach Volume').should('be.enabled'); + }); + }); + + /* + * - Confirms notice is absent when creating and attaching a new Volume via the Linode details page. + */ + it('does not show notice on Linode details page when creating new Volume and attaching to Linode with BSE', () => { + mockGetVolumes([]); + cy.visitWithLogin(`/linodes/${mockLinodeWithCapability.id}/storage`); + + ui.button + .findByTitle('Add Volume') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Create Volume for ${mockLinodeWithCapability.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Create and Attach Volume').should('be.checked'); + + // Confirm that reboot notice is absent before encryption is selected. + cy.findByLabelText('Encrypt Volume').should('not.be.checked'); + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + + // Click the "Encrypt Volume" checkbox and confirm that notice appears. + cy.findByText('Encrypt Volume').should('be.visible').click(); + + cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); + ui.button.findByTitle('Create Volume').should('be.enabled'); + }); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts index c4aecfc2eaa..df637091da3 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts @@ -1,11 +1,9 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { - linodeFactory, - volumeFactory, - volumeTypeFactory, -} from '@src/factories'; +import { linodeFactory } from '@linode/utilities'; +import { volumeFactory, volumeTypeFactory } from '@src/factories'; import { mockGetLinodeDetails, + mockGetLinodeDisks, mockGetLinodeVolumes, mockGetLinodes, } from 'support/intercepts/linodes'; @@ -19,14 +17,13 @@ import { } from 'support/intercepts/volumes'; import { ui } from 'support/ui'; import { randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT, UNKNOWN_PRICE, } from 'src/utilities/pricing/constants'; -const region = 'US, Newark, NJ'; - /** * Asserts that a volume is listed and has the expected config information. * @@ -38,6 +35,7 @@ const region = 'US, Newark, NJ'; */ const validateBasicVolume = ( volumeLabel: string, + regionLabel: string, attachedLinodeLabel?: string ) => { const attached = attachedLinodeLabel ?? 'Unattached'; @@ -53,7 +51,7 @@ const validateBasicVolume = ( cy.findByText(volumeLabel) .closest('tr') .within(() => { - cy.findByText(region).should('be.visible'); + cy.findByText(regionLabel).should('be.visible'); cy.findByText(attached).should('be.visible'); }); }; @@ -81,7 +79,11 @@ const localStorageOverrides = { describe('volumes', () => { it('creates a volume without linode from volumes page', () => { - const mockVolume = volumeFactory.build({ label: randomLabel() }); + const mockRegion = chooseRegion({ capabilities: ['Block Storage'] }); + const mockVolume = volumeFactory.build({ + label: randomLabel(), + region: mockRegion.id, + }); const mockVolumeTypes = volumeTypeFactory.buildList(1); mockGetVolumes([]).as('getVolumes'); @@ -110,12 +112,16 @@ describe('volumes', () => { cy.findByText('Must provide a region or a Linode ID.').should('be.visible'); - ui.regionSelect.find().click().type('newark{enter}'); + ui.regionSelect.find().click().type(`${mockRegion.label}`); + ui.regionSelect + .findItemByRegionId(mockRegion.id) + .should('be.visible') + .click(); mockGetVolumes([mockVolume]).as('getVolumes'); ui.button.findByTitle('Create Volume').should('be.visible').click(); cy.wait(['@createVolume', '@getVolume', '@getVolumes']); - validateBasicVolume(mockVolume.label); + validateBasicVolume(mockVolume.label, mockRegion.label); ui.actionMenu .findByTitle(`Action menu for Volume ${mockVolume.label}`) @@ -126,18 +132,22 @@ describe('volumes', () => { }); it('creates volume from linode details', () => { + const mockRegion = chooseRegion({ capabilities: ['Block Storage'] }); const mockLinode = linodeFactory.build({ id: randomNumber(), label: randomLabel(), + region: mockRegion.id, }); const newVolume = volumeFactory.build({ label: randomLabel(), linode_id: mockLinode.id, + region: mockRegion.id, }); mockCreateVolume(newVolume).as('createVolume'); mockGetLinodes([mockLinode]).as('getLinodes'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinodeDetail'); + mockGetLinodeDisks(mockLinode.id, []); mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); cy.visitWithLogin('/linodes', { @@ -185,11 +195,16 @@ describe('volumes', () => { }); it('detaches attached volume', () => { - const mockLinode = linodeFactory.build({ label: randomLabel() }); + const mockRegion = chooseRegion({ capabilities: ['Block Storage'] }); + const mockLinode = linodeFactory.build({ + label: randomLabel(), + region: mockRegion.id, + }); const mockAttachedVolume = volumeFactory.build({ label: randomLabel(), linode_id: mockLinode.id, linode_label: mockLinode.label, + region: mockRegion.id, }); mockDetachVolume(mockAttachedVolume.id).as('detachVolume'); @@ -232,7 +247,11 @@ describe('volumes', () => { }); it('does not allow creation of a volume with invalid pricing from volumes landing', () => { - const mockVolume = volumeFactory.build({ label: randomLabel() }); + const mockRegion = chooseRegion({ capabilities: ['Block Storage'] }); + const mockVolume = volumeFactory.build({ + label: randomLabel(), + region: mockRegion.id, + }); mockGetVolumes([]).as('getVolumes'); mockCreateVolume(mockVolume).as('createVolume'); @@ -248,7 +267,11 @@ describe('volumes', () => { cy.url().should('endWith', 'volumes/create'); - ui.regionSelect.find().click().type('newark{enter}'); + ui.regionSelect.find().click().type(mockRegion.label); + ui.regionSelect + .findItemByRegionId(mockRegion.id) + .should('be.visible') + .click(); cy.wait(['@getVolumeTypesError']); @@ -263,17 +286,21 @@ describe('volumes', () => { }); it('does not allow creation of a volume with invalid pricing from linode details', () => { + const mockRegion = chooseRegion({ capabilities: ['Block Storage'] }); const mockLinode = linodeFactory.build({ id: randomNumber(), label: randomLabel(), + region: mockRegion.id, }); const newVolume = volumeFactory.build({ label: randomLabel(), + region: mockRegion.id, }); mockCreateVolume(newVolume).as('createVolume'); mockGetLinodes([mockLinode]).as('getLinodes'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinodeDetail'); + mockGetLinodeDisks(mockLinode.id, []); mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); // Mock an error response to the /types endpoint so prices cannot be calculated. mockGetVolumeTypesError().as('getVolumeTypesError'); 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 3fcdaad7eaf..8a12ff4f8ee 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -1,3 +1,4 @@ +import { createLinodeRequestFactory } from '@linode/utilities'; import { accountUserFactory, grantsFactory, @@ -6,35 +7,18 @@ import { import { authenticate } from 'support/api/authentication'; import { entityTag } from 'support/constants/cypress'; import { mockGetUser } from 'support/intercepts/account'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { - mockGetLinodeDetails, - mockGetLinodes, -} from 'support/intercepts/linodes'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { - interceptCreateVolume, - mockGetVolume, - mockGetVolumes, -} from 'support/intercepts/volumes'; +import { interceptCreateVolume } from 'support/intercepts/volumes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { accountFactory, regionFactory, volumeFactory } from 'src/factories'; -import { - createLinodeRequestFactory, - linodeFactory, -} from 'src/factories/linodes'; - -import type { Linode, Region } from '@linode/api-v4'; +import type { Linode } from '@linode/api-v4'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -43,18 +27,6 @@ const pageSizeOverride = { PAGE_SIZE: 100, }; -const mockRegions: Region[] = [ - regionFactory.build({ - capabilities: ['Linodes', 'Block Storage', 'Block Storage Encryption'], - id: 'us-east', - label: 'Newark, NJ', - site_type: 'core', - }), -]; - -const CLIENT_LIBRARY_UPDATE_COPY = - 'This Linode requires a client library update and will need to be rebooted prior to attaching an encrypted volume.'; - authenticate(); describe('volume create flow', () => { before(() => { @@ -156,10 +128,6 @@ describe('volume create flow', () => { .should('be.visible') .click(); - // @TODO BSE: once BSE is fully rolled out, check for the notice (selected linode doesn't have - // "Block Storage Encryption" capability + user checked "Encrypt Volume" checkbox) instead of the absence of it - cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); - cy.findByText('Create Volume').click(); cy.wait('@createVolume'); @@ -189,195 +157,6 @@ describe('volume create flow', () => { ); }); - /* - * - Checks for Block Storage Encryption client library update notice on the Volume Create page. - */ - it('displays a warning notice on Volume Create page re: rebooting for client library updates under the appropriate conditions', () => { - // Conditions: Block Storage encryption feature flag is on; user has Block Storage Encryption capability; volume being created is encrypted and the - // selected Linode does not support Block Storage Encryption - - // Mock feature flag -- @TODO BSE: Remove feature flag once BSE is fully rolled out - mockAppendFeatureFlags({ - blockStorageEncryption: true, - }).as('getFeatureFlags'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Block Storage Encryption'], - }); - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions).as('getRegions'); - - const linodeRequest = createLinodeRequestFactory.build({ - booted: false, - label: randomLabel(), - region: mockRegions[0].id, - root_pass: randomString(16), - }); - - cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( - (linode: Linode) => { - cy.visitWithLogin('/volumes/create'); - cy.wait(['@getFeatureFlags', '@getAccount']); - - // Select a linode without the BSE capability - cy.findByLabelText('Linode').should('be.visible').click(); - cy.focused().type(linode.label); - - ui.autocompletePopper - .findByTitle(linode.label) - .should('be.visible') - .click(); - - // Check the "Encrypt Volume" checkbox - cy.get('[data-qa-checked]').should('be.visible').click(); - // }); - - // Ensure warning notice is displayed and "Create Volume" button is disabled - cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); - ui.button - .findByTitle('Create Volume') - .should('be.visible') - .should('be.disabled'); - } - ); - }); - - /* - * - Checks for absence of Block Storage Encryption client library update notice on the Volume Create page - * when selected linode supports BSE - */ - it('does not display a warning notice on Volume Create page re: rebooting for client library updates when selected linode supports BSE', () => { - // Conditions: Block Storage encryption feature flag is on; user has Block Storage Encryption capability; volume being created is encrypted and the - // selected Linode supports Block Storage Encryption - - // Mock feature flag -- @TODO BSE: Remove feature flag once BSE is fully rolled out - mockAppendFeatureFlags({ - blockStorageEncryption: true, - }).as('getFeatureFlags'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Block Storage Encryption'], - }); - - // Mock linode - const mockLinode = linodeFactory.build({ - capabilities: ['Block Storage Encryption'], - id: 123456, - region: mockRegions[0].id, - }); - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions).as('getRegions'); - mockGetLinodes([mockLinode]).as('getLinodes'); - mockGetLinodeDetails(mockLinode.id, mockLinode); - - cy.visitWithLogin(`/volumes/create`); - cy.wait(['@getAccount', '@getRegions', '@getLinodes']); - - // Select a linode without the BSE capability - cy.findByLabelText('Linode').should('be.visible').click(); - cy.focused().type(mockLinode.label); - - ui.autocompletePopper - .findByTitle(mockLinode.label) - .should('be.visible') - .click(); - - // Check the "Encrypt Volume" checkbox - cy.get('[data-qa-checked]').should('be.visible').click(); - // }); - - // Ensure warning notice is not displayed and "Create Volume" button is enabled - cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); - ui.button - .findByTitle('Create Volume') - .should('be.visible') - .should('be.enabled'); - }); - - /* - * - Checks for Block Storage Encryption client library update notice in the Create/Attach Volume drawer from the - 'Storage' details page of an existing Linode. - */ - it('displays a warning notice re: rebooting for client library updates under the appropriate conditions in Create/Attach Volume drawer', () => { - // Conditions: Block Storage encryption feature flag is on; user has Block Storage Encryption capability; Linode does not support Block Storage Encryption and the user is trying to attach an encrypted volume - - // Mock feature flag -- @TODO BSE: Remove feature flag once BSE is fully rolled out - mockAppendFeatureFlags({ - blockStorageEncryption: true, - }).as('getFeatureFlags'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Block Storage Encryption'], - }); - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions).as('getRegions'); - - const volume = volumeFactory.build({ - encryption: 'enabled', - region: mockRegions[0].id, - }); - - const linodeRequest = createLinodeRequestFactory.build({ - booted: false, - label: randomLabel(), - region: mockRegions[0].id, - root_pass: randomString(16), - }); - - cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( - (linode: Linode) => { - mockGetVolumes([volume]).as('getVolumes'); - mockGetVolume(volume); - - cy.visitWithLogin(`/linodes/${linode.id}/storage`); - cy.wait(['@getFeatureFlags', '@getAccount']); - - // Click "Add Volume" button - cy.findByText('Add Volume').click(); - - // Check "Encrypt Volume" checkbox - cy.get('[data-qa-drawer="true"]').within(() => { - cy.get('[data-qa-checked]').should('be.visible').click(); - }); - - // Ensure client library update notice is displayed and the "Create Volume" button is disabled - cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); - ui.button.findByTitle('Create Volume').should('be.disabled'); - - // Ensure notice is cleared when switching views in drawer - cy.get('[data-qa-radio="Attach Existing Volume"]').click(); - cy.wait(['@getVolumes']); - cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('not.exist'); - ui.button - .findByTitle('Attach Volume') - .should('be.visible') - .should('be.enabled'); - - // Ensure notice is displayed in "Attach Existing Volume" view when an encrypted volume is selected, & that the "Attach Volume" button is disabled - cy.findByPlaceholderText('Select a Volume') - .should('be.visible') - .click(); - cy.focused().type(`${volume.label}{downarrow}{enter}`); - ui.autocompletePopper - .findByTitle(volume.label) - .should('be.visible') - .click(); - - cy.findByText(CLIENT_LIBRARY_UPDATE_COPY).should('be.visible'); - ui.button - .findByTitle('Attach Volume') - .should('be.visible') - .should('be.disabled'); - } - ); - }); - /* * - Creates a volume from the 'Storage' details page of an existing Linode. * - Confirms that volume is listed correctly on Linode 'Storage' details page. diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts index 5370154172f..63887775207 100644 --- a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts @@ -3,6 +3,7 @@ import { authenticate } from 'support/api/authentication'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; import type { Volume } from '@linode/api-v4'; @@ -20,15 +21,16 @@ describe('Search Volumes', () => { */ it('creates two volumes and make sure they show up in the table and are searchable', () => { const createTwoVolumes = async (): Promise<[Volume, Volume]> => { + const volumeRegion = chooseRegion(); return Promise.all([ createVolume({ label: randomLabel(), - region: 'us-east', + region: volumeRegion.id, size: 10, }), createVolume({ label: randomLabel(), - region: 'us-east', + region: volumeRegion.id, size: 10, }), ]); diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index 14c5f8b7b0f..f6ff9e71a2d 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -1,6 +1,6 @@ +import { linodeFactory } from '@linode/utilities'; import { eventFactory, - linodeFactory, notificationFactory, volumeFactory, } from '@src/factories'; @@ -16,10 +16,14 @@ import { mockMigrateVolumes, } from 'support/intercepts/volumes'; import { ui } from 'support/ui'; +import { chooseRegion } from 'support/util/regions'; describe('volume upgrade/migration', () => { it('can upgrade an unattached volume to NVMe', () => { - const volume = volumeFactory.build(); + const mockRegion = chooseRegion({ capabilities: ['Block Storage'] }); + const volume = volumeFactory.build({ + region: mockRegion.id, + }); const migrationScheduledNotification = notificationFactory.build({ entity: { id: volume.id, type: 'volume' }, @@ -96,10 +100,14 @@ describe('volume upgrade/migration', () => { }); it('can upgrade an attached volume from the volumes landing page', () => { - const linode = linodeFactory.build(); + const mockRegion = chooseRegion({ capabilities: ['Block Storage'] }); + const linode = linodeFactory.build({ + region: mockRegion.id, + }); const volume = volumeFactory.build({ linode_id: linode.id, linode_label: linode.label, + region: mockRegion.id, }); const migrationScheduledNotification = notificationFactory.build({ @@ -110,6 +118,7 @@ describe('volume upgrade/migration', () => { mockGetVolumes([volume]).as('getVolumes'); mockMigrateVolumes().as('migrateVolumes'); mockGetLinodeDetails(linode.id, linode).as('getLinode'); + mockGetLinodeVolumes(linode.id, [volume]); mockGetLinodeDisks(linode.id, []); mockGetNotifications([migrationScheduledNotification]).as( 'getNotifications' @@ -142,7 +151,6 @@ describe('volume upgrade/migration', () => { `A Volume attached to Linode ${linode.label} will be upgraded to high-performance NVMe Block Storage.`, { exact: false } ).should('be.visible'); - ui.button .findByTitle('Enter Upgrade Queue') .should('be.visible') @@ -187,10 +195,14 @@ describe('volume upgrade/migration', () => { }); it('can upgrade an attached volume from the linode details page', () => { - const linode = linodeFactory.build(); + const mockRegion = chooseRegion({ capabilities: ['Block Storage'] }); + const linode = linodeFactory.build({ + region: mockRegion.id, + }); const volume = volumeFactory.build({ linode_id: linode.id, linode_label: linode.label, + region: mockRegion.id, }); const migrationScheduledNotification = notificationFactory.build({ diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts index bfa81f820c7..a355f77a294 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts @@ -2,12 +2,8 @@ * @file Integration tests for VPC create flow. */ -import { - linodeFactory, - regionFactory, - subnetFactory, - vpcFactory, -} from '@src/factories'; +import { linodeFactory, regionFactory } from '@linode/utilities'; +import { subnetFactory, vpcFactory } from '@src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreateVPC, 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 affd4240eae..dcc5323773a 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -1,13 +1,9 @@ import { linodeConfigInterfaceFactory, linodeConfigInterfaceFactoryWithVPC, -} from '@linode/utilities'; -import { - linodeConfigFactory, linodeFactory, - subnetFactory, - vpcFactory, -} from '@src/factories'; +} from '@linode/utilities'; +import { linodeConfigFactory, subnetFactory, vpcFactory } from '@src/factories'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { mockGetLinodeDetails } from 'support/intercepts/linodes'; import { @@ -22,8 +18,7 @@ import { } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; -import { getRegionById } from 'support/util/regions'; -import { chooseRegion } from 'support/util/regions'; +import { chooseRegion, getRegionById } from 'support/util/regions'; import { WARNING_ICON_UNRECOMMENDED_CONFIG } from 'src/features/VPCs/constants'; 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 25a1a075727..840b1d7f09b 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -2,13 +2,11 @@ * @file Integration tests for VPC assign/unassign Linodes flows. */ -import { linodeConfigInterfaceFactoryWithVPC } from '@linode/utilities'; import { - linodeConfigFactory, + linodeConfigInterfaceFactoryWithVPC, linodeFactory, - subnetFactory, - vpcFactory, -} from '@src/factories'; +} from '@linode/utilities'; +import { linodeConfigFactory, subnetFactory, vpcFactory } from '@src/factories'; import { vpcAssignLinodeRebootNotice, vpcUnassignLinodeRebootNotice, diff --git a/packages/manager/cypress/support/api/linodes.ts b/packages/manager/cypress/support/api/linodes.ts index 9fbe30f3302..65e7a30bcc2 100644 --- a/packages/manager/cypress/support/api/linodes.ts +++ b/packages/manager/cypress/support/api/linodes.ts @@ -1,5 +1,5 @@ import { Linode, deleteLinode, getLinodes } from '@linode/api-v4'; -import { linodeFactory } from '@src/factories'; +import { linodeFactory } from '@linode/utilities'; import { makeResourcePage } from '@src/mocks/serverHandlers'; import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; diff --git a/packages/manager/cypress/support/api/nodebalancers.ts b/packages/manager/cypress/support/api/nodebalancers.ts index 37ff6c420d2..d8c55b8922c 100644 --- a/packages/manager/cypress/support/api/nodebalancers.ts +++ b/packages/manager/cypress/support/api/nodebalancers.ts @@ -1,4 +1,5 @@ import { deleteNodeBalancer, getNodeBalancers } from '@linode/api-v4'; +import { nodeBalancerFactory } from '@linode/utilities'; import { oauthToken, pageSize } from 'support/constants/api'; import { entityTag } from 'support/constants/cypress'; import { depaginate } from 'support/util/paginate'; @@ -6,7 +7,7 @@ import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { isTestLabel } from './common'; -import { nodeBalancerFactory } from 'src/factories'; + import type { NodeBalancer } from '@linode/api-v4'; export const makeNodeBalCreateReq = (nodeBal: NodeBalancer) => { diff --git a/packages/manager/cypress/support/component/setup.tsx b/packages/manager/cypress/support/component/setup.tsx index 1fc6e7ece96..87fdadf9db1 100644 --- a/packages/manager/cypress/support/component/setup.tsx +++ b/packages/manager/cypress/support/component/setup.tsx @@ -35,7 +35,7 @@ import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; import { storeFactory } from 'src/store'; import type { ThemeName } from '@linode/ui'; -import type { AnyRouter } from '@tanstack/react-router'; +import type { AnyRoute, AnyRouter } from '@tanstack/react-router'; import type { Flags } from 'src/featureFlags'; /** @@ -48,7 +48,8 @@ export const mountWithTheme = ( jsx: React.ReactNode, theme: ThemeName = 'light', flags: Partial = {}, - useTanstackRouter: boolean = false + useTanstackRouter: boolean = false, + routeTree?: (parentRoute: AnyRoute) => AnyRoute[] ) => { const queryClient = queryClientFactory(); const store = storeFactory(); @@ -59,10 +60,13 @@ export const mountWithTheme = ( path: '/', }); const router: AnyRouter = createRouter({ + defaultNotFoundComponent: () =>
Not Found
, history: createMemoryHistory({ initialEntries: ['/'], }), - routeTree: rootRoute.addChildren([indexRoute]), + routeTree: routeTree + ? rootRoute.addChildren([indexRoute, ...routeTree(indexRoute)]) + : rootRoute.addChildren([indexRoute]), }); return mount( diff --git a/packages/manager/cypress/support/constants/databases.ts b/packages/manager/cypress/support/constants/databases.ts index e463f331750..e34533ba34d 100644 --- a/packages/manager/cypress/support/constants/databases.ts +++ b/packages/manager/cypress/support/constants/databases.ts @@ -344,16 +344,6 @@ export const databaseConfigurations: databaseClusterConfiguration[] = [ region: chooseRegion({ capabilities: ['Managed Databases'] }), version: '5', }, - // { - // label: randomLabel(), - // linodeType: 'g6-dedicated-16', - // clusterSize: 1, - // dbType: 'mongodb', - // regionTypeahead: 'Atlanta', - // region: 'us-southeast', - // engine: 'MongoDB', - // version: '4', - // }, { clusterSize: 3, dbType: 'postgresql', diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 584fee1378f..9c8ea564874 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -2,7 +2,7 @@ * @file Constants related to DC-specific pricing. */ -import { linodeTypeFactory } from '@src/factories'; +import { linodeTypeFactory } from '@linode/utilities'; import type { LkePlanDescription } from 'support/api/lke'; diff --git a/packages/manager/cypress/support/e2e.ts b/packages/manager/cypress/support/e2e.ts index 5996d3d71aa..0c614affa44 100644 --- a/packages/manager/cypress/support/e2e.ts +++ b/packages/manager/cypress/support/e2e.ts @@ -14,6 +14,8 @@ // *********************************************************** import '@testing-library/cypress/add-commands'; +// reporter needs to register for events in order to attach media to test results in html report +import 'cypress-mochawesome-reporter/register'; // Cypress command and assertion setup. import chaiString from 'chai-string'; import 'cypress-axe'; diff --git a/packages/manager/cypress/support/intercepts/firewalls.ts b/packages/manager/cypress/support/intercepts/firewalls.ts index 6ffe43ada71..490c1fb11bc 100644 --- a/packages/manager/cypress/support/intercepts/firewalls.ts +++ b/packages/manager/cypress/support/intercepts/firewalls.ts @@ -37,6 +37,29 @@ export const mockGetFirewalls = ( ); }; +/** + * Intercepts GET request to fetch a Linode Interface's Firewalls + * + * @param linodeId - The ID of the Linode + * @param interfaceId - The ID of the Linode Interface + * @param firewalls - The Firewalls assigned to the LinodeInterface + * + * @returns Cypress chainable. + */ +export const mockGetLinodeInterfaceFirewalls = ( + linodeId: number, + interfaceId: number, + firewalls: Firewall[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher( + `linode/instances/${linodeId}/interfaces/${interfaceId}/firewalls` + ), + paginateResponse(firewalls) + ); +}; + /** * Intercepts POST request to create a Firewall and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index b9433352cab..163dbb07136 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -13,6 +13,8 @@ import type { Firewall, Kernel, Linode, + LinodeInterface, + LinodeInterfaces, LinodeIPsResponse, LinodeType, Volume, @@ -117,7 +119,7 @@ export const interceptGetLinodes = (): Cypress.Chainable => { export const mockGetLinodes = (linodes: Linode[]): Cypress.Chainable => { return cy.intercept( 'GET', - apiMatcher('linode/instances/**'), + apiMatcher('linode/instances*'), paginateResponse(linodes) ); }; @@ -641,3 +643,41 @@ export const interceptCancelLinodeBackups = ( apiMatcher(`linode/instances/${linodeId}/backups/cancel`) ); }; + +/** + * Mocks GET request to get a Linode's Interfaces. + * + * @param linodeId - ID of Linode to get interfaces associated with it + * @param interfaces - the mocked Linode interfaces + * + * @returns Cypress Chainable. + */ +export const mockGetLinodeInterfaces = ( + linodeId: number, + interfaces: LinodeInterfaces +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/interfaces`), + interfaces + ); +}; + +/** + * Intercepts POST request to create a Linode Interface. + * + * @param linodeId - the Linodes ID to add the interface to. + * @param linodeInterface - a mock linode interface object. + * + * @returns Cypress chainable. + */ +export const mockCreateLinodeInterface = ( + linodeId: number, + linodeInterface: LinodeInterface +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/interfaces`), + makeResponse(linodeInterface) + ); +}; diff --git a/packages/manager/cypress/support/plugins/configure-multi-reporters.ts b/packages/manager/cypress/support/plugins/configure-multi-reporters.ts new file mode 100644 index 00000000000..262f50fc223 --- /dev/null +++ b/packages/manager/cypress/support/plugins/configure-multi-reporters.ts @@ -0,0 +1,33 @@ +import { CypressPlugin } from './plugin'; +// The name of the environment variable to read when checking report configuration. +const envVarJunit = 'CY_TEST_JUNIT_REPORT'; +const envVarHtml = 'CY_TEST_HTML_REPORT'; + +/** + * Configure multiple reporters to be used by Cypress + * Multireporter uses between 0 and 2 reporters (junit, html) + * and for either core or component directory + * + * @returns Cypress configuration object. + */ +export const configureMultiReporters: CypressPlugin = (_on, config) => { + const arrReporters = []; + if (config.env[envVarJunit]) { + console.log('Junit reporting configuration added.'); + arrReporters.push('mocha-junit-reporter'); + } + if (config.env[envVarHtml]) { + console.log('Html reporting configuration added.'); + arrReporters.push('cypress-mochawesome-reporter'); + } + if (arrReporters.length > 0) { + config.reporter = 'cypress-multi-reporters'; + if (!config.reporterOptions) { + config.reporterOptions = {}; + } + config.reporterOptions.reporterEnabled = arrReporters.join(', '); + } else { + console.log('No reporters configured.'); + } + return config; +}; diff --git a/packages/manager/cypress/support/plugins/configure-test-suite.ts b/packages/manager/cypress/support/plugins/configure-test-suite.ts deleted file mode 100644 index 0e431ee07a9..00000000000 --- a/packages/manager/cypress/support/plugins/configure-test-suite.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { CypressPlugin } from './plugin'; - -// The name of the environment variable to read when checking suite configuration. -const envVarName = 'CY_TEST_SUITE'; - -/** - * Overrides the Cypress test suite according to `CY_TEST_SUITE` environment variable. - * - * If `CY_TEST_SUITE` is undefined or invalid, the 'core' test suite will be run - * by default. - * - * The resolved test suite name can be read by tests and other plugins via - * `Cypress.env('cypress_test_suite')`. - * - * @returns Cypress configuration object. - */ -export const configureTestSuite: CypressPlugin = (_on, config) => { - const suiteName = (() => { - switch (config.env[envVarName]) { - case 'synthetic': - return 'synthetic'; - - case 'core': - default: - if (!!config.env[envVarName] && config.env[envVarName] !== 'core') { - const desiredSuite = config.env[envVarName]; - console.warn( - `Unknown test suite '${desiredSuite}'. Running 'core' test suite instead.` - ); - } - return 'core'; - } - })(); - - config.env['cypress_test_suite'] = suiteName; - config.specPattern = `cypress/e2e/${suiteName}/**/*.spec.{ts,tsx}`; - return config; -}; diff --git a/packages/manager/cypress/support/plugins/html-report.ts b/packages/manager/cypress/support/plugins/html-report.ts new file mode 100644 index 00000000000..ebcd7a528c2 --- /dev/null +++ b/packages/manager/cypress/support/plugins/html-report.ts @@ -0,0 +1,33 @@ +import { CypressPlugin } from './plugin'; +import cypressReporterLib from 'cypress-mochawesome-reporter/lib'; +const { beforeRunHook, afterRunHook } = cypressReporterLib; + +// The name of the environment variable to read when checking report configuration. +const envVarName = 'CY_TEST_HTML_REPORT'; + +/** + * @returns Cypress configuration object. + */ +export const enableHtmlReport: CypressPlugin = async function (on, config) { + if (!!config.env[envVarName]) { + if (!config.reporterOptions) { + config.reporterOptions = {}; + } + config.reporterOptions.cypressMochawesomeReporterReporterOptions = { + reportPageTitle: 'Cloud Manager E2e Test Results', + inlineAssets: true, + embeddedScreenshots: true, + videoOnFailOnly: true, + charts: true, + quiet: true, + }; + on('before:run', async (results) => { + await beforeRunHook(results); + }); + + on('after:run', async () => { + await afterRunHook(); + }); + } + return config; +}; diff --git a/packages/manager/cypress/support/plugins/junit-report.ts b/packages/manager/cypress/support/plugins/junit-report.ts index 33117a64823..e2d1b3393ef 100644 --- a/packages/manager/cypress/support/plugins/junit-report.ts +++ b/packages/manager/cypress/support/plugins/junit-report.ts @@ -8,42 +8,37 @@ const capitalize = (str: string): string => { }; /** - * Returns a plugin to enable JUnit reporting when `CY_TEST_JUNIT_REPORT` is defined. - * - * If no suite name is specified, this function will attempt to determine the - * suite name using the Cypress configuration object. - * - * @param suiteName - Optional suite name in the JUnit output. - * * @returns Cypress configuration object. */ -export const enableJunitReport = ( - suiteName?: string, - jenkinsMode: boolean = false -): CypressPlugin => { - return (_on, config) => { - if (!!config.env[envVarName]) { - // Use `suiteName` if it is specified. - // Otherwise, attempt to determine the test suite name using - // our Cypress configuration. - const testSuite = suiteName || config.env['cypress_test_suite'] || 'core'; - - const testSuiteName = `${capitalize(testSuite)} Test Suite`; +export const enableJunitE2eReport: CypressPlugin = (_on, config) => { + const testSuiteName = 'core'; + return getCommonJunitConfig(testSuiteName, config); +}; - // Cypress doesn't know to look for modules in the root `node_modules` - // directory, so we have to pass a relative path. - // See also: https://github.com/cypress-io/cypress/issues/6406 - config.reporter = 'node_modules/mocha-junit-reporter'; +/** + * @returns Cypress configuration object. + */ +export const enableJunitComponentReport: CypressPlugin = (_on, config) => { + const testSuiteName = 'component'; + return getCommonJunitConfig(testSuiteName, config); +}; - // See also: https://www.npmjs.com/package/mocha-junit-reporter#full-configuration-options - config.reporterOptions = { - mochaFile: 'cypress/results/test-results-[hash].xml', - rootSuiteTitle: 'Cloud Manager Cypress Tests', - testsuitesTitle: testSuiteName, - jenkinsMode, - suiteTitleSeparatedBy: jenkinsMode ? '→' : ' ', - }; +const getCommonJunitConfig = ( + testSuite: string, + config: Cypress.PluginConfigOptions +) => { + if (!!config.env[envVarName]) { + if (!config.reporterOptions) { + config.reporterOptions = {}; } - return config; - }; + const testSuiteName = `${capitalize(testSuite)} Test Suite`; + config.reporterOptions.mochaJunitReporterReporterOptions = { + mochaFile: 'cypress/results/test-results-[hash].xml', + rootSuiteTitle: 'Cloud Manager Cypress Tests', + testsuitesTitle: testSuiteName, + jenkinsMode: true, + suiteTitleSeparatedBy: '→', + }; + } + return config; }; diff --git a/packages/manager/cypress/support/setup/defer-command.ts b/packages/manager/cypress/support/setup/defer-command.ts index bb695ef47a0..1b09f4ab5db 100644 --- a/packages/manager/cypress/support/setup/defer-command.ts +++ b/packages/manager/cypress/support/setup/defer-command.ts @@ -220,6 +220,9 @@ Cypress.Commands.add( return result; }; - return cy.wrap, T>(wrapPromise(), wrapOptions); + return cy.wrap, T>(wrapPromise(), { + timeout: timeoutLength, + ...wrapOptions, + }); } ); diff --git a/packages/manager/cypress/support/util/components.ts b/packages/manager/cypress/support/util/components.ts index 7eca479968c..607c3c019c3 100644 --- a/packages/manager/cypress/support/util/components.ts +++ b/packages/manager/cypress/support/util/components.ts @@ -3,9 +3,9 @@ */ import type { ThemeName } from '@linode/ui'; +import type { AnyRoute } from '@tanstack/react-router'; import type { MountReturn } from 'cypress/react'; import type { Flags } from 'src/featureFlags'; - /** * Array of themes for which to test components. */ @@ -49,11 +49,18 @@ export const componentTests = ( componentName: string, callback: (mountCommand: MountCommand) => void, options: { + routeTree?: (parentRoute: AnyRoute) => AnyRoute[]; useTanstackRouter?: boolean; } = {} ) => { const mountCommand = (jsx: React.ReactNode, flags?: Flags) => - cy.mountWithTheme(jsx, defaultTheme, flags, options.useTanstackRouter); + cy.mountWithTheme( + jsx, + defaultTheme, + flags, + options.useTanstackRouter, + options.routeTree + ); describe(`${componentName} component tests`, () => { callback(mountCommand); }); @@ -71,11 +78,23 @@ export const componentTests = ( * * @param callback - Test scope callback. */ -export const visualTests = (callback: (mountCommand: MountCommand) => void) => { +export const visualTests = ( + callback: (mountCommand: MountCommand) => void, + options: { + routeTree?: (parentRoute: AnyRoute) => AnyRoute[]; + useTanstackRouter?: boolean; + } = {} +) => { describe('Visual tests', () => { componentThemes.forEach((themeName: ThemeName) => { const mountCommand = (jsx: React.ReactNode, flags?: any) => - cy.mountWithTheme(jsx, themeName, flags); + cy.mountWithTheme( + jsx, + themeName, + flags, + options.useTanstackRouter, + options.routeTree + ); describe(`${capitalize(themeName)} theme`, () => { callback(mountCommand); }); diff --git a/packages/manager/cypress/support/util/cypress-mochawesome-reporter.d.ts b/packages/manager/cypress/support/util/cypress-mochawesome-reporter.d.ts new file mode 100644 index 00000000000..383f99e586b --- /dev/null +++ b/packages/manager/cypress/support/util/cypress-mochawesome-reporter.d.ts @@ -0,0 +1 @@ +declare module 'cypress-mochawesome-reporter/lib'; diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index d6b07f2c131..55997e2ca6d 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -1,5 +1,5 @@ import { createLinode, getLinodeConfigs } from '@linode/api-v4'; -import { createLinodeRequestFactory } from '@src/factories'; +import { createLinodeRequestFactory } from '@linode/utilities'; import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; import { findOrCreateDependencyVlan } from 'support/api/vlans'; import { pageSize } from 'support/constants/api'; @@ -8,6 +8,8 @@ import { pollLinodeDiskStatuses, pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; + import { depaginate } from './paginate'; import type { @@ -184,6 +186,7 @@ export const createTestLinode = async ( }, message: `Create Linode '${linode.label}' (ID ${linode.id})`, name: 'createTestLinode', + timeout: LINODE_CREATE_TIMEOUT, }); return { diff --git a/packages/manager/cypress/support/util/regions.ts b/packages/manager/cypress/support/util/regions.ts index 1df135b8ffb..26b80b4ace0 100644 --- a/packages/manager/cypress/support/util/regions.ts +++ b/packages/manager/cypress/support/util/regions.ts @@ -17,7 +17,7 @@ import type { Capabilities, Region } from '@linode/api-v4'; * the `apiLabel` property. * * @see {@link https://github.com/linode/manager/pull/10740|Cloud Manager PR #10740} - * @see {@link src/queries/regions/regions.ts} + * @see {@link packages/queries/src/regions/regions.ts (@linode/queries)} */ export interface ExtendedRegion extends Region { /** Region label as defined by API v4. */ @@ -100,6 +100,36 @@ const disallowedRegionIds = [ // Washington, DC 'us-iad', + + // Atlanta, GA + 'us-southeast', + + // Dallas, TX + 'us-central', + + // Frankfurt, DE + 'eu-central', + + // Fremont, CA + 'us-west', + + // London, GB + 'eu-west', + + // Mumbai, IN + 'ap-west', + + // Newark, NJ + 'us-east', + + // Singapore, SG + 'ap-south', + + // Sydney, AU + 'ap-southeast', + + // Toronto, CA + 'ca-central', ]; /** diff --git a/packages/manager/cypress/support/util/tag.ts b/packages/manager/cypress/support/util/tag.ts index 9be33d52a39..db105961963 100644 --- a/packages/manager/cypress/support/util/tag.ts +++ b/packages/manager/cypress/support/util/tag.ts @@ -7,6 +7,10 @@ const queryRegex = /(?:-|\+)?([^\s]+)/g; * Allowed test tags. */ export type TestTag = + // Environment-related tags. + // Used to identify tests where certain environment-specific features are required. + | 'env:premiumPlans' + // Feature-related tags. // Used to identify tests which deal with a certain feature or features. | 'feat:linodes' diff --git a/packages/manager/package.json b/packages/manager/package.json index 9ba580a9e0b..a4548a39b9e 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.138.1", + "version": "1.139.0", "private": true, "type": "module", "bugs": { @@ -25,18 +25,19 @@ "@hookform/resolvers": "3.9.1", "@linode/api-v4": "workspace:*", "@linode/design-language-system": "^4.0.0", - "@linode/validation": "workspace:*", "@linode/queries": "workspace:*", "@linode/search": "workspace:*", + "@linode/shared": "workspace:*", "@linode/ui": "workspace:*", "@linode/utilities": "workspace:*", + "@linode/validation": "workspace:*", "@lukemorales/query-key-factory": "^1.3.4", "@mui/icons-material": "^6.4.5", "@mui/material": "^6.4.5", "@mui/utils": "^6.4.3", "@mui/x-date-pickers": "^7.27.0", "@paypal/react-paypal-js": "^7.8.3", - "@reach/tabs": "^0.10.5", + "@reach/tabs": "^0.18.0", "@sentry/react": "^7.119.1", "@shikijs/langs": "^3.1.0", "@shikijs/themes": "^3.1.0", @@ -57,7 +58,6 @@ "he": "^1.2.0", "immer": "^9.0.6", "ipaddr.js": "^1.9.1", - "js-sha256": "^0.11.0", "jspdf": "^3.0.1", "jspdf-autotable": "^5.0.2", "launchdarkly-react-client-sdk": "3.0.10", @@ -126,7 +126,7 @@ }, "devDependencies": { "@4tw/cypress-drag-drop": "^2.3.0", - "@linode/eslint-plugin-cloud-manager": "^0.0.7", + "@linode/eslint-plugin-cloud-manager": "^0.0.10", "@storybook/addon-a11y": "^8.6.7", "@storybook/addon-actions": "^8.6.7", "@storybook/addon-controls": "^8.6.7", @@ -183,6 +183,9 @@ "cypress": "14.0.1", "cypress-axe": "^1.6.0", "cypress-file-upload": "^5.0.8", + "cypress-mochawesome-reporter": "^3.8.2", + "cypress-multi-reporters": "^2.0.5", + "cypress-on-fix": "^1.1.0", "cypress-real-events": "^1.14.0", "cypress-vite": "^1.6.0", "dotenv": "^16.0.3", @@ -211,7 +214,7 @@ "redux-mock-store": "^1.5.3", "storybook": "^8.6.7", "storybook-dark-mode": "4.0.1", - "vite": "^6.2.2", + "vite": "^6.2.4", "vite-plugin-svgr": "^3.2.0" }, "browserslist": [ diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index d078cf5e730..02c79b145d4 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -129,9 +129,6 @@ const Profile = React.lazy(() => default: module.Profile, })) ); -const NodeBalancers = React.lazy( - () => import('src/features/NodeBalancers/NodeBalancers') -); const SupportTickets = React.lazy( () => import('src/features/Support/SupportTickets') ); @@ -368,10 +365,6 @@ export const MainContent = () => { }> - - - diff --git a/packages/manager/src/assets/icons/chevron-up.svg b/packages/manager/src/assets/icons/chevron-up.svg deleted file mode 100644 index 79281e16a34..00000000000 --- a/packages/manager/src/assets/icons/chevron-up.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/manager/src/assets/icons/refresh.svg b/packages/manager/src/assets/icons/refresh.svg index 4864b6402c3..118f068f75c 100644 --- a/packages/manager/src/assets/icons/refresh.svg +++ b/packages/manager/src/assets/icons/refresh.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/manager/src/assets/icons/swapSmall.svg b/packages/manager/src/assets/icons/swapSmall.svg index 6711e50df3b..3d69d430869 100644 --- a/packages/manager/src/assets/icons/swapSmall.svg +++ b/packages/manager/src/assets/icons/swapSmall.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/manager/src/assets/icons/zoomin.svg b/packages/manager/src/assets/icons/zoomin.svg index 1a9d0e6873d..389ea95c5da 100644 --- a/packages/manager/src/assets/icons/zoomin.svg +++ b/packages/manager/src/assets/icons/zoomin.svg @@ -1,18 +1,18 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/packages/manager/src/assets/icons/zoomout.svg b/packages/manager/src/assets/icons/zoomout.svg index 173bcb9b058..30f229b8083 100644 --- a/packages/manager/src/assets/icons/zoomout.svg +++ b/packages/manager/src/assets/icons/zoomout.svg @@ -1,10 +1,10 @@ - - - - - - - - - + + + + + + + + + diff --git a/packages/manager/src/components/BetaChip/BetaChip.test.tsx b/packages/manager/src/components/BetaChip/BetaChip.test.tsx deleted file mode 100644 index 39d28178640..00000000000 --- a/packages/manager/src/components/BetaChip/BetaChip.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { BetaChip } from './BetaChip'; - -describe('BetaChip', () => { - it('renders with default color', () => { - const { getByTestId } = renderWithTheme(); - const betaChip = getByTestId('betaChip'); - expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: rgba(0, 0, 0, 0.08)'); - }); - - it('renders with primary color', () => { - const { getByTestId } = renderWithTheme(); - const betaChip = getByTestId('betaChip'); - expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)'); - }); - - it('triggers an onClick callback', () => { - const onClickMock = vi.fn(); - const { getByTestId } = renderWithTheme( - - ); - const betaChip = getByTestId('betaChip'); - fireEvent.click(betaChip); - expect(onClickMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/manager/src/components/BetaChip/BetaChip.tsx b/packages/manager/src/components/BetaChip/BetaChip.tsx deleted file mode 100644 index d5d7f589133..00000000000 --- a/packages/manager/src/components/BetaChip/BetaChip.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Chip } from '@linode/ui'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -import type { ChipProps } from '@linode/ui'; - -export interface BetaChipProps - extends Omit< - ChipProps, - | 'avatar' - | 'clickable' - | 'deleteIcon' - | 'disabled' - | 'icon' - | 'label' - | 'onDelete' - | 'outlineColor' - | 'size' - | 'variant' - > { - /** - * The color of the chip. - * default renders a gray chip, primary renders a blue chip. - */ - color?: 'default' | 'primary'; -} - -/** - * ## Usage - * - * Beta chips label features that are not yet part of Cloud Manager's core supported functionality.
- * **Example:** A beta chip may appear in the [primary navigation](https://github.com/linode/manager/pull/8104#issuecomment-1309334374), - * breadcrumbs, [banners](/docs/components-notifications-dismissible-banners--beta-banners), tabs, and/or plain text to designate beta functionality.
- * **Visual style:** bold, capitalized text; reduced height, letter spacing, and font size; solid color background. - * - */ -export const BetaChip = (props: BetaChipProps) => { - const { color } = props; - - return ( - - ); -}; - -const StyledBetaChip = styled(Chip, { - label: 'StyledBetaChip', -})(({ theme }) => ({ - '& .MuiChip-label': { - padding: 0, - }, - font: theme.font.bold, - fontSize: '0.625rem', - height: 16, - letterSpacing: '.25px', - marginLeft: theme.spacing(), - padding: theme.spacing(0.5), - textTransform: 'uppercase', -})); diff --git a/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx b/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx index a50d3a4206f..4edad9d654b 100644 --- a/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx +++ b/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx @@ -1,12 +1,57 @@ import { action } from '@storybook/addon-actions'; import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; +import { Chip } from '@linode/ui'; import { Breadcrumb } from './Breadcrumb'; +const withBadgeCrumbs = [ + { + position: 3, + label: ( + <> + test + + + + + ), + }, +]; + +const noBadgeCrumbs = [ + { + position: 3, + label: test, + }, +]; + const meta: Meta = { component: Breadcrumb, title: 'Foundations/Breadcrumb', + argTypes: { + crumbOverrides: { + options: ['With Badge', 'No Badge'], + mapping: { + 'With Badge': withBadgeCrumbs, + 'No Badge': noBadgeCrumbs, + }, + control: { + type: 'radio', + labels: { + 'With Badge': 'Show Beta Badge', + 'No Badge': 'Hide Beta Badge', + }, + }, + defaultValue: 'No Badge', + }, + }, }; type Story = StoryObj; @@ -20,6 +65,7 @@ export const Default: Story = { onEdit: async () => action('onEdit'), }, pathname: '/linodes/9872893679817/test/lastcrumb', + crumbOverrides: noBadgeCrumbs, }, render: (args) => , }; diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx index 241f6705b90..8536c2dffd8 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx @@ -3,23 +3,24 @@ import { styled } from '@mui/material'; export const StyledTypography = styled(Typography, { label: 'StyledTypography', -})(({}) => ({ +})(({ theme }) => ({ '&:hover': { textDecoration: 'underline', }, - fontSize: '1.125rem', + fontSize: '1rem', lineHeight: 'normal', textTransform: 'capitalize', whiteSpace: 'nowrap', + color: theme.tokens.component.Breadcrumb.LastItem.Text, })); export const StyledSlashTypography = styled(Typography, { label: 'StyledSlashTypography', })(({ theme }) => ({ color: theme.textColors.tableHeader, - fontSize: 20, - marginLeft: 2, - marginRight: 2, + fontSize: 16, + marginLeft: 4, + marginRight: 4, })); export const StyledDiv = styled('div', { label: 'StyledDiv' })({ diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.tsx index 174d86166c4..bd8e6585f87 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.tsx @@ -14,7 +14,7 @@ import type { EditableProps, LabelProps } from './types'; import type { LinkProps } from 'react-router-dom'; export interface CrumbOverridesProps { - label?: string; + label?: string | React.ReactNode; linkTo?: LinkProps['to']; noCap?: boolean; position: number; diff --git a/packages/manager/src/components/Breadcrumb/FinalCrumb.styles.tsx b/packages/manager/src/components/Breadcrumb/FinalCrumb.styles.tsx index af43289b40f..0fd695b3110 100644 --- a/packages/manager/src/components/Breadcrumb/FinalCrumb.styles.tsx +++ b/packages/manager/src/components/Breadcrumb/FinalCrumb.styles.tsx @@ -14,16 +14,16 @@ export const StyledEditableText = styled(EditableText, { '& > div': { width: 250, }, - marginLeft: `-${theme.spacing()}`, })); export const StyledH1Header = styled(H1Header, { label: 'StyledH1Header' })( ({ theme }) => ({ - color: theme.textColors.tableStatic, - fontSize: '1.125rem', + color: theme.tokens.component.Breadcrumb.Normal.Text.Default, + fontSize: '1rem', + paddingLeft: 0, textTransform: 'capitalize', [theme.breakpoints.up('lg')]: { - fontSize: '1.125rem', + fontSize: '1rem', }, }) ); diff --git a/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx b/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx index 729f1222230..f5f011ff4fd 100644 --- a/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx +++ b/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx @@ -38,6 +38,7 @@ export const FinalCrumb = React.memo((props: Props) => { disabledBreadcrumbEditButton={disabledBreadcrumbEditButton} errorText={onEditHandlers.errorText} handleAnalyticsEvent={onEditHandlers.handleAnalyticsEvent} + isBreadcrumb onCancel={onEditHandlers.onCancel} onEdit={onEditHandlers.onEdit} text={onEditHandlers.editableTextTitle} diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx index 478b59d667d..37b71485bba 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx @@ -1,7 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import { action } from '@storybook/addon-actions'; import * as React from 'react'; -import { linodeFactory } from 'src/factories/linodes'; import { LinodeEntityDetail } from 'src/features/Linodes/LinodeEntityDetail'; import { EntityDetail } from './EntityDetail'; diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index 7616ab5af15..87d920afd6d 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -1,22 +1,21 @@ -import { Stack, Tooltip, Typography } from '@linode/ui'; +import { ListItemOption, Stack, Tooltip, Typography } from '@linode/ui'; import React from 'react'; import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; -import { ListItemOption } from 'src/components/ListItemOption'; import { useFlags } from 'src/hooks/useFlags'; import { OSIcon } from '../OSIcon'; import { isImageDeprecated } from './utilities'; import type { Image } from '@linode/api-v4'; -import type { ListItemProps } from 'src/components/ListItemOption'; +import type { ListItemOptionProps } from '@linode/ui'; export const ImageOption = ({ disabledOptions, item, props, selected, -}: ListItemProps) => { +}: ListItemOptionProps) => { const flags = useFlags(); return ( diff --git a/packages/manager/src/components/ImageSelect/utilities.ts b/packages/manager/src/components/ImageSelect/utilities.ts index dcb1babfb1e..e18797f7500 100644 --- a/packages/manager/src/components/ImageSelect/utilities.ts +++ b/packages/manager/src/components/ImageSelect/utilities.ts @@ -4,7 +4,7 @@ import { MAX_MONTHS_EOL_FILTER } from 'src/constants'; import type { ImageSelectVariant } from './ImageSelect'; import type { Image, RegionSite } from '@linode/api-v4'; -import type { DisableItemOption } from 'src/components/ListItemOption'; +import type { DisableItemOption } from '@linode/ui'; /** * Given a Image Select "variant", this PR returns an diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupSelectOption.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupSelectOption.tsx index 5e5904431ba..d7af0dac896 100644 --- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupSelectOption.tsx +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupSelectOption.tsx @@ -1,18 +1,16 @@ import { PLACEMENT_GROUP_TYPES } from '@linode/api-v4'; -import { Box, Stack } from '@linode/ui'; +import { Box, ListItemOption, Stack } from '@linode/ui'; import React from 'react'; -import { ListItemOption } from 'src/components/ListItemOption'; - import type { PlacementGroup } from '@linode/api-v4'; -import type { ListItemProps } from 'src/components/ListItemOption'; +import type { ListItemOptionProps } from '@linode/ui'; export const PlacementGroupSelectOption = ({ disabledOptions, item, props, selected, -}: ListItemProps) => ( +}: ListItemOptionProps) => ( { queryMocks.useAllPlacementGroupsQuery.mockReturnValue({ data: [ placementGroupFactory.build({ - placement_group_type: 'affinity:local', id: 1, is_compliant: true, - placement_group_policy: 'strict', label: 'my-placement-group', members: [ { @@ -78,6 +77,8 @@ describe('PlacementGroupSelect', () => { linode_id: 1, }, ], + placement_group_policy: 'strict', + placement_group_type: 'affinity:local', region: 'ca-central', }), ], diff --git a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx index f2f50d090eb..2a85aec40ac 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx @@ -61,11 +61,7 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { > {display} {isBeta ? ( - + ) : null} diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx index ab53a8c8db3..b29904bc140 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx @@ -1,8 +1,8 @@ import { Box } from '@linode/ui'; -import { sortByString } from '@linode/utilities'; +import { regions, sortByString } from '@linode/utilities'; import React, { useState } from 'react'; -import { regions } from 'src/__data__/regionsData'; +// @todo: modularization - Move `SelectedRegionsList` to the `/data` directory in the `@linode/shared` package. import { SelectedRegionsList } from 'src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList'; import { RegionMultiSelect } from './RegionMultiSelect'; @@ -48,6 +48,7 @@ const meta: Meta = { disabled: false, errorText: '', isClearable: false, + isGeckoLAEnabled: false, label: 'Regions', placeholder: 'Select Regions or type to search', regions, diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx index ea64ef5929b..7113d13599f 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx @@ -1,12 +1,14 @@ -import { Region } from '@linode/api-v4'; +import { regionFactory } from '@linode/utilities'; import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; -import { regionFactory } from 'src/factories/regions'; +// @todo: modularization - Replace 'testHelpers' with 'testHelpers' from the shared package once available. import { renderWithTheme } from 'src/utilities/testHelpers'; import { RegionMultiSelect } from './RegionMultiSelect'; +import type { Region } from '@linode/api-v4'; + const regionNewark = regionFactory.build({ id: 'us-east', label: 'Newark, NJ', @@ -42,6 +44,7 @@ describe('RegionMultiSelect', () => { renderWithTheme( { renderWithTheme( { renderWithTheme( { /> )} currentCapability="Block Storage" + isGeckoLAEnabled={false} onChange={mockHandleSelection} regions={[regionNewark, regionAtlanta]} selectedIds={[regionNewark.id]} @@ -108,7 +114,7 @@ describe('RegionMultiSelect', () => { // Open the dropdown fireEvent.click(screen.getByRole('button', { name: 'Open' })); - // Check Newark chip shows becaused it is selected + // Check Newark chip shows because it is selected expect( screen.getByRole('listitem', { name: 'Newark, NJ', diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 1847f88c772..a623e9469b6 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -1,11 +1,14 @@ +import { useAllAccountAvailabilitiesQuery } from '@linode/queries'; import { Autocomplete, Chip, Stack, StyledListItem } from '@linode/ui'; import CloseIcon from '@mui/icons-material/Close'; import React from 'react'; -import { Flag } from 'src/components/Flag'; -import { useAllAccountAvailabilitiesQuery } from '@linode/queries'; +// @todo: modularization - Move `getRegionCountryGroup` utility to `@linode/shared` package +// as it imports GLOBAL_QUOTA_VALUE from RegionSelect's constants.ts and update the import. import { getRegionCountryGroup } from 'src/utilities/formatRegion'; +// @todo: modularization - Move `Flag` component to `@linode/shared` package. +import { Flag } from '../Flag'; import { RegionOption } from './RegionOption'; import { StyledAutocompleteContainer } from './RegionSelect.styles'; import { @@ -15,7 +18,7 @@ import { import type { RegionMultiSelectProps } from './RegionSelect.types'; import type { Region } from '@linode/api-v4'; -import type { DisableItemOption } from 'src/components/ListItemOption'; +import type { DisableItemOption } from '@linode/ui'; interface RegionChipLabelProps { region: Region; @@ -41,6 +44,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { helperText, ignoreAccountAvailability, isClearable, + isGeckoLAEnabled, label, onChange, placeholder, @@ -122,6 +126,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { return ( { + isGeckoLAEnabled: boolean; +} export const RegionOption = ({ disabledOptions, + isGeckoLAEnabled, item, props, selected, -}: ListItemProps) => { - const { isGeckoLAEnabled } = useIsGeckoEnabled(); - +}: RegionOptionProps) => { return ( = { disabled: false, errorText: '', helperText: '', + isGeckoLAEnabled: false, label: 'Region', regions, required: true, diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx index 3f6721f2ac6..1bc12b12404 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx @@ -1,6 +1,7 @@ +import { regionFactory } from '@linode/utilities'; import * as React from 'react'; -import { regionFactory } from 'src/factories'; +// @todo: modularization - Replace 'testHelpers' with 'testHelpers' from the shared package once available. import { renderWithTheme } from 'src/utilities/testHelpers'; import { RegionSelect } from './RegionSelect'; @@ -15,13 +16,14 @@ describe('RegionSelect', () => { currentCapability: 'Linodes', disabled: false, errorText: '', - onChange: vi.fn(), helperText: '', + isGeckoLAEnabled: false, label: '', + onChange: vi.fn(), regions, required: false, - value: '', tooltipText: '', + value: '', width: 100, }; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 4dce699e0e4..357e001765e 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -1,13 +1,15 @@ +import { useAllAccountAvailabilitiesQuery } from '@linode/queries'; import { Autocomplete } from '@linode/ui'; import PublicIcon from '@mui/icons-material/Public'; import { createFilterOptions } from '@mui/material/Autocomplete'; import * as React from 'react'; -import { Flag } from 'src/components/Flag'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; -import { useAllAccountAvailabilitiesQuery } from '@linode/queries'; +// @todo: modularization - Move `getRegionCountryGroup` utility to `@linode/shared` package +// as it imports GLOBAL_QUOTA_VALUE from RegionSelect's constants.ts and update the import. import { getRegionCountryGroup } from 'src/utilities/formatRegion'; +// @todo: modularization - Move `Flag` component to `@linode/shared` package. +import { Flag } from '../Flag'; import { RegionOption } from './RegionOption'; import { StyledAutocompleteContainer } from './RegionSelect.styles'; import { @@ -17,7 +19,7 @@ import { import type { RegionSelectProps } from './RegionSelect.types'; import type { Region } from '@linode/api-v4'; -import type { DisableItemOption } from 'src/components/ListItemOption'; +import type { DisableItemOption } from '@linode/ui'; /** * A specific select for regions. @@ -42,6 +44,7 @@ export const RegionSelect = < forcefullyShownRegionIds, helperText, ignoreAccountAvailability, + isGeckoLAEnabled, label, noMarginTop, onChange, @@ -55,8 +58,6 @@ export const RegionSelect = < width, } = props; - const { isGeckoLAEnabled } = useIsGeckoEnabled(); - const { data: accountAvailability, isLoading: accountAvailabilityLoading, @@ -117,6 +118,7 @@ export const RegionSelect = < return ( void; regions: Region[]; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index 1681e0bb59b..e371deb03f1 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -1,4 +1,4 @@ -import { accountAvailabilityFactory, regionFactory } from 'src/factories'; +import { accountAvailabilityFactory, regionFactory } from '@linode/utilities'; import { getRegionOptions, diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 9fea41ec64d..3a3e45844c4 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -1,7 +1,7 @@ import { CONTINENT_CODE_TO_CONTINENT } from '@linode/api-v4'; -import { useFlags } from 'src/hooks/useFlags'; -import { useRegionsQuery } from '@linode/queries'; +// @todo: modularization - Move `getRegionCountryGroup` utility to `@linode/shared` package +// as it imports GLOBAL_QUOTA_VALUE from RegionSelect's constants.ts and update the import. import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import type { @@ -9,7 +9,7 @@ import type { RegionFilterValue, } from './RegionSelect.types'; import type { AccountAvailability, Capabilities, Region } from '@linode/api-v4'; -import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types'; +import type { LinodeCreateType } from '@linode/utilities'; const NORTH_AMERICA = CONTINENT_CODE_TO_CONTINENT.NA; @@ -163,18 +163,3 @@ export const getIsDistributedRegion = ( ); return region?.site_type === 'distributed'; }; - -export const useIsGeckoEnabled = () => { - const flags = useFlags(); - const isGeckoLA = flags?.gecko2?.enabled && flags.gecko2.la; - const isGeckoBeta = flags.gecko2?.enabled && !flags.gecko2?.la; - const { data: regions } = useRegionsQuery(); - - const hasDistributedRegionCapability = regions?.some((region: Region) => - region.capabilities.includes('Distributed Plans') - ); - const isGeckoLAEnabled = hasDistributedRegionCapability && isGeckoLA; - const isGeckoBetaEnabled = hasDistributedRegionCapability && isGeckoBeta; - - return { isGeckoBetaEnabled, isGeckoLAEnabled }; -}; diff --git a/packages/manager/src/components/StackScript/StackScript.tsx b/packages/manager/src/components/StackScript/StackScript.tsx index 6b95ef255ff..786897165a7 100644 --- a/packages/manager/src/components/StackScript/StackScript.tsx +++ b/packages/manager/src/components/StackScript/StackScript.tsx @@ -1,3 +1,4 @@ +import { listToItemsByID } from '@linode/queries'; import { Box, Button, @@ -16,7 +17,6 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Link } from 'src/components/Link'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { listToItemsByID } from '@linode/queries'; import { useAllImagesQuery } from 'src/queries/images'; import { CodeBlock } from '../CodeBlock/CodeBlock'; diff --git a/packages/manager/src/components/Tabs/SafeTabPanel.test.tsx b/packages/manager/src/components/Tabs/SafeTabPanel.test.tsx index f884961b017..74dc8bc275a 100644 --- a/packages/manager/src/components/Tabs/SafeTabPanel.test.tsx +++ b/packages/manager/src/components/Tabs/SafeTabPanel.test.tsx @@ -1,6 +1,8 @@ import { render } from '@testing-library/react'; import * as React from 'react'; +import { Tabs } from 'src/components/Tabs/Tabs'; + import { SafeTabPanel } from './SafeTabPanel'; vi.mock('@reach/tabs', async () => { @@ -14,9 +16,11 @@ vi.mock('@reach/tabs', async () => { describe('SafeTabPanel', () => { it('renders children when the tab is selected', () => { const { getByText } = render( - -
Child Content
-
+ + +
Child Content
+
+
); expect(getByText('Child Content')).toBeInTheDocument(); @@ -24,9 +28,11 @@ describe('SafeTabPanel', () => { it('does not render children when the tab is not selected', () => { const { queryByText } = render( - -
Child Content
-
+ + +
Child Content
+
+
); expect(queryByText('Child Content')).toBeNull(); @@ -34,11 +40,13 @@ describe('SafeTabPanel', () => { it('renders empty when the index is null', () => { const { container } = render( - -
Child Content
-
+ + +
Child Content
+
+
); - expect(container.firstChild).toBeEmptyDOMElement(); + expect(container.firstChild?.firstChild).toBeEmptyDOMElement(); }); }); diff --git a/packages/manager/src/components/Tabs/Tab.test.tsx b/packages/manager/src/components/Tabs/Tab.test.tsx index acbeeb938ac..a8e4ce9300b 100644 --- a/packages/manager/src/components/Tabs/Tab.test.tsx +++ b/packages/manager/src/components/Tabs/Tab.test.tsx @@ -1,31 +1,44 @@ import { screen } from '@testing-library/react'; import React from 'react'; +import { Tabs } from 'src/components/Tabs/Tabs'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { Tab } from './Tab'; describe('Tab Component', () => { it('renders tab with children', () => { - renderWithTheme(Hello Tab); + renderWithTheme( + + Hello Tab + + ); const tabElement = screen.getByText('Hello Tab'); expect(tabElement).toBeInTheDocument(); }); it('applies styles correctly', () => { - renderWithTheme(Hello Tab); + renderWithTheme( + + Hello Tab + + ); const tabElement = screen.getByText('Hello Tab'); expect(tabElement).toHaveStyle(` display: inline-flex; - color: rgb(1, 116, 188); + color: rgb(52, 52, 56); `); }); it('handles disabled state', () => { - renderWithTheme(Click Me); + renderWithTheme( + + Click Me + + ); const tabElement = screen.getByText('Click Me'); diff --git a/packages/manager/src/components/Tabs/Tabs.tsx b/packages/manager/src/components/Tabs/Tabs.tsx index a54f849947a..0a8a9c904fa 100644 --- a/packages/manager/src/components/Tabs/Tabs.tsx +++ b/packages/manager/src/components/Tabs/Tabs.tsx @@ -1 +1,7 @@ -export { Tabs } from '@reach/tabs'; +import { Tabs as ReachTabs } from '@reach/tabs'; +import * as React from 'react'; + +export const Tabs = (props: React.ComponentProps) => { + const id = React.useId(); + return ; +}; 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 7c5d66fe7d1..cc6c4e44ecc 100644 --- a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap +++ b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap @@ -5,6 +5,7 @@ exports[`TabList component > renders TabList correctly 1`] = `
Promise; existingTags: string[]; diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 2529e0909dd..6dce5122a9a 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -1,11 +1,14 @@ +import { + updateTagsSuggestionsData, + useAllTagsQuery, + useProfile, +} from '@linode/queries'; import { Autocomplete, Chip } from '@linode/ui'; import CloseIcon from '@mui/icons-material/Close'; import { useQueryClient } from '@tanstack/react-query'; import { concat } from 'ramda'; import * as React from 'react'; -import { useProfile } from '@linode/queries'; -import { updateTagsSuggestionsData, useAllTagsQuery } from 'src/queries/tags'; import { getErrorMap } from 'src/utilities/errorUtils'; import type { APIError } from '@linode/api-v4/lib/types'; diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.test.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.test.tsx index 1c82bc0abe2..bc0254826fd 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.test.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.test.tsx @@ -1,7 +1,7 @@ +import { regionFactory } from '@linode/utilities'; import { fireEvent } from '@testing-library/react'; import React from 'react'; -import { regionFactory } from 'src/factories'; import { accountTransferFactory, accountTransferNoResourceFactory, diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx index d99221a5f9a..70727a373a0 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx @@ -1,11 +1,12 @@ +import { useIsGeckoEnabled } from '@linode/shared'; import { Box, Dialog, Divider, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Link } from 'src/components/Link'; +import { useFlags } from 'src/hooks/useFlags'; -import { useIsGeckoEnabled } from '../RegionSelect/RegionSelect.utils'; import { NETWORK_TRANSFER_USAGE_AND_COST_LINK } from './constants'; import { TransferDisplayDialogHeader } from './TransferDisplayDialogHeader'; import { TransferDisplayUsage } from './TransferDisplayUsage'; @@ -36,7 +37,11 @@ export const TransferDisplayDialog = React.memo( regionTransferPools, } = props; const theme = useTheme(); - const { isGeckoLAEnabled } = useIsGeckoEnabled(); + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const daysRemainingInMonth = getDaysRemaining(); const listOfOtherRegionTransferPools: string[] = diff --git a/packages/manager/src/components/TransferDisplay/utils.test.tsx b/packages/manager/src/components/TransferDisplay/utils.test.tsx index bd91d7a1ba4..61d5e8b1cce 100644 --- a/packages/manager/src/components/TransferDisplay/utils.test.tsx +++ b/packages/manager/src/components/TransferDisplay/utils.test.tsx @@ -1,8 +1,9 @@ -import { accountTransferFactory } from 'src/factories/account'; import { regionFactory, regionWithDynamicPricingFactory, -} from 'src/factories/regions'; +} from '@linode/utilities'; + +import { accountTransferFactory } from 'src/factories/account'; import { calculatePoolUsagePct, diff --git a/packages/manager/src/dev-tools/DesignTokensTool.tsx b/packages/manager/src/dev-tools/DesignTokensTool.tsx new file mode 100644 index 00000000000..b54d77fa106 --- /dev/null +++ b/packages/manager/src/dev-tools/DesignTokensTool.tsx @@ -0,0 +1,240 @@ +import { ThemeProvider } from '@emotion/react'; +import { + Box, + CircleProgress, + Notice, + Select, + Stack, + Typography, + light, +} from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import * as React from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Link } from 'src/components/Link'; +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 { themes } from 'src/utilities/theme'; + +import { TokenSection } from './components/Tokens/TokenSection'; +import { countTokens, filterTokenObject } from './components/Tokens/utils'; + +import type { ThemeName } from '@linode/ui'; + +const _tokens = Object.entries(light.tokens ?? {}); + +export type TokenObjects = typeof _tokens; +export type TokenObject = TokenObjects[number][1]; +export type RecursiveTokenObject = { + [key: string]: RecursiveTokenObject | string; +}; +export type TokenCategory = keyof NonNullable; + +const TokenPanelContent = ({ + searchValue, + tokenCategory, + tokenObject, +}: { + searchValue: string; + tokenCategory: TokenCategory; + tokenObject: TokenObject; +}) => { + const [ + renderedContent, + setRenderedContent, + ] = React.useState(null); + const [isSearching, setIsSearching] = React.useState(false); + + React.useEffect(() => { + const computeTokens = async () => { + setIsSearching(true); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const filteredObject = searchValue + ? filterTokenObject(tokenObject, searchValue.toLowerCase()) + : tokenObject; + + const totalResults = filteredObject ? countTokens(filteredObject) : 0; + + const content = + totalResults > 0 ? ( + + {Object.entries(filteredObject).map(([key, value], index) => ( + + ))} + + ) : ( + + + No matching tokens found for "{searchValue}" + + + ); + setRenderedContent(content); + setIsSearching(false); + }; + + computeTokens(); + }, [tokenCategory, tokenObject, searchValue]); + + if (!renderedContent || isSearching) { + return ( + + + + ); + } + + return renderedContent; +}; + +export const DesignTokensTool = () => { + const [selectedTheme, setSelectedTheme] = React.useState('light'); + const [selectedTab, setSelectedTab] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(''); + + const handleThemeChange = React.useCallback( + async ( + _e: React.SyntheticEvent, + value: { value: ThemeName } + ) => { + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const newTokens = Object.entries(themes[value.value].tokens ?? {}); + setSelectedTheme(value.value); + setIsLoading(false); + + return newTokens; + }, + [] + ); + + const filteredTokens = Object.entries(themes[selectedTheme].tokens ?? {}); + return ( + + ({ + backgroundColor: theme.tokens.alias.Background.Normal, + flex: 1, + height: '100%', + overflow: 'auto', + position: 'absolute', + width: '100%', + })} + className="dev-tools__design-tokens" + > + {isLoading ? ( + + + + ) : ( + + + + + {filteredTokens.map(([tokenCategory, _tokenObject]) => ( + + {capitalize(tokenCategory)} + + ))} + + + + {props.children} +
); }; diff --git a/packages/manager/src/dev-tools/components/Draggable.tsx b/packages/manager/src/dev-tools/components/Draggable.tsx index 6cb9ec864ec..e6d312ad0a7 100644 --- a/packages/manager/src/dev-tools/components/Draggable.tsx +++ b/packages/manager/src/dev-tools/components/Draggable.tsx @@ -117,14 +117,14 @@ export const Draggable = ({ children, draggable }: DraggableProps) => { {draggable && ( <> diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx index ccbd50dfba4..51fb95e4a5d 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx @@ -27,7 +27,7 @@ export const ExtraPresetOptionSelect = ( ?.id ) || '' } - className="dev-tools__select thin" + className="dt-select dev-tools__select thin" onChange={(e) => onSelectChange(e, group)} style={{ width: 125 }} > diff --git a/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx b/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx index bced5b34bb5..317810062f3 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx @@ -109,7 +109,7 @@ export const ExtraPresetProfile = ({ {isEnabled && (
diff --git a/packages/manager/src/dev-tools/components/Tokens/ColorSwatch.tsx b/packages/manager/src/dev-tools/components/Tokens/ColorSwatch.tsx new file mode 100644 index 00000000000..cc932ae404e --- /dev/null +++ b/packages/manager/src/dev-tools/components/Tokens/ColorSwatch.tsx @@ -0,0 +1,21 @@ +import { Border } from '@linode/design-language-system'; +import { Box } from '@linode/ui'; +import React from 'react'; + +interface ColorSwatchProps { + color: string; +} + +export const ColorSwatch = ({ color }: ColorSwatchProps) => { + return ( + + ); +}; diff --git a/packages/manager/src/dev-tools/components/Tokens/TokenCopy.tsx b/packages/manager/src/dev-tools/components/Tokens/TokenCopy.tsx new file mode 100644 index 00000000000..1dd934b65bc --- /dev/null +++ b/packages/manager/src/dev-tools/components/Tokens/TokenCopy.tsx @@ -0,0 +1,67 @@ +import { Border, Color, Font } from '@linode/design-language-system'; +import { Box, Typography } from '@linode/ui'; +import React from 'react'; + +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; + +interface TokenCopyProps { + format: 'CSS' | 'JS' | 'SCSS' | 'Val'; + isLowerCase?: boolean; + value: string; +} + +export const TokenValue = ({ + format, + isLowerCase = false, + value, +}: TokenCopyProps) => { + if (isLowerCase) { + value = value.toLowerCase(); + } + + return ( + + + + {format}: + + {' '} + + {value} + + + + ); +}; diff --git a/packages/manager/src/dev-tools/components/Tokens/TokenInfo.tsx b/packages/manager/src/dev-tools/components/Tokens/TokenInfo.tsx new file mode 100644 index 00000000000..e55283f48a0 --- /dev/null +++ b/packages/manager/src/dev-tools/components/Tokens/TokenInfo.tsx @@ -0,0 +1,52 @@ +import { Stack } from '@linode/ui'; +import React from 'react'; + +import { ColorSwatch } from './ColorSwatch'; +import { TokenValue } from './TokenCopy'; +import { formatValue } from './utils'; + +import type { TokenCategory } from '../../DesignTokensTool'; + +interface TokenInfoProps { + category: TokenCategory; + path: string[]; + value: string; + variant: string; +} + +export const TokenInfo = (props: TokenInfoProps) => { + const { category, path = [], value, variant } = props; + + const jsPath = + path.length > 0 + ? path + .flatMap((segment) => segment.split('.')) + .map((segment) => formatValue(segment, category)) + .reduce((result, segment, index) => { + // First segment never gets a dot + if (index === 0) { + return segment; + } + + // If this segment contains a bracket, don't add a dot + if (segment.includes('[')) { + return result + segment; + } + + // Otherwise add a dot + return result + '.' + segment; + }, '') + : formatValue(variant, category); + + return ( + + {(value.startsWith('#') || value.startsWith('lch')) && ( + + )} + + + + + + ); +}; diff --git a/packages/manager/src/dev-tools/components/Tokens/TokenSection.tsx b/packages/manager/src/dev-tools/components/Tokens/TokenSection.tsx new file mode 100644 index 00000000000..ebcd31f7846 --- /dev/null +++ b/packages/manager/src/dev-tools/components/Tokens/TokenSection.tsx @@ -0,0 +1,125 @@ +import { Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { TokenInfo } from './TokenInfo'; + +import type { + RecursiveTokenObject, + TokenCategory, +} from '../../DesignTokensTool'; + +export interface TokenSectionProps { + category: TokenCategory; + title: string; + value: RecursiveTokenObject | string; + variant: string; +} + +export const TokenSection = ({ + category, + title, + value, + variant, +}: TokenSectionProps) => { + const isColorValueString = typeof value === 'string'; + + const renderTokenGroup = ( + groupValue: RecursiveTokenObject | string, + parentPath: string[] = [] + ) => { + if (typeof groupValue === 'string') { + return ( + + ); + } + + return Object.entries(groupValue).map(([key, value]) => ( + + {parentPath.length === 0 && ( + ({ + borderBottom: `1px solid ${theme.tokens.alias.Border.Normal}`, + font: theme.font.bold, + py: 1, + })} + > + {key} + + )} + {typeof value === 'object' ? ( + + {parentPath.length > 0 && ( + ({ + font: theme.font.semibold, + })} + > + {key} + + )} + {renderTokenGroup(value, [...parentPath, key])} + + ) : typeof value === 'string' ? ( + + ) : null} + + )); + }; + + if (isColorValueString) { + return ( + + ({ + backgroundColor: theme.tokens.alias.Background.Normal, + position: 'sticky', + top: 0, + zIndex: 2, + })} + variant="h3" + > + {title} + + + + ); + } + + return ( + + ({ + backgroundColor: theme.tokens.alias.Background.Normal, + position: 'sticky', + top: 0, + zIndex: 2, + })} + variant="h3" + > + {title} + + {renderTokenGroup(value)} + + ); +}; diff --git a/packages/manager/src/dev-tools/components/Tokens/utils.ts b/packages/manager/src/dev-tools/components/Tokens/utils.ts new file mode 100644 index 00000000000..893c68a1730 --- /dev/null +++ b/packages/manager/src/dev-tools/components/Tokens/utils.ts @@ -0,0 +1,87 @@ +import type { + RecursiveTokenObject, + TokenCategory, + TokenObject, +} from 'src/dev-tools/DesignTokensTool'; + +export const filterTokenObject = ( + obj: RecursiveTokenObject | TokenObject | string, + searchTerm: string, + path: string[] = [] +): RecursiveTokenObject | string => { + if (searchTerm.length < 3) { + return {}; + } + + if (typeof obj === 'string') { + // If it's a color value, check if it matches + return obj.toLowerCase().includes(searchTerm) ? obj : {}; + } + + const filtered: Record = {}; + + Object.entries(obj).forEach(([key, value]) => { + const currentPath = [...path, key]; + + // Check if the key or path matches + const keyMatches = key.toLowerCase().includes(searchTerm); + const pathMatches = currentPath + .join('.') + .toLowerCase() + .includes(searchTerm); + + if (keyMatches || pathMatches) { + // If key matches, include the whole subtree + filtered[key] = value; + } else if (typeof value === 'object') { + // Recursively filter nested objects + const filteredValue = filterTokenObject(value, searchTerm, currentPath); + if (filteredValue && Object.keys(filteredValue).length > 0) { + filtered[key] = filteredValue; + } + } else if ( + typeof value === 'string' && + value.toLowerCase().includes(searchTerm) + ) { + // If value matches (e.g., color code) + filtered[key] = value; + } + }); + + return Object.keys(filtered).length > 0 ? filtered : {}; +}; + +export const countTokens = ( + obj: RecursiveTokenObject | TokenObject | string +): number => { + if (typeof obj === 'string') { + return 1; + } + + return Object.values(obj).reduce((count, value) => { + if (typeof value === 'object') { + return count + countTokens(value); + } + return count + 1; + }, 0); +}; + +export const formatValue = (value: string, category: TokenCategory) => { + if (category === 'spacing') { + return value; + } + + // If it's a pure number, wrap in brackets + if (!isNaN(Number(value))) { + return `[${value}]`; + } + + // For any string containing a number + const match = value.match(/(\d+)/); + if (match) { + const parts = value.split(match[0]); + return `${parts[0]}[${match[0]}]${parts[1]}`; + } + + return value; +}; diff --git a/packages/manager/src/dev-tools/dev-tools.css b/packages/manager/src/dev-tools/dev-tools.css index 3565f87269d..e742b313f72 100644 --- a/packages/manager/src/dev-tools/dev-tools.css +++ b/packages/manager/src/dev-tools/dev-tools.css @@ -112,7 +112,7 @@ transform: rotate(135deg); } -.dev-tools__select select { +.dev-tools__select select.dt-select { background: transparent; border: none; color: white; @@ -124,7 +124,7 @@ } /* avoid overriding TanStack React Query Devtools styles */ -.dev-tools__body button:not(.tsqd-parent-container button) { +.dev-tools__body button.dev-tools-button:not(.tsqd-parent-container button) { background: transparent; border: 2px solid rgba(255, 255, 255, 0.5); border-radius: 1000px; @@ -155,18 +155,18 @@ } } -.dev-tools__body .dev-tools__content button:hover { +.dev-tools__body .dev-tools__content button.dev-tools-button:hover { background: rgba(255, 255, 255, 0.1); } -.dev-tools__body .dev-tools__content button:not(:disabled).green:hover { +.dev-tools__body .dev-tools__content button.dev-tools-button:not(:disabled).green:hover { background-color: #60e9a4; color: #080808; } -.dev-tools__body .dev-tools__content button:disabled, -.dev-tools__body .dev-tools__content button:disabled:active { +.dev-tools__body .dev-tools__content button.dev-tools-button:disabled, +.dev-tools__body .dev-tools__content button.dev-tools-button:disabled:active { background: transparent; cursor: not-allowed; border-color: rgba(255, 255, 255, 0.3); @@ -232,7 +232,7 @@ text-shadow: 0px -1px 0px black; } -.dev-tools__body .dev-tools__content button:active { +.dev-tools__body .dev-tools__content button.dev-tools-button:active { background: rgb(50, 50, 50); border-color: rgba(255, 255, 255, 0.65); } @@ -327,7 +327,7 @@ } .dev-tools__body input[type="number"], -.dev-tools__body input[type="text"], +.dev-tools__body input[type="text"]:not(.MuiInput-input), .dev-tools__body textarea { border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.5); diff --git a/packages/manager/src/factories/accountEntities.ts b/packages/manager/src/factories/accountEntities.ts new file mode 100644 index 00000000000..95193a1286d --- /dev/null +++ b/packages/manager/src/factories/accountEntities.ts @@ -0,0 +1,22 @@ +import { Factory } from '@linode/utilities'; + +import type { AccountEntity, EntityType } from '@linode/api-v4'; + +export const possibleTypes: EntityType[] = [ + 'database', + 'domain', + 'firewall', + 'image', + 'linode', + 'longview', + 'nodebalancer', + 'stackscript', + 'volume', + 'vpc', +]; + +export const accountEntityFactory = Factory.Sync.makeFactory({ + id: Factory.each((i) => i), + label: Factory.each((i) => `test-${i}`), + type: Factory.each((i) => possibleTypes[i % possibleTypes.length]), +}); diff --git a/packages/manager/src/factories/accountPermissions.ts b/packages/manager/src/factories/accountPermissions.ts index 697192fe0a9..7efd01d23f5 100644 --- a/packages/manager/src/factories/accountPermissions.ts +++ b/packages/manager/src/factories/accountPermissions.ts @@ -20,7 +20,6 @@ const createResourceRoles = ( viewer = [], }: CreateResourceRoles ) => ({ - resource_type: resourceType, roles: [ accountAdmin.length > 0 ? { @@ -58,13 +57,13 @@ const createResourceRoles = ( } : null, ].filter(Boolean), + type: resourceType, }); export const accountPermissionsFactory = Factory.Sync.makeFactory( { account_access: [ { - resource_type: 'account', roles: [ { description: @@ -308,6 +307,7 @@ export const accountPermissionsFactory = Factory.Sync.makeFactory([ - { - resource_type: 'linode', - resources: [ - { - id: 12345678, - name: 'debian-us-123', - }, - { - id: 23456789, - name: 'linode-uk-123', - }, - { - id: 1, - name: 'debian-us-1', - }, - { - id: 2, - name: 'linode-uk-1', - }, - { - id: 3, - name: 'debian-us-2', - }, - { - id: 4, - name: 'linode-uk-2', - }, - { - id: 5, - name: 'debian-us-3', - }, - { - id: 6, - name: 'linode-uk-3', - }, - { - id: 7, - name: 'debian-us-4', - }, - { - id: 8, - name: 'linode-uk-4', - }, - ], - }, - { - resource_type: 'firewall', - resources: [ - { - id: 45678901, - name: 'firewall-us-123', - }, - ], - }, - { - resource_type: 'image', - resources: [ - { - id: 65789745, - name: 'image-us-123', - }, - ], - }, - { - resource_type: 'vpc', - resources: [ - { - id: 7654321, - name: 'vpc-us-123', - }, - ], - }, - { - resource_type: 'volume', - resources: [ - { - id: 890357, - name: 'volume-us-123', - }, - ], - }, - { - resource_type: 'nodebalancer', - resources: [ - { - id: 4532187, - name: 'nodebalancer-us-123', - }, - ], - }, - { - resource_type: 'longview', - resources: [ - { - id: 432178973, - name: 'longview-us-123', - }, - ], - }, - { - resource_type: 'domain', - resources: [ - { - id: 5437894, - name: 'domain-us-123', - }, - ], - }, - { - resource_type: 'stackscript', - resources: [ - { - id: 654321789, - name: 'stackscript-us-123', - }, - ], - }, - { - resource_type: 'database', - resources: [ - { - id: 643218965, - name: 'database-us-123', - }, - ], - }, -]); diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 5495121a1f5..2dabc3119b8 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -6,6 +6,7 @@ import type { Database, DatabaseBackup, DatabaseEngine, + DatabaseEngineConfig, DatabaseInstance, DatabaseStatus, DatabaseType, @@ -141,8 +142,6 @@ export const databaseInstanceFactory = Factory.Sync.makeFactory({ advanced: { connect_timeout: 10, default_time_zone: '+03:00', - group_concat_max_len: 4, - information_schema_stats_expiry: 900, innodb_print_all_deadlocks: true, sql_mode: 'ANSI,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,STRICT_ALL_TABLES', @@ -275,3 +272,103 @@ export const databaseEngineFactory = Factory.Sync.makeFactory({ id: Factory.each((i) => `test/${i}`), version: Factory.each((i) => `${i}`), }); + +export const databaseEngineConfigFactory = Factory.Sync.makeFactory( + { + binlog_retention_period: { + description: + 'The minimum amount of time in seconds to keep binlog entries before deletion. This may be extended for services that require binlog entries for longer than the default for example if using the MySQL Debezium Kafka connector.', + example: 600, + maximum: 86400, + minimum: 600, + restart_cluster: false, + type: 'integer', + }, + mysql: { + connect_timeout: { + description: + 'The number of seconds that the mysqld server waits for a connect packet before responding with Bad handshake', + example: 10, + maximum: 3600, + minimum: 2, + restart_cluster: false, + type: 'integer', + }, + default_time_zone: { + description: + "Default server time zone as an offset from UTC (from -12:00 to +12:00), a time zone name, or 'SYSTEM' to use the MySQL server default.", + example: '+03:00', + maxLength: 100, + minLength: 2, + pattern: '^([-+][\\d:]*|[\\w/]*)$', + restart_cluster: false, + type: 'string', + }, + innodb_ft_min_token_size: { + description: + 'Minimum length of words that are stored in an InnoDB FULLTEXT index. Changing this parameter will lead to a restart of the MySQL service.', + example: 3, + maximum: 16, + minimum: 0, + restart_cluster: true, + type: 'integer', + }, + innodb_ft_server_stopword_table: { + description: + 'This option is used to specify your own InnoDB FULLTEXT index stopword list for all InnoDB tables.', + example: 'db_name/table_name', + maxLength: 1024, + pattern: '^.+/.+$', + restart_cluster: false, + type: ['null', 'string'], + }, + innodb_print_all_deadlocks: { + description: + 'When enabled, information about all deadlocks in InnoDB user transactions is recorded in the error log. Disabled by default.', + example: true, + restart_cluster: false, + type: 'boolean', + }, + log_output: { + description: + 'The slow log output destination when slow_query_log is ON. To enable MySQL AI Insights, choose INSIGHTS. To use MySQL AI Insights and the mysql.slow_log table at the same time, choose INSIGHTS,TABLE. To only use the mysql.slow_log table, choose TABLE. To silence slow logs, choose NONE.', + enum: ['INSIGHTS', 'NONE', 'TABLE', 'INSIGHTS,TABLE'], + example: 'INSIGHTS', + restart_cluster: false, + type: 'string', + }, + long_query_time: { + description: + 'The slow_query_logs work as SQL statements that take more than long_query_time seconds to execute.', + example: 10, + maximum: 3600, + minimum: 0.0, + restart_cluster: false, + type: 'number', + }, + sql_mode: { + description: + 'Global SQL mode. Set to empty to use MySQL server defaults. When creating a new service and not setting this field Aiven default SQL mode (strict, SQL standard compliant) will be assigned.', + example: 'ANSI,TRADITIONAL', + maxLength: 1024, + pattern: '^[A-Z_]*(,[A-Z_]+)*$', + restart_cluster: false, + type: 'string', + }, + sql_require_primary_key: { + description: + 'Require primary key to be defined for new tables or old tables modified with ALTER TABLE and fail if missing. It is recommended to always have primary keys because various functionality may break if any large table is missing them.', + example: true, + restart_cluster: false, + type: 'boolean', + }, + }, + service_log: { + description: + 'Store logs for the service so that they are available in the HTTP API and console.', + example: true, + restart_cluster: false, + type: ['boolean', 'null'], + }, + } +); diff --git a/packages/manager/src/factories/disk.ts b/packages/manager/src/factories/disk.ts index 165b5163baa..70330a97e4b 100644 --- a/packages/manager/src/factories/disk.ts +++ b/packages/manager/src/factories/disk.ts @@ -1,6 +1,7 @@ -import { Disk } from '@linode/api-v4/lib/linodes/types'; import { Factory } from '@linode/utilities'; +import type { Disk } from '@linode/api-v4/lib/linodes/types'; + export const linodeDiskFactory = Factory.Sync.makeFactory({ created: '2018-01-01', disk_encryption: 'enabled', diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index ccfc6cc35e8..1532384e897 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -1,6 +1,5 @@ export * from './account'; export * from './accountAgreements'; -export * from './accountAvailability'; export * from './accountLogin'; export * from './accountMaintenance'; export * from './accountOAuth'; @@ -21,7 +20,6 @@ export * from './images'; export * from './kernels'; export * from './kubernetesCluster'; export * from './linodeConfigs'; -export * from './linodes'; export * from './longviewClient'; export * from './longviewDisks'; export * from './longviewProcess'; @@ -31,7 +29,6 @@ export * from './longviewSubscription'; export * from './longviewTopProcesses'; export * from './managed'; export * from './networking'; -export * from './nodebalancer'; export * from './notification'; export * from './oauth'; export * from './objectStorage'; @@ -39,7 +36,6 @@ export * from './placementGroups'; export * from './preferences'; export * from './profile'; export * from './promotionalOffer'; -export * from './regions'; export * from './stackscripts'; export * from './statusPage'; export * from './subnets'; diff --git a/packages/manager/src/factories/tags.ts b/packages/manager/src/factories/tags.ts index b4f42bfbb61..5f100f7992f 100644 --- a/packages/manager/src/factories/tags.ts +++ b/packages/manager/src/factories/tags.ts @@ -1,6 +1,7 @@ -import { Tag } from '@linode/api-v4/lib/tags/types'; import { Factory } from '@linode/utilities'; +import type { Tag } from '@linode/api-v4'; + export const tagFactory = Factory.Sync.makeFactory({ label: Factory.each((id) => `tag-${id + 1}`), }); diff --git a/packages/manager/src/factories/userPermissions.ts b/packages/manager/src/factories/userPermissions.ts index eb271aacb9e..9d6efb9dec5 100644 --- a/packages/manager/src/factories/userPermissions.ts +++ b/packages/manager/src/factories/userPermissions.ts @@ -1,6 +1,56 @@ import { Factory } from '@linode/utilities'; -import type { IamUserPermissions } from '@linode/api-v4'; +import type { + EntityAccess, + EntityType, + IamUserPermissions, + RoleType, +} from '@linode/api-v4'; + +const possibleRoles: RoleType[] = [ + 'firewall_admin', + 'firewall_creator', + 'linode_contributor', + 'linode_creator', + 'linode_viewer', + 'update_firewall', +]; + +export const possibleTypes: EntityType[] = [ + 'database', + 'domain', + 'firewall', + 'image', + 'linode', + 'longview', + 'nodebalancer', + 'stackscript', + 'volume', + 'vpc', +]; + +export const entityAccessFactory = Factory.Sync.makeFactory({ + id: Factory.each((i) => i + 1), + roles: Factory.each((i) => [possibleRoles[i % possibleRoles.length]]), + type: Factory.each((i) => possibleTypes[i % possibleTypes.length]), +}); + +const entityAccessList = [ + ...entityAccessFactory.buildList(7, { + roles: ['linode_contributor'], + type: 'linode', + }), + entityAccessFactory.build({ + id: 10, + roles: ['linode_contributor', 'linode_viewer'], + type: 'linode', + }), + entityAccessFactory.build({ + id: 1, + roles: ['firewall_admin'], + type: 'firewall', + }), +]; export const userPermissionsFactory = Factory.Sync.makeFactory( { @@ -11,62 +61,6 @@ export const userPermissionsFactory = Factory.Sync.makeFactory({ config_id: Factory.each((i) => i), gateway: '192.0.2.1', interface_id: Factory.each((i) => i), + ipv6_addresses: [ + { + slaac_address: '2001:DB8::0000', + }, + ], + ipv6_is_public: null, + ipv6_range: null, linode_id: Factory.each((i) => i), nat_1_1: '192.0.2.97', prefix: 24, diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index c95d4881b7e..dd2bf476489 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -1,3 +1,5 @@ +import { useAccount, useProfile } from '@linode/queries'; +import { BetaChip } from '@linode/ui'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { matchPath, useHistory, useLocation } from 'react-router-dom'; @@ -14,7 +16,6 @@ import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/use import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useAccount, useProfile } from '@linode/queries'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import AccountLogins from './AccountLogins'; @@ -89,6 +90,7 @@ const AccountLanding = () => { ...(showQuotasTab ? [ { + chip: , routeName: '/account/quotas', title: 'Quotas', }, diff --git a/packages/manager/src/features/Account/AccountLogins.tsx b/packages/manager/src/features/Account/AccountLogins.tsx index 3c3d4739c9c..2c06eb241bb 100644 --- a/packages/manager/src/features/Account/AccountLogins.tsx +++ b/packages/manager/src/features/Account/AccountLogins.tsx @@ -1,7 +1,9 @@ +import { useAccountLoginsQuery, useProfile } from '@linode/queries'; import { Notice, Typography } from '@linode/ui'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Hidden } from 'src/components/Hidden'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; @@ -15,14 +17,12 @@ import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading' import { TableSortCell } from 'src/components/TableSortCell'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useAccountLoginsQuery, useProfile } from '@linode/queries'; import AccountLoginsTableRow from './AccountLoginsTableRow'; import { getRestrictedResourceText } from './utils'; import type { AccountLogin } from '@linode/api-v4/lib/account/types'; import type { Theme } from '@mui/material/styles'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; const preferenceKey = 'account-logins'; diff --git a/packages/manager/src/features/Account/AccountLoginsTableRow.tsx b/packages/manager/src/features/Account/AccountLoginsTableRow.tsx index 837725aa1ef..cd76d5b8411 100644 --- a/packages/manager/src/features/Account/AccountLoginsTableRow.tsx +++ b/packages/manager/src/features/Account/AccountLoginsTableRow.tsx @@ -1,3 +1,4 @@ +import { useProfile } from '@linode/queries'; import { capitalize } from '@linode/utilities'; import * as React from 'react'; @@ -7,7 +8,6 @@ import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useProfile } from '@linode/queries'; import { formatDate } from 'src/utilities/formatDate'; import type { diff --git a/packages/manager/src/features/Account/CloseAccountDialog.tsx b/packages/manager/src/features/Account/CloseAccountDialog.tsx index 32ca167673f..3428969433e 100644 --- a/packages/manager/src/features/Account/CloseAccountDialog.tsx +++ b/packages/manager/src/features/Account/CloseAccountDialog.tsx @@ -1,4 +1,5 @@ import { cancelAccount } from '@linode/api-v4/lib/account'; +import { useProfile } from '@linode/queries'; import { Notice, TextField, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -9,7 +10,6 @@ import { CANCELLATION_DATA_LOSS_WARNING, CANCELLATION_DIALOG_TITLE, } from 'src/features/Account/constants'; -import { useProfile } from '@linode/queries'; import type { APIError } from '@linode/api-v4/lib/types'; diff --git a/packages/manager/src/features/Account/CloseAccountSetting.tsx b/packages/manager/src/features/Account/CloseAccountSetting.tsx index 2c5a03dc20d..cd7ba68b31a 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.tsx @@ -1,9 +1,8 @@ +import { useProfile } from '@linode/queries'; import { Accordion, Button } from '@linode/ui'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; -import { useProfile } from '@linode/queries'; - import CloseAccountDialog from './CloseAccountDialog'; import { CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, diff --git a/packages/manager/src/features/Account/DefaultFirewalls.test.tsx b/packages/manager/src/features/Account/DefaultFirewalls.test.tsx new file mode 100644 index 00000000000..a1d4b9478d1 --- /dev/null +++ b/packages/manager/src/features/Account/DefaultFirewalls.test.tsx @@ -0,0 +1,47 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; + +import { firewallFactory, firewallSettingsFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DefaultFirewalls } from './DefaultFirewalls'; + +const loadingTestId = 'circle-progress'; + +describe('NetworkInterfaces', () => { + it('renders the NetworkInterfaces accordion', async () => { + server.use( + http.get('*/v4beta/networking/firewalls/settings', () => + HttpResponse.json(firewallSettingsFactory.build()) + ), + http.get('*/v4beta/networking/firewalls', () => + HttpResponse.json(makeResourcePage(firewallFactory.buildList(1))) + ) + ); + const { getByTestId, getByText } = renderWithTheme(, { + flags: { linodeInterfaces: { enabled: true } }, + }); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText('Default Firewalls')).toBeVisible(); + expect(getByText('Linodes')).toBeVisible(); + expect( + getByText('Configuration Profile Interfaces Firewall') + ).toBeVisible(); + expect( + getByText('Linode Interfaces - Public Interface Firewall') + ).toBeVisible(); + expect( + getByText('Linode Interfaces - VPC Interface Firewall') + ).toBeVisible(); + expect(getByText('NodeBalancers')).toBeVisible(); + expect(getByText('NodeBalancers Firewall')).toBeVisible(); + expect(getByText('Save')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Account/DefaultFirewalls.tsx b/packages/manager/src/features/Account/DefaultFirewalls.tsx new file mode 100644 index 00000000000..8ebcb910a3d --- /dev/null +++ b/packages/manager/src/features/Account/DefaultFirewalls.tsx @@ -0,0 +1,177 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { + useFirewallSettingsQuery, + useMutateFirewallSettings, +} from '@linode/queries'; +import { + Accordion, + Box, + Button, + CircleProgress, + Divider, + ErrorState, + Notice, + Stack, + Typography, +} from '@linode/ui'; +import { UpdateFirewallSettingsSchema } from '@linode/validation'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; + +import { FirewallSelect } from '../Firewalls/components/FirewallSelect'; + +import type { UpdateFirewallSettings } from '@linode/api-v4'; + +const DEFAULT_FIREWALL_PLACEHOLDER = 'None'; + +export const DefaultFirewalls = () => { + const { enqueueSnackbar } = useSnackbar(); + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + + const { + data: firewallSettings, + error: firewallSettingsError, + isLoading: isLoadingFirewallSettings, + } = useFirewallSettingsQuery({ enabled: isLinodeInterfacesEnabled }); + + const { mutateAsync: updateFirewallSettings } = useMutateFirewallSettings(); + + const values = { + default_firewall_ids: { ...firewallSettings?.default_firewall_ids }, + }; + + const { + control, + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + setError, + } = useForm({ + defaultValues: { ...values }, + mode: 'onBlur', + resolver: yupResolver(UpdateFirewallSettingsSchema), + values, + }); + + const onSubmit = async (values: UpdateFirewallSettings) => { + try { + await updateFirewallSettings(values); + enqueueSnackbar('Default firewall settings updated.', { + variant: 'success', + }); + } catch (error) { + setError(error.field ?? 'root', { message: error[0].reason }); + } + }; + + if (isLoadingFirewallSettings) { + return ( + + + + ); + } + + if (firewallSettingsError) { + return ( + + + + ); + } + + return ( + +
+ {errors.root?.message && ( + {errors.root.message} + )} + + Set the default firewall that is assigned to each network interface + type when creating a Linode. The same firewall (new or existing) can + be assigned to each type of interface/connection. + + } spacing={2}> + + Linodes + ( + field.onChange(firewall.id)} + placeholder={DEFAULT_FIREWALL_PLACEHOLDER} + value={field.value} + /> + )} + control={control} + name="default_firewall_ids.linode" + /> + ( + field.onChange(firewall.id)} + placeholder={DEFAULT_FIREWALL_PLACEHOLDER} + value={field.value} + /> + )} + control={control} + name="default_firewall_ids.public_interface" + /> + ( + field.onChange(firewall.id)} + placeholder={DEFAULT_FIREWALL_PLACEHOLDER} + value={field.value} + /> + )} + control={control} + name="default_firewall_ids.vpc_interface" + /> + + + NodeBalancers + ( + field.onChange(firewall.id)} + placeholder={DEFAULT_FIREWALL_PLACEHOLDER} + value={field.value} + /> + )} + control={control} + name="default_firewall_ids.nodebalancer" + /> + + + ({ marginTop: theme.spacing(2) })}> + + +
+
+ ); +}; diff --git a/packages/manager/src/features/Account/GlobalSettings.tsx b/packages/manager/src/features/Account/GlobalSettings.tsx index 6c222089266..4a84bf86a3d 100644 --- a/packages/manager/src/features/Account/GlobalSettings.tsx +++ b/packages/manager/src/features/Account/GlobalSettings.tsx @@ -1,19 +1,20 @@ +import { + useAccountSettings, + useAllLinodesQuery, + useMutateAccountSettings, +} from '@linode/queries'; import { CircleProgress, ErrorState } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { - useAccountSettings, - useMutateAccountSettings, - useAllLinodesQuery, -} from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { BackupDrawer } from '../Backups'; import AutoBackups from './AutoBackups'; import CloseAccountSetting from './CloseAccountSetting'; +import { DefaultFirewalls } from './DefaultFirewalls'; import { EnableManaged } from './EnableManaged'; import NetworkHelper from './NetworkHelper'; import { NetworkInterfaceType } from './NetworkInterfaceType'; @@ -84,6 +85,7 @@ const GlobalSettings = () => {
{isLinodeInterfacesEnabled && } + {isLinodeInterfacesEnabled && } { + const { enqueueSnackbar } = useSnackbar(); const { data: accountSettings } = useAccountSettings(); const { mutateAsync: updateAccountSettings } = useMutateAccountSettings(); @@ -67,6 +69,9 @@ export const NetworkInterfaceType = () => { const onSubmit = async (values: InterfaceSettingValues) => { try { await updateAccountSettings(values); + enqueueSnackbar('Network Interface type settings updated.', { + variant: 'success', + }); } catch (error) { setError('interfaces_for_new_linodes', { message: error[0].reason }); } @@ -76,7 +81,7 @@ export const NetworkInterfaceType = () => { } + headingChip={} >
diff --git a/packages/manager/src/features/Account/ObjectStorageSettings.tsx b/packages/manager/src/features/Account/ObjectStorageSettings.tsx index db2e0a7522a..ee26d882c75 100644 --- a/packages/manager/src/features/Account/ObjectStorageSettings.tsx +++ b/packages/manager/src/features/Account/ObjectStorageSettings.tsx @@ -1,3 +1,4 @@ +import { useAccountSettings, useProfile } from '@linode/queries'; import { Accordion, Box, @@ -12,7 +13,6 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; -import { useAccountSettings, useProfile } from '@linode/queries'; import { useCancelObjectStorageMutation } from 'src/queries/object-storage/queries'; export const ObjectStorageSettings = () => { diff --git a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx index df347dfd92d..d90cef86c43 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx @@ -1,9 +1,9 @@ +import { regionFactory } from '@linode/utilities'; import { QueryClient } from '@tanstack/react-query'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { regionFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { Quotas } from './Quotas'; diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index 60089bd7eba..6db0c64c2eb 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -1,4 +1,5 @@ import { quotaTypes } from '@linode/api-v4'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Divider, Paper, Select, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -6,6 +7,7 @@ import { useHistory } from 'react-router-dom'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useFlags } from 'src/hooks/useFlags'; import { QuotasTable } from './QuotasTable'; import { useGetLocationsForQuotaService } from './utils'; @@ -15,6 +17,11 @@ import type { SelectOption } from '@linode/ui'; import type { Theme } from '@mui/material'; export const Quotas = () => { + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const history = useHistory(); const [selectedService, setSelectedService] = React.useState< SelectOption @@ -110,6 +117,7 @@ export const Quotas = () => { currentCapability={undefined} disableClearable disabled={isFetchingLocations} + isGeckoLAEnabled={isGeckoLAEnabled} loading={isFetchingLocations} noOptionsText={`No resource found for ${selectedService.label}`} regions={regions ?? []} diff --git a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx index 2d30fe2400c..c7c8f901d5e 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx @@ -1,5 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { useProfile } from '@linode/queries'; +import { useCreateSupportTicketMutation, useProfile } from '@linode/queries'; import { Accordion, ActionsPanel, @@ -13,7 +13,6 @@ import * as React from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { Markdown } from 'src/components/Markdown/Markdown'; -import { useCreateSupportTicketMutation } from 'src/queries/support'; import { getQuotaIncreaseFormSchema, getQuotaIncreaseMessage } from './utils'; diff --git a/packages/manager/src/features/Account/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx index 945e816fedd..e9c2b548e86 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -7,7 +7,17 @@ import type { ButtonProps } from '@linode/ui'; export const SwitchAccountButton = (props: ButtonProps) => { return ( - ); diff --git a/packages/manager/src/features/Account/utils.ts b/packages/manager/src/features/Account/utils.ts index 4fabcaa6edf..cd7aa09578c 100644 --- a/packages/manager/src/features/Account/utils.ts +++ b/packages/manager/src/features/Account/utils.ts @@ -18,6 +18,8 @@ export type ActionType = | 'rebuild' | 'rescue' | 'resize' + | 'resume' + | 'suspend' | 'view'; interface GetRestrictedResourceText { diff --git a/packages/manager/src/features/Backups/BackupDrawer.test.tsx b/packages/manager/src/features/Backups/BackupDrawer.test.tsx index be8bdcde0f7..eb58026abc9 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.test.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.test.tsx @@ -1,14 +1,15 @@ +import { linodeFactory } from '@linode/utilities'; import * as React from 'react'; -import { - accountSettingsFactory, - linodeFactory, - typeFactory, -} from 'src/factories'; + +import { accountSettingsFactory, typeFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { BackupDrawer } from './BackupDrawer'; const queryMocks = vi.hoisted(() => ({ + useAccountSettings: vi.fn().mockReturnValue({ + data: undefined, + }), useAllLinodesQuery: vi.fn().mockReturnValue({ data: undefined, }), @@ -18,9 +19,6 @@ const queryMocks = vi.hoisted(() => ({ useTypeQuery: vi.fn().mockReturnValue({ data: undefined, }), - useAccountSettings: vi.fn().mockReturnValue({ - data: undefined, - }), })); vi.mock('@linode/queries', async () => { @@ -51,8 +49,6 @@ vi.mock('src/queries/accountSettings', async () => { describe('BackupDrawer', () => { beforeEach(() => { const mockType = typeFactory.build({ - id: 'mock-linode-type', - label: 'Mock Linode Type', addons: { backups: { price: { @@ -68,6 +64,8 @@ describe('BackupDrawer', () => { ], }, }, + id: 'mock-linode-type', + label: 'Mock Linode Type', }); queryMocks.useAccountSettings.mockReturnValue({ data: accountSettingsFactory.build({ @@ -87,20 +85,20 @@ describe('BackupDrawer', () => { queryMocks.useAllLinodesQuery.mockReturnValue({ data: [ linodeFactory.build({ + backups: { enabled: false }, region: 'es-mad', type: 'mock-linode-type', - backups: { enabled: false }, }), ...linodeFactory.buildList(5, { + backups: { enabled: false }, region: 'us-east', type: 'mock-linode-type', - backups: { enabled: false }, }), ], }); const { findByText } = renderWithTheme( - + ); expect(await findByText('Total for 6 Linodes:')).toBeVisible(); expect(await findByText('$12.50')).toBeVisible(); @@ -110,15 +108,15 @@ describe('BackupDrawer', () => { queryMocks.useAllLinodesQuery.mockReturnValue({ data: [ linodeFactory.build({ + backups: { enabled: false }, region: 'es-mad', type: 'mock-linode-type', - backups: { enabled: false }, }), ], }); const { findByText } = renderWithTheme( - + ); expect(await findByText('Total for 1 Linode:')).toBeVisible(); expect(await findByText('$0.00')).toBeVisible(); @@ -134,7 +132,7 @@ describe('BackupDrawer', () => { }); const { findByText } = renderWithTheme( - + ); expect(await findByText('Total for 1 Linode:')).toBeVisible(); expect(await findByText('$--.--')).toBeVisible(); @@ -156,7 +154,7 @@ describe('BackupDrawer', () => { }); const { findByText, queryByText } = renderWithTheme( - + ); // Confirm that Linodes without backups are listed in table. /* eslint-disable no-await-in-loop */ diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx index 4a648a419a5..6c891619547 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.test.tsx @@ -1,6 +1,6 @@ +import { linodeFactory, linodeTypeFactory } from '@linode/utilities'; import * as React from 'react'; -import { linodeFactory, linodeTypeFactory } from 'src/factories/linodes'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Backups/BackupsCTA.test.tsx b/packages/manager/src/features/Backups/BackupsCTA.test.tsx index cddd228d6ed..a09eff4c069 100644 --- a/packages/manager/src/features/Backups/BackupsCTA.test.tsx +++ b/packages/manager/src/features/Backups/BackupsCTA.test.tsx @@ -1,10 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import * as React from 'react'; -import { - accountSettingsFactory, - linodeFactory, - profileFactory, -} from 'src/factories'; +import { accountSettingsFactory, profileFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.test.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.test.ts index ce429358e8d..4bb008048f8 100644 --- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.test.ts +++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.test.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import fs from 'fs'; import { PdfReader } from 'pdfreader'; @@ -6,7 +7,6 @@ import { invoiceFactory, invoiceItemFactory, paymentFactory, - regionFactory, } from 'src/factories'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Billing/PdfGenerator/utils.test.ts b/packages/manager/src/features/Billing/PdfGenerator/utils.test.ts index c69336a539b..943f8580518 100644 --- a/packages/manager/src/features/Billing/PdfGenerator/utils.test.ts +++ b/packages/manager/src/features/Billing/PdfGenerator/utils.test.ts @@ -1,11 +1,13 @@ -import { invoiceItemFactory, regionFactory } from 'src/factories'; +import { regionFactory } from '@linode/utilities'; + +import { ADDRESSES } from 'src/constants'; +import { invoiceItemFactory } from 'src/factories'; import { getInvoiceRegion, getRemitAddress, invoiceCreatedAfterDCPricingLaunch, } from './utils'; -import { ADDRESSES } from 'src/constants'; describe('getInvoiceRegion', () => { it('should get a formatted label given invoice items and regions', () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx index e99ca26e930..10c91aa20ca 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx @@ -1,10 +1,9 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import React from 'react'; import { alertFactory, - linodeFactory, notificationChannelFactory, - regionFactory, serviceTypesFactory, } from 'src/factories/'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -106,7 +105,7 @@ describe('AlertDetail component tests', () => { queryMocks.useAlertDefinitionQuery.mockReturnValueOnce({ data: null, isError: false, - isFetching: true, + isLoading: true, }); const { getByTestId } = renderWithTheme(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx index 691e3127f1b..a351c28e009 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx @@ -29,7 +29,7 @@ export interface AlertRouteParams { export const AlertDetail = () => { const { alertId, serviceType } = useParams(); - const { data: alertDetails, isError, isFetching } = useAlertDefinitionQuery( + const { data: alertDetails, isError, isLoading } = useAlertDefinitionQuery( alertId, serviceType ); @@ -54,7 +54,7 @@ export const AlertDetail = () => { const nonSuccessBoxHeight = '600px'; const sectionMaxHeight = '785px'; - if (isFetching) { + if (isLoading) { return ( <> @@ -159,6 +159,7 @@ export const StyledPlaceholder = styled(Placeholder, { h1: { fontSize: theme.spacing(2), }, + padding: 0, svg: { maxHeight: theme.spacing(10), }, diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx index f1fe04dfae0..fd57fad5f4f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx @@ -66,11 +66,7 @@ export const AlertsLanding = React.memo(() => { docsLabel="Docs" docsLink="https://techdocs.akamai.com/cloud-computing/docs/akamai-cloud-pulse" /> - + diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx index 29338d9c14f..8ebd5c96310 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx @@ -5,6 +5,7 @@ import { alertFactory } from 'src/factories'; import { formatDate } from 'src/utilities/formatDate'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { UPDATE_ALERT_SUCCESS_MESSAGE } from '../constants'; import { AlertsListTable } from './AlertListTable'; const queryMocks = vi.hoisted(() => ({ @@ -97,7 +98,7 @@ describe('Alert List Table test', () => { const actionMenu = getByLabelText(`Action menu for Alert ${alert.label}`); await userEvent.click(actionMenu); await userEvent.click(getByText('Enable')); // click the enable button to enable alert - expect(getByText('Alert enabled')).toBeInTheDocument(); // validate whether snackbar is displayed properly if alert is enabled successfully + expect(getByText(UPDATE_ALERT_SUCCESS_MESSAGE)).toBeInTheDocument(); // validate whether snackbar is displayed properly }); it('should show success snackbar when disabling alert succeeds', async () => { @@ -114,7 +115,7 @@ describe('Alert List Table test', () => { const actionMenu = getByLabelText(`Action menu for Alert ${alert.label}`); await userEvent.click(actionMenu); await userEvent.click(getByText('Disable')); // click the enable button to enable alert - expect(getByText('Alert disabled')).toBeInTheDocument(); // validate whether snackbar is displayed properly if alert is disabled successfully + expect(getByText(UPDATE_ALERT_SUCCESS_MESSAGE)).toBeInTheDocument(); // validate whether snackbar is displayed properly }); it('should show error snackbar when enabling alert fails', async () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx index ccd5d2a516c..4523734aa4f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx @@ -14,6 +14,7 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { useEditAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { UPDATE_ALERT_SUCCESS_MESSAGE } from '../constants'; import { AlertTableRow } from './AlertTableRow'; import { AlertListingTableLabelMap } from './constants'; @@ -71,7 +72,7 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { }) .then(() => { // Handle success - enqueueSnackbar(`Alert ${toggleStatus}`, { + enqueueSnackbar(UPDATE_ALERT_SUCCESS_MESSAGE, { variant: 'success', }); }) diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx index 34651adcc73..9532552fe2e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx @@ -1,7 +1,7 @@ +import { regionFactory } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { regionFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AlertsRegionFilter } from './AlertsRegionFilter'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx index f200dfb19ad..e0556a70f19 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx @@ -1,6 +1,8 @@ +import { useIsGeckoEnabled } from '@linode/shared'; import React from 'react'; import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; +import { useFlags } from 'src/hooks/useFlags'; import type { Region } from '@linode/api-v4'; @@ -19,6 +21,11 @@ export const AlertsRegionFilter = React.memo((props: AlertsRegionProps) => { const { handleSelectionChange, regionOptions } = props; const [selectedRegion, setSelectedRegion] = React.useState([]); + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const handleRegionChange = React.useCallback( (regionIds: string[]) => { @@ -47,6 +54,7 @@ export const AlertsRegionFilter = React.memo((props: AlertsRegionProps) => { currentCapability={undefined} // this is a required property, no specific capability required here disableSelectAll isClearable + isGeckoLAEnabled={isGeckoLAEnabled} label="Select Regions" limitTags={1} onChange={handleRegionChange} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx index 1e59a95f23f..1616c915642 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx @@ -1,8 +1,8 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { linodeFactory, regionFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AlertResources } from './AlertsResources'; @@ -192,7 +192,7 @@ describe('AlertResources component tests', () => { it('should handle selection correctly and publish', async () => { const handleResourcesSelection = vi.fn(); - const { getByTestId, queryByTestId, getByText } = renderWithTheme( + const { getByTestId, getByText, queryByTestId } = renderWithTheme( { } = useRegionsQuery(); const flags = useFlags(); + const theme = useTheme(); // Validate launchDarkly region ids with the ids from regionOptions prop const supportedRegionIds = getSupportedRegionIds( @@ -336,7 +338,15 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { } const filtersToRender = serviceToFiltersMap[serviceType ?? '']; - + const noticeStyles: React.CSSProperties = { + alignItems: 'center', + backgroundColor: theme.tokens.alias.Background.Normal, + borderRadius: 1, + display: 'flex', + flexWrap: 'nowrap', + marginBottom: 0, + padding: theme.spacingFunction(16), + }; return ( {!hideLabel && ( @@ -415,13 +425,24 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { )} {errorText?.length && ( - + + + )} {maxSelectionCount !== undefined && ( - + + + )} {isSelectionsNeeded && !isDataLoadingError && diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index a36dee43e80..37240c8bca9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -12,7 +12,16 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { useFlags } from 'src/hooks/useFlags'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; -import { enhanceValidationSchemaWithEntityIdValidation } from '../Utils/utils'; +import { + CREATE_ALERT_ERROR_FIELD_MAP, + MULTILINE_ERROR_SEPARATOR, + SINGLELINE_ERROR_SEPARATOR, + CREATE_ALERT_SUCCESS_MESSAGE +} from '../constants'; +import { + enhanceValidationSchemaWithEntityIdValidation, + handleMultipleError, +} from '../Utils/utils'; import { MetricCriteriaField } from './Criteria/MetricCriteria'; import { TriggerConditions } from './Criteria/TriggerConditions'; import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; @@ -27,6 +36,7 @@ import type { MetricCriteriaForm, TriggerConditionForm, } from './types'; +import type { APIError } from '@linode/api-v4'; import type { ObjectSchema } from 'yup'; const triggerConditionInitialValues: TriggerConditionForm = { @@ -110,20 +120,24 @@ export const CreateAlertDefinition = () => { const onSubmit = handleSubmit(async (values) => { try { await createAlert(filterFormValues(values)); - enqueueSnackbar('Alert successfully created', { + enqueueSnackbar(CREATE_ALERT_SUCCESS_MESSAGE, { variant: 'success', }); alertCreateExit(); } catch (errors) { - for (const error of errors) { - if (error.field) { - setError(error.field, { message: error.reason }); - } else { - enqueueSnackbar(`Alert failed: ${error.reason}`, { - variant: 'error', - }); - setError('root', { message: error.reason }); - } + handleMultipleError({ + errorFieldMap: CREATE_ALERT_ERROR_FIELD_MAP, + errors, + multiLineErrorSeparator: MULTILINE_ERROR_SEPARATOR, + setError, + singleLineErrorSeparator: SINGLELINE_ERROR_SEPARATOR, + }); + + const rootError = errors.find((error: APIError) => !error.field); + if (rootError) { + enqueueSnackbar(`Creating alert failed: ${rootError.reason}`, { + variant: 'error', + }); } } }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx index 75a0343caf6..7a1135e129b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx @@ -1,5 +1,4 @@ -import { Box } from '@linode/ui'; -import { Button, Stack, Typography } from '@linode/ui'; +import { Box, Button, Stack, Typography } from '@linode/ui'; import React from 'react'; import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx index db0e92292ed..3bd8d686cb5 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx @@ -1,9 +1,16 @@ import { Button, Stack, Typography } from '@linode/ui'; import * as React from 'react'; -import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { + Controller, + useFieldArray, + useFormContext, + useWatch, +} from 'react-hook-form'; import { useGetCloudPulseMetricDefinitionsByServiceType } from 'src/queries/cloudpulse/services'; +import { MULTILINE_ERROR_SEPARATOR } from '../../constants'; +import { AlertListNoticeMessages } from '../../Utils/AlertListNoticeMessages'; import { convertToSeconds } from '../utilities'; import { Metric } from './Metric'; @@ -66,45 +73,60 @@ export const MetricCriteriaField = (props: MetricCriteriaProps) => { }); return ( - - 3. Criteria - - {fields !== null && - fields.length !== 0 && - fields.map((field, index) => { - return ( - remove(index)} - showDeleteIcon={fields.length > 1} + ( + + 3. Criteria + {formState.isSubmitted && + fieldState.error && + fieldState.error.message?.length && ( + - ); - })} - - - + )} + + {fields !== null && + fields.length !== 0 && + fields.map((field, index) => { + return ( + remove(index)} + showDeleteIcon={fields.length > 1} + /> + ); + })} + + + + )} + control={control} + name={name} + /> ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx index ed607e593c0..f228e02d22d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx @@ -1,8 +1,10 @@ import { useRegionsQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import * as React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useFlags } from 'src/hooks/useFlags'; import type { CreateAlertDefinitionForm } from '../types'; import type { FieldPathByValue } from 'react-hook-form'; @@ -18,6 +20,12 @@ export const CloudPulseRegionSelect = (props: CloudViewRegionSelectProps) => { const { name } = props; const { data: regions, isError, isLoading } = useRegionsQuery(); const { control } = useFormContext(); + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + return ( ( @@ -31,6 +39,7 @@ export const CloudPulseRegionSelect = (props: CloudViewRegionSelectProps) => { }} currentCapability={undefined} fullWidth + isGeckoLAEnabled={isGeckoLAEnabled} label="Region" loading={isLoading} placeholder="Select a Region" diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx index d89fe9d3a24..4436ae11d1a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx @@ -1,8 +1,8 @@ +import { linodeFactory } from '@linode/utilities'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { linodeFactory } from 'src/factories'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { CloudPulseMultiResourceSelect } from './ResourceMultiSelect'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx index 16569bd8243..02c0606c882 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx @@ -1,11 +1,12 @@ -import { Box, Button, Notice, Stack, Typography } from '@linode/ui'; +import { Box, Button, Stack, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { useAllAlertNotificationChannelsQuery } from 'src/queries/cloudpulse/alerts'; -import { channelTypeOptions } from '../../constants'; +import { MULTILINE_ERROR_SEPARATOR, channelTypeOptions } from '../../constants'; +import { AlertListNoticeMessages } from '../../Utils/AlertListNoticeMessages'; import { getAlertBoxStyles } from '../../Utils/utils'; import { ClearIconButton } from '../Criteria/ClearIconButton'; import { AddNotificationChannelDrawer } from './AddNotificationChannelDrawer'; @@ -140,32 +141,38 @@ export const AddChannelListing = (props: AddChannelListingProps) => { return ( ( - <> - - 4. Notification Channels - - {(formState.isSubmitted || fieldState.isTouched) && fieldState.error && ( - - {fieldState.error.message} - - )} - - {selectedNotifications.length > 0 && - selectedNotifications.map((notification, id) => ( + + 4. Notification Channels + {(formState.isSubmitted || fieldState.isTouched) && + fieldState.error && + fieldState.error.message?.length && ( + + )} + {selectedNotifications.length > 0 && ( + + {selectedNotifications.map((notification, id) => ( ))} - + + )} + + - {!engineConfigs && ( + {configs.map((config, index) => ( + { + return ( + handleRemoveConfig(index)} + /> + ); + }} + control={control} + key={config.label} + name={`configs.${index}.value`} + /> + ))} + {configs.length === 0 && ( No advanced configurations have been added. @@ -59,12 +226,14 @@ export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.style.ts new file mode 100644 index 00000000000..09c8fa54488 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.style.ts @@ -0,0 +1,28 @@ +import { Box, Chip } from '@linode/ui'; +import { styled } from '@mui/material'; + +export const StyledWrapper = styled(Box, { + label: 'StyledWrapper', +})(({ theme }) => ({ + marginBottom: theme.tokens.spacing.S12, +})); + +export const StyledBox = styled(Box, { + label: 'StyledBox', +})(({ theme }) => ({ + background: theme.tokens.alias.Background.Neutral, + padding: theme.tokens.spacing.S8, + width: '100%', +})); + +export const StyledChip = styled(Chip, { + label: 'StyledChip', +})(({ theme }) => ({ + backgroundColor: + theme.palette.mode === 'dark' + ? theme.tokens.alias.Background.Warningsubtle + : theme.tokens.color.Amber[5], + color: theme.tokens.alias.Accent.Warning.Primary, + font: theme.tokens.alias.Typography.Heading.Overline, + textTransform: theme.tokens.alias.Typography.Heading.OverlineTextCase, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx new file mode 100644 index 00000000000..acd1a3c046d --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx @@ -0,0 +1,149 @@ +import { + Autocomplete, + FormControlLabel, + IconButton, + TextField, + Toggle, + Typography, +} from '@linode/ui'; +import Close from '@mui/icons-material/Close'; +import React from 'react'; + +import { + formatConfigValue, + isConfigBoolean, + isConfigStringWithEnum, +} from '../../utilities'; +import { + StyledBox, + StyledChip, + StyledWrapper, +} from './DatabaseConfigurationItem.style'; + +import type { ConfigurationOption } from './DatabaseConfigurationSelect'; +import type { ConfigValue } from '@linode/api-v4'; + +interface Props { + configItem?: ConfigurationOption; + engine: string; + errorText: string | undefined; + onChange: (config: ConfigValue) => void; + onRemove?: (label: string) => void; +} + +export const DatabaseConfigurationItem = (props: Props) => { + const { configItem, engine, errorText, onChange, onRemove } = props; + const configLabel = configItem?.label || ''; + + const renderInputField = () => { + if (configItem && isConfigBoolean(configItem)) { + return ( + onChange(e.target.checked)} + /> + } + label={formatConfigValue(String(configItem.value))} + /> + ); + } + if (configItem && isConfigStringWithEnum(configItem)) { + const options = + configItem.enum?.map((option) => ({ label: option })) || []; + const selectedValue = options.find( + (option) => option.label === String(configItem.value) + ); + return ( + { + onChange(selected?.label ?? ''); + }} + renderInput={(params) => ( + + )} + disableClearable + filterOptions={(options) => options} + isOptionEqualToValue={(option, value) => option.label === value.label} + label={''} + options={options} + value={selectedValue ?? options[0]} + /> + ); + } + if (configItem?.type === 'number' || configItem?.type === 'integer') { + return ( + onChange(Number(e.target.value))} + type="number" + value={Number(configItem.value)} + /> + ); + } + + if ( + configItem?.type === 'string' || + (Array.isArray(configItem?.type) && + configItem?.type.includes('string') && + !configItem.enum) + ) { + return ( + onChange(e.target.value)} + placeholder={String(configItem.example)} + type="text" + value={configItem.value ? String(configItem.value) : ''} + /> + ); + } + return null; + }; + + return ( + + + ({ + font: theme.tokens.alias.Typography.Body.Bold, + mr: 0.5, + })} + > + {`${engine}.${configLabel}`} + + {configItem?.restart_cluster && ( + + )} + {configItem?.description && ( + {configItem?.description} + )} + {renderInputField()} + + + {configItem?.isNew && configItem && onRemove && ( + onRemove(configItem?.label)} + size="large" + > + + + )} + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect.tsx index 056c4449695..87f6daef80e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect.tsx @@ -1,84 +1,83 @@ -import { Autocomplete, Button, TextField } from '@linode/ui'; -import Grid from '@mui/material/Grid2'; +import { Autocomplete, TextField } from '@linode/ui'; import React from 'react'; -interface ConfigurationOption { +import { GroupHeader, GroupItems } from './DatabaseAdvancedConfiguration.style'; + +import type { ConfigValue, ConfigurationItem } from '@linode/api-v4'; + +export interface ConfigurationOption extends ConfigurationItem { category: string; - description: string; + isNew?: boolean; label: string; + value?: ConfigValue; } interface Props { configurations: ConfigurationOption[]; errorText: string | undefined; - onChange: (value: string) => void; - value: string; + label: string; + onChange: (value: ConfigurationOption) => void; } export const DatabaseConfigurationSelect = (props: Props) => { - const { configurations, errorText, onChange, value } = props; + const { configurations, errorText, label, onChange } = props; - const selectedConfiguration = React.useMemo(() => { - return configurations.find((val) => val.label === value); - }, [value, configurations]); + const selectedConfig = configurations.find((val) => val.label === label); return ( - - - { - if (option.category === 'Other') { - return 'Other'; - } - return option.category; - }} - isOptionEqualToValue={(option, selectedValue) => - option.label === selectedValue.label - } - onChange={(_, selected) => { - onChange(selected.label); - }} - renderInput={(params) => ( - - )} - renderOption={(props, option) => ( -
  • -
    - {option.label} - {/* TODO: Add description if needed */} - {/* {option.description &&
    {option.description}
    } */} -
    -
  • - )} - autoHighlight - disableClearable - getOptionLabel={(option) => option.label} - label={''} - options={configurations} - sx={{ width: '336px' }} - value={selectedConfiguration} + { + if (option.category === 'other') { + return 'other'; + } + return option.category; + }} + isOptionEqualToValue={(option, selectedValue) => + option.label === selectedValue.label + } + onChange={(_, selected) => { + onChange(selected!); + }} + options={[...configurations].sort((a, b) => { + if (a.category === 'other') return 1; + if (b.category === 'other') return -1; + return a.category.localeCompare(b.category); + })} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} + renderInput={(params) => ( + -
    - - - -
    + )} + renderOption={(props, option) => ( +
  • + {option.label} +
  • + )} + slotProps={{ + listbox: { + style: { + padding: 0, + }, + }, + }} + sx={{ + width: '316px', + }} + autoHighlight + clearIcon={null} + getOptionLabel={(option) => option.label} + label={''} + value={selectedConfig ?? null} + /> ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx index 50aef5ccbd0..f97bbac4cb5 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx @@ -1,3 +1,4 @@ +import { useProfile } from '@linode/queries'; import { Divider, Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; @@ -16,7 +17,6 @@ import { isDefaultDatabase, useIsDatabasesEnabled, } from 'src/features/Databases/utilities'; -import { useProfile } from '@linode/queries'; import AccessControls from '../AccessControls'; import DatabaseSettingsDeleteClusterDialog from './DatabaseSettingsDeleteClusterDialog'; @@ -148,7 +148,7 @@ export const DatabaseSettings: React.FC = (props) => { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx index 859fffcd0e9..5f6e0ab7026 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx @@ -11,14 +11,12 @@ import { DatabaseSummary } from './DatabaseSummary'; import type { Database } from '@linode/api-v4'; const CLUSTER_CONFIGURATION = 'Cluster Configuration'; -const THREE_NODE = 'Primary (+2 Nodes)'; + const TWO_NODE = 'Primary (+1 Node)'; const VERSION = 'Version'; const CONNECTION_DETAILS = 'Connection Details'; const PRIVATE_NETWORK_HOST = 'Private Network Host'; -const PRIVATE_NETWORK_HOST_LABEL = 'private network host'; -const READONLY_HOST_LABEL = 'read-only host'; const GA_READONLY_HOST_LABEL = 'Read-only Host'; const ACCESS_CONTROLS = 'Access Controls'; @@ -27,12 +25,6 @@ const DEFAULT_PLATFORM = 'rdbms-default'; const DEFAULT_PRIMARY = 'db-mysql-default-primary.net'; const DEFAULT_STANDBY = 'db-mysql-default-standby.net'; -const LEGACY_PLATFORM = 'rdbms-legacy'; -const LEGACY_PRIMARY = 'db-mysql-legacy-primary.net'; -const LEGACY_SECONDARY = 'db-mysql-legacy-secondary.net'; - -const BUTTON_ACCESS_CONTROLS = 'button-access-control'; - const spy = vi.spyOn(utils, 'useIsDatabasesEnabled'); spy.mockReturnValue({ isDatabasesEnabled: true, @@ -71,143 +63,4 @@ describe('Database Summary', () => { expect(queryAllByText(ACCESS_CONTROLS)).toHaveLength(0); }); }); - - it('should render V2GA view legacy db', async () => { - const database = databaseFactory.build({ - cluster_size: 3, - hosts: { - primary: LEGACY_PRIMARY, - secondary: LEGACY_SECONDARY, - }, - platform: LEGACY_PLATFORM, - }) as Database; - - const { queryAllByText } = renderWithTheme( - - ); - - await waitFor(() => { - expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); - expect(queryAllByText(THREE_NODE)).toHaveLength(1); - expect(queryAllByText(VERSION)).toHaveLength(0); - - expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); - expect(queryAllByText(PRIVATE_NETWORK_HOST)).toHaveLength(1); - expect(queryAllByText(GA_READONLY_HOST_LABEL)).toHaveLength(0); - expect(queryAllByText(LEGACY_SECONDARY)).toHaveLength(1); - - expect(queryAllByText(ACCESS_CONTROLS)).toHaveLength(0); - }); - }); - - it('should render Beta view default db', async () => { - spy.mockReturnValue({ - isDatabasesEnabled: true, - isDatabasesV2Beta: true, - isDatabasesV2Enabled: true, - isDatabasesV2GA: false, - isUserExistingBeta: true, - isUserNewBeta: false, - }); - const database = databaseFactory.build({ - cluster_size: 2, - hosts: { - primary: DEFAULT_PRIMARY, - secondary: undefined, - standby: DEFAULT_STANDBY, - }, - platform: DEFAULT_PLATFORM, - }) as Database; - - const { getByTestId, queryAllByText } = renderWithTheme( - - ); - - await waitFor(() => { - expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); - expect(queryAllByText(TWO_NODE)).toHaveLength(1); - expect(queryAllByText(VERSION)).toHaveLength(1); - - expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); - expect(queryAllByText(PRIVATE_NETWORK_HOST_LABEL)).toHaveLength(0); - expect(queryAllByText(READONLY_HOST_LABEL)).toHaveLength(1); - expect(queryAllByText(/db-mysql-default-standby.net/)).toHaveLength(1); - - expect(getByTestId(BUTTON_ACCESS_CONTROLS)).toBeInTheDocument(); - }); - }); - - it('should render Beta view legacy db', async () => { - spy.mockReturnValue({ - isDatabasesEnabled: true, - isDatabasesV2Beta: true, - isDatabasesV2Enabled: true, - isDatabasesV2GA: false, - isUserExistingBeta: true, - isUserNewBeta: false, - }); - const database = databaseFactory.build({ - cluster_size: 3, - hosts: { - primary: LEGACY_PRIMARY, - secondary: LEGACY_SECONDARY, - standby: undefined, - }, - platform: LEGACY_PLATFORM, - }) as Database; - - const { getByTestId, queryAllByText } = renderWithTheme( - - ); - - await waitFor(() => { - expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); - expect(queryAllByText(THREE_NODE)).toHaveLength(1); - expect(queryAllByText(VERSION)).toHaveLength(1); - - expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); - expect(queryAllByText(PRIVATE_NETWORK_HOST_LABEL)).toHaveLength(1); - expect(queryAllByText(READONLY_HOST_LABEL)).toHaveLength(0); - expect(queryAllByText(/db-mysql-legacy-secondary.net/)).toHaveLength(1); - - expect(getByTestId(BUTTON_ACCESS_CONTROLS)).toBeInTheDocument(); - }); - }); - - it('should render V1 view legacy db', async () => { - spy.mockReturnValue({ - isDatabasesEnabled: true, - isDatabasesV2Beta: false, - isDatabasesV2Enabled: false, - isDatabasesV2GA: false, - isUserExistingBeta: false, - isUserNewBeta: false, - }); - const database = databaseFactory.build({ - cluster_size: 3, - hosts: { - primary: LEGACY_PRIMARY, - secondary: LEGACY_SECONDARY, - standby: undefined, - }, - platform: LEGACY_PLATFORM, - }) as Database; - - const { getByTestId, queryAllByText } = renderWithTheme( - - ); - - await waitFor(() => { - expect(queryAllByText(CLUSTER_CONFIGURATION)).toHaveLength(1); - expect(queryAllByText(THREE_NODE)).toHaveLength(1); - expect(queryAllByText(VERSION)).toHaveLength(1); - - expect(queryAllByText(CONNECTION_DETAILS)).toHaveLength(1); - expect(queryAllByText(PRIVATE_NETWORK_HOST_LABEL)).toHaveLength(1); - expect(queryAllByText(READONLY_HOST_LABEL)).toHaveLength(0); - expect(queryAllByText(/db-mysql-legacy-secondary.net/)).toHaveLength(1); - - expect(getByTestId(BUTTON_ACCESS_CONTROLS)).toBeInTheDocument(); - }); - }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index 97a8f09d416..c97e81f0d26 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -1,14 +1,9 @@ -import { Divider, Paper, Typography } from '@linode/ui'; +import { Paper } from '@linode/ui'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; -import { Link } from 'src/components/Link'; -import AccessControls from 'src/features/Databases/DatabaseDetail/AccessControls'; import ClusterConfiguration from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration'; import ConnectionDetails from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails'; -import ClusterConfigurationLegacy from 'src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy'; -import ConnectionDetailsLegacy from 'src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy'; -import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import type { Database } from '@linode/api-v4/lib/databases/types'; @@ -18,71 +13,28 @@ interface Props { } export const DatabaseSummary: React.FC = (props) => { - const { database, disabled } = props; - const { isDatabasesV2GA } = useIsDatabasesEnabled(); - - const description = ( - <> - - Add IPv4 addresses or ranges that should be authorized to access this - cluster. All other public and private connections are denied.{' '} - - Learn more - - . - - - You can add or modify access controls after your database cluster is - active. - - - ); + const { database } = props; return ( - {isDatabasesV2GA ? ( - - ) : ( - // Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 - // TODO (UIE-8214) remove POST GA - - )} + - {isDatabasesV2GA ? ( - - ) : ( - // Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 - // TODO (UIE-8214) remove POST GA - - )} + - {!isDatabasesV2GA && ( - // Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 - // AccessControls accessible through dropdown menu on landing page table and on settings tab - // TODO (UIE-8214) remove POST GA - <> - - - - )} ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts index c6420ce478f..dbc39604847 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; -import { styled } from '@mui/material/styles'; import Grid2 from '@mui/material/Grid2'; +import { styled } from '@mui/material/styles'; export const StyledGridContainer = styled(Grid2, { label: 'StyledGridContainer', @@ -28,10 +28,7 @@ export const StyledGridContainer = styled(Grid2, { export const StyledLabelTypography = styled(Typography, { label: 'StyledLabelTypography', })(({ theme }) => ({ - background: - theme.palette.mode === 'dark' - ? theme.bg.tableHeader - : theme.palette.grey[200], + background: theme.tokens.alias.Background.Neutral, color: theme.palette.mode === 'dark' ? theme.color.grey6 : 'inherit', font: theme.font.bold, height: '100%', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx index 454abcdbfb2..588b6f5fe1e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx @@ -1,8 +1,8 @@ +import { regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { databaseFactory, databaseTypeFactory } from 'src/factories/databases'; -import { regionFactory } from 'src/factories/regions'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { DatabaseSummaryClusterConfiguration } from './DatabaseSummaryClusterConfiguration'; @@ -105,75 +105,6 @@ describe('DatabaseSummaryClusterConfiguration', () => { }); }); - it('should display correctly for legacy db', async () => { - queryMocks.useRegionsQuery.mockReturnValue({ - data: regionFactory.buildList(1, { - country: 'us', - id: 'us-southeast', - label: 'Atlanta, GA, USA', - status: 'ok', - }), - }); - - queryMocks.useDatabaseTypesQuery.mockReturnValue({ - data: databaseTypeFactory.buildList(1, { - class: 'nanode', - disk: 25600, - id: 'g6-nanode-1', - label: 'DBaaS - Nanode 1GB', - memory: 1024, - vcpus: 1, - }), - }); - - const database = databaseFactory.build({ - cluster_size: 1, - engine: 'mysql', - platform: 'rdbms-legacy', - region: 'us-southeast', - replication_type: 'none', - status: 'provisioning', - total_disk_size_gb: 15, - type: 'g6-nanode-1', - used_disk_size_gb: 2, - version: '8.0.30', - }) as Database; - - const { queryAllByText } = renderWithTheme( - - ); - - expect(queryMocks.useDatabaseTypesQuery).toHaveBeenCalledWith({ - platform: 'rdbms-legacy', - }); - - await waitFor(() => { - expect(queryAllByText('Status')).toHaveLength(1); - expect(queryAllByText('Provisioning')).toHaveLength(1); - - expect(queryAllByText('Plan')).toHaveLength(1); - expect(queryAllByText('Nanode 1 GB')).toHaveLength(1); - - expect(queryAllByText('Nodes')).toHaveLength(1); - expect(queryAllByText('Primary (1 Node)')).toHaveLength(1); - - expect(queryAllByText('CPUs')).toHaveLength(1); - expect(queryAllByText(1)).toHaveLength(1); - - expect(queryAllByText('Engine')).toHaveLength(1); - expect(queryAllByText('MySQL v8.0.30')).toHaveLength(1); - - expect(queryAllByText('Region')).toHaveLength(1); - expect(queryAllByText('Atlanta, GA, USA')).toHaveLength(1); - - expect(queryAllByText('RAM')).toHaveLength(1); - expect(queryAllByText('1 GB')).toHaveLength(1); - - expect(queryAllByText('Total Disk Size')).toHaveLength(1); - expect(queryAllByText('15 GB')).toHaveLength(1); - }); - }); - it('should return null when there is no matching type', async () => { queryMocks.useDatabaseTypesQuery.mockReturnValue({ data: databaseTypeFactory.buildList(1, { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx deleted file mode 100644 index 554f51d0eb2..00000000000 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useRegionsQuery } from '@linode/queries'; -import { Box, TooltipIcon, Typography } from '@linode/ui'; -import { convertMegabytesTo, formatStorageUnits } from '@linode/utilities'; -import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; - -import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; -import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; -import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; -import { useInProgressEvents } from 'src/queries/events/events'; - -import type { Region } from '@linode/api-v4'; -import type { - Database, - DatabaseType, -} from '@linode/api-v4/lib/databases/types'; -import type { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles()((theme: Theme) => ({ - configs: { - fontSize: '0.875rem', - lineHeight: '22px', - }, - header: { - marginBottom: theme.spacing(2), - }, - label: { - font: theme.font.bold, - lineHeight: '22px', - width: theme.spacing(13), - }, - status: { - alignItems: 'center', - display: 'flex', - textTransform: 'capitalize', - }, -})); - -interface Props { - database: Database; -} - -/** - * Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 - * TODO (UIE-8214) remove POST GA - */ -export const DatabaseSummaryClusterConfigurationLegacy = (props: Props) => { - const { classes } = useStyles(); - const { database } = props; - - const { data: types } = useDatabaseTypesQuery({ - platform: database.platform, - }); - - const type = types?.find((type: DatabaseType) => type.id === database?.type); - - const { data: regions } = useRegionsQuery(); - - const region = regions?.find((r: Region) => r.id === database.region); - - const { data: events } = useInProgressEvents(); - - if (!database || !type) { - return null; - } - - const configuration = - database.cluster_size === 1 - ? 'Primary (1 Node)' - : database.cluster_size > 2 - ? `Primary (+${database.cluster_size - 1} Nodes)` - : `Primary (+${database.cluster_size - 1} Node)`; - - const sxTooltipIcon = { - marginLeft: '4px', - padding: '0px', - }; - - const STORAGE_COPY = - 'The total disk size is smaller than the selected plan capacity due to overhead from the OS.'; - - return ( - <> - - Cluster Configuration - -
    - - Status -
    - -
    -
    - - Version - - - - Nodes - {configuration} - - - Region - {region?.label ?? database.region} - - - Plan - {formatStorageUnits(type.label)} - - - RAM - {type.memory / 1024} GB - - - CPUs - {type.vcpus} - - {database.total_disk_size_gb ? ( - <> - - Total Disk Size - {database.total_disk_size_gb} GB - - - - Used - {database.used_disk_size_gb} GB - - - ) : ( - - Storage - {convertMegabytesTo(type.disk, true)} - - )} -
    - - ); -}; - -export default DatabaseSummaryClusterConfigurationLegacy; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx deleted file mode 100644 index af6add09add..00000000000 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; -import { - Box, - Button, - CircleProgress, - TooltipIcon, - Typography, -} from '@linode/ui'; -import { downloadFile } from '@linode/utilities'; -import { useTheme } from '@mui/material'; -import { useSnackbar } from 'notistack'; -import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; - -import DownloadIcon from 'src/assets/icons/lke-download.svg'; -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { DB_ROOT_USERNAME } from 'src/constants'; -import { useDatabaseCredentialsQuery } from 'src/queries/databases/databases'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; - -import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types'; -import type { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles()((theme: Theme) => ({ - actionBtnsCtn: { - display: 'flex', - justifyContent: 'flex-end', - marginTop: '10px', - padding: `${theme.spacing(1)} 0`, - }, - caCertBtn: { - '& svg': { - marginRight: theme.spacing(), - }, - '&:hover': { - backgroundColor: 'transparent', - opacity: 0.7, - }, - '&[disabled]': { - '& g': { - stroke: theme.tokens.color.Neutrals[30], - }, - '&:hover': { - backgroundColor: 'inherit', - textDecoration: 'none', - }, - // Override disabled background color defined for dark mode - backgroundColor: 'transparent', - color: theme.tokens.color.Neutrals[30], - cursor: 'default', - }, - color: theme.palette.primary.main, - font: theme.font.bold, - fontSize: '0.875rem', - lineHeight: '1.125rem', - marginLeft: theme.spacing(), - minHeight: 'auto', - minWidth: 'auto', - padding: 0, - }, - connectionDetailsCtn: { - '& p': { - lineHeight: '1.5rem', - }, - '& span': { - font: theme.font.bold, - }, - background: theme.bg.bgAccessRowTransparentGradient, - border: `1px solid ${ - theme.name === 'light' - ? theme.tokens.color.Neutrals[40] - : theme.tokens.color.Neutrals.Black - }`, - padding: '8px 15px', - }, - copyToolTip: { - '& svg': { - color: theme.palette.primary.main, - height: `16px !important`, - width: `16px !important`, - }, - marginRight: 12, - }, - error: { - color: theme.color.red, - marginLeft: theme.spacing(2), - }, - header: { - marginBottom: theme.spacing(2), - }, - inlineCopyToolTip: { - '& svg': { - height: `16px`, - width: `16px`, - }, - '&:hover': { - backgroundColor: 'transparent', - }, - display: 'inline-flex', - marginLeft: 4, - }, - progressCtn: { - '& circle': { - stroke: theme.palette.primary.main, - }, - alignSelf: 'flex-end', - marginBottom: 2, - marginLeft: 22, - }, - provisioningText: { - font: theme.font.normal, - fontStyle: 'italic', - }, - showBtn: { - color: theme.palette.primary.main, - fontSize: '0.875rem', - marginLeft: theme.spacing(), - minHeight: 'auto', - minWidth: 'auto', - padding: 0, - }, -})); - -interface Props { - database: Database; -} - -const sxTooltipIcon = { - marginLeft: '4px', - padding: '0px', -}; - -const privateHostCopy = - 'A private network host and a private IP can only be used to access a Database Cluster from Linodes in the same data center and will not incur transfer costs.'; - -/** - * Deprecated @since DBaaS V2 GA. Will be removed remove post GA release ~ Dec 2024 - * TODO (UIE-8214) remove POST GA - */ -export const DatabaseSummaryConnectionDetailsLegacy = (props: Props) => { - const { database } = props; - const { classes } = useStyles(); - const theme = useTheme(); - const { enqueueSnackbar } = useSnackbar(); - - const [showCredentials, setShowPassword] = React.useState(false); - const [isCACertDownloading, setIsCACertDownloading] = React.useState( - false - ); - - const { - data: credentials, - error: credentialsError, - isLoading: credentialsLoading, - refetch: getDatabaseCredentials, - } = useDatabaseCredentialsQuery(database.engine, database.id); - - const username = - database.platform === 'rdbms-default' - ? 'akmadmin' - : database.engine === 'postgresql' - ? 'linpostgres' - : DB_ROOT_USERNAME; - - const password = - showCredentials && credentials ? credentials?.password : '••••••••••'; - - const handleShowPasswordClick = () => { - setShowPassword((showCredentials) => !showCredentials); - }; - - React.useEffect(() => { - if (showCredentials && !credentials) { - getDatabaseCredentials(); - } - }, [credentials, getDatabaseCredentials, showCredentials]); - - const handleDownloadCACertificate = () => { - setIsCACertDownloading(true); - getSSLFields(database.engine, database.id) - .then((response: SSLFields) => { - // Convert to utf-8 from base64 - try { - const decodedFile = window.atob(response.ca_certificate); - downloadFile(`${database.label}-ca-certificate.crt`, decodedFile); - setIsCACertDownloading(false); - } catch (e) { - enqueueSnackbar('Error parsing your CA Certificate file', { - variant: 'error', - }); - setIsCACertDownloading(false); - return; - } - }) - .catch((errorResponse: any) => { - const error = getErrorStringOrDefault( - errorResponse, - 'Unable to download your CA Certificate' - ); - setIsCACertDownloading(false); - enqueueSnackbar(error, { variant: 'error' }); - }); - }; - - const disableShowBtn = ['failed', 'provisioning'].includes(database.status); - const disableDownloadCACertificateBtn = database.status === 'provisioning'; - const readOnlyHost = database?.hosts?.standby || database?.hosts?.secondary; - - const credentialsBtn = (handleClick: () => void, btnText: string) => { - return ( - - ); - }; - - const caCertificateJSX = ( - <> - - {disableDownloadCACertificateBtn && ( - - - - )} - - ); - - return ( - <> - - Connection Details - - - - username = {username} - - - - password = {password} - - {showCredentials && credentialsLoading ? ( -
    - -
    - ) : credentialsError ? ( - <> - - Error retrieving credentials. - - {credentialsBtn(() => getDatabaseCredentials(), 'Retry')} - - ) : ( - credentialsBtn( - handleShowPasswordClick, - showCredentials && credentials ? 'Hide' : 'Show' - ) - )} - {disableShowBtn && ( - - )} - {showCredentials && credentials && ( - - )} -
    - - - {database.hosts?.primary ? ( - <> - - host ={' '} - - {database.hosts?.primary} - {' '} - - - - ) : ( - - host ={' '} - - Your hostname will appear here once it is available. - - - )} - - - {readOnlyHost && ( - - - {database.platform === 'rdbms-default' ? ( - read-only host - ) : ( - private network host - )} - = {readOnlyHost} - - - {database.platform === 'rdbms-legacy' && ( - - )} - - )} - - port = {database.port} - - - ssl = {database.ssl_connection ? 'ENABLED' : 'DISABLED'} - -
    -
    - {database.ssl_connection ? caCertificateJSX : null} -
    - - ); -}; - -export default DatabaseSummaryConnectionDetailsLegacy; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx index af844c1e1c6..2f59cb0e70c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -1,10 +1,9 @@ -import { CircleProgress, ErrorState, Notice } from '@linode/ui'; +import { BetaChip, CircleProgress, ErrorState, Notice } from '@linode/ui'; import { useEditableLabelState } from '@linode/utilities'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { matchPath, useHistory, useParams } from 'react-router-dom'; -import { BetaChip } from 'src/components/BetaChip/BetaChip'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx index 439cca75acc..3084ac5aa7d 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useResumeDatabaseMutation } from 'src/queries/databases/databases'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -10,6 +12,7 @@ import { useIsDatabasesEnabled } from '../utilities'; import type { DatabaseStatus, Engine } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; +import type { ActionType } from 'src/features/Account/utils'; interface Props { databaseEngine: Engine; @@ -63,48 +66,73 @@ export const DatabaseActionMenu = (props: Props) => { } }; + const isDatabaseReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'database', + id: databaseId, + }); + + const getTooltipText = (action: ActionType) => { + return isDatabaseReadOnly + ? getRestrictedResourceText({ + action, + isSingular: true, + resourceType: 'Databases', + }) + : undefined; + }; + const actions: Action[] = [ { - disabled: isDatabaseNotRunning || isDatabaseSuspended, + disabled: + isDatabaseNotRunning || isDatabaseSuspended || isDatabaseReadOnly, onClick: handlers.handleManageAccessControls, title: 'Manage Access Controls', + tooltip: getTooltipText('edit'), }, { - disabled: isDatabaseNotRunning || isDatabaseSuspended, + disabled: + isDatabaseNotRunning || isDatabaseSuspended || isDatabaseReadOnly, onClick: handlers.handleResetPassword, title: 'Reset Root Password', + tooltip: getTooltipText('edit'), }, { - disabled: isDatabaseNotRunning || isDatabaseSuspended, + disabled: + isDatabaseNotRunning || isDatabaseSuspended || isDatabaseReadOnly, onClick: () => { history.push({ pathname: `/databases/${databaseEngine}/${databaseId}/resize`, }); }, title: 'Resize', + tooltip: getTooltipText('resize'), }, { - disabled: isDatabaseNotRunning, + disabled: isDatabaseNotRunning || isDatabaseReadOnly, onClick: handlers.handleDelete, title: 'Delete', + tooltip: getTooltipText('delete'), }, ]; if (isDatabasesV2GA) { actions.unshift({ - disabled: databaseStatus !== 'active', + disabled: databaseStatus !== 'active' || isDatabaseReadOnly, onClick: () => { handlers.handleSuspend(); }, title: 'Suspend', + tooltip: getTooltipText('suspend'), }); actions.splice(4, 0, { - disabled: !isDatabaseSuspended, + disabled: !isDatabaseSuspended || isDatabaseReadOnly, onClick: () => { handleResume(); }, title: 'Resume', + tooltip: getTooltipText('resume'), }); } diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index 1cad7b863d9..a41f22de581 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -94,7 +94,12 @@ const DatabaseLandingTable = ({ return ( <> - +
    { const theme = useTheme(); - const { isDatabasesV2GA } = useIsDatabasesEnabled(); return ( { sx={sx ? sx : { margin: '20px' }} > - {!isDatabasesV2GA && ( - - )} diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index 2f251e5e373..0834e92f814 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -3,12 +3,18 @@ import { DateTime } from 'luxon'; import { accountFactory, + databaseEngineConfigFactory, databaseFactory, databaseTypeFactory, } from 'src/factories'; import { + convertEngineConfigToOptions, + convertExistingConfigsToArray, + findConfigItem, + formatConfigPayload, formatConfigValue, getDatabasesDescription, + getDefaultConfigValue, hasPendingUpdates, isDateOutsideBackup, isDefaultDatabase, @@ -22,9 +28,12 @@ import { import { HttpResponse, http, server } from 'src/mocks/testServer'; import { wrapWithTheme } from 'src/utilities/testHelpers'; +import type { ConfigurationOption } from './DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect'; import type { AccountCapability, Database, + DatabaseEngineConfig, + DatabaseInstanceAdvancedConfig, Engine, PendingUpdates, } from '@linode/api-v4'; @@ -397,10 +406,10 @@ describe('toFormatedDate', () => { const today = DateTime.utc(); const mockTodayWithHours = DateTime.fromObject({ day: today.day, - month: today.month, - year: today.year, hour: today.hour, minute: 0, + month: today.month, + year: today.year, }).toFormat('yyyy-MM-dd HH:mm'); const result = toFormatedDate(selectedDate, undefined); expect(result).toContain(mockTodayWithHours); @@ -586,3 +595,219 @@ describe('formatConfigValue', () => { expect(result).toBe('+03:00'); }); }); + +describe('findConfigItem', () => { + const mockConfigs: DatabaseEngineConfig = databaseEngineConfigFactory.build(); + const expectedConfig = { + description: + 'The minimum amount of time in seconds to keep binlog entries before deletion. This may be extended for services that require binlog entries for longer than the default for example if using the MySQL Debezium Kafka connector.', + example: 600, + maximum: 86400, + minimum: 600, + restart_cluster: false, + type: 'integer', + }; + + const expectedNestedConfig = { + description: + 'The number of seconds that the mysqld server waits for a connect packet before responding with Bad handshake', + example: 10, + maximum: 3600, + minimum: 2, + restart_cluster: false, + type: 'integer', + }; + it('should return the correct ConfigurationItem for a given targetKey', () => { + const result = findConfigItem(mockConfigs, 'binlog_retention_period'); + expect(result).toEqual(expectedConfig); + }); + + it('should return the correct ConfigurationItem for a nested key', () => { + const result = findConfigItem(mockConfigs, 'connect_timeout'); + expect(result).toEqual(expectedNestedConfig); + }); + + it('should return undefined if the targetKey does not exist', () => { + const result = findConfigItem(mockConfigs, 'non_existing_key'); + expect(result).toBeUndefined(); + }); +}); + +describe('convertExistingConfigsToArray', () => { + const mockConfigs: DatabaseEngineConfig = databaseEngineConfigFactory.build(); + + const existingConfigs: DatabaseInstanceAdvancedConfig = { + advanced: { + connect_timeout: 10, + default_time_zone: '+03:00', + }, + binlog_retention_period: 600, + }; + + const expectedOptions: ConfigurationOption[] = [ + { + category: '', + description: + 'The number of seconds that the mysqld server waits for a connect packet before responding with Bad handshake', + example: 10, + label: 'connect_timeout', + maximum: 3600, + minimum: 2, + restart_cluster: false, + type: 'integer', + value: 10, + }, + { + category: '', + description: + "Default server time zone as an offset from UTC (from -12:00 to +12:00), a time zone name, or 'SYSTEM' to use the MySQL server default.", + example: '+03:00', + label: 'default_time_zone', + maxLength: 100, + minLength: 2, + pattern: '^([-+][\\d:]*|[\\w/]*)$', + restart_cluster: false, + type: 'string', + value: '+03:00', + }, + { + category: '', + description: + 'The minimum amount of time in seconds to keep binlog entries before deletion. This may be extended for services that require binlog entries for longer than the default for example if using the MySQL Debezium Kafka connector.', + example: 600, + label: 'binlog_retention_period', + maximum: 86400, + minimum: 600, + restart_cluster: false, + type: 'integer', + value: 600, + }, + ]; + + it('should convert configs to array of ConfigurationOptions with label and current value', () => { + const result = convertExistingConfigsToArray(existingConfigs, mockConfigs); + expect(result).toEqual(expectedOptions); + }); +}); + +describe('convertEngineConfigToOptions', () => { + it('should correctly convert a flat configuration', () => { + const configs = { + binlog_retention_period: { type: 'integer' }, + service_log: { type: ['boolean', 'null'] }, + }; + const expectedConfigOptions = [ + { + category: 'other', + enum: [], + label: 'binlog_retention_period', + type: 'integer', + }, + { + category: 'other', + enum: [], + label: 'service_log', + type: ['boolean', 'null'], + }, + ]; + expect(convertEngineConfigToOptions(configs)).toEqual( + expectedConfigOptions + ); + }); + + it('should correctly convert a nested configuration', () => { + const configs = { + mysql: { + connect_timeout: { type: 'integer' }, + default_time_zone: { type: 'string' }, + }, + }; + const expectedConfigOptions = [ + { + category: 'mysql', + enum: [], + label: 'connect_timeout', + type: 'integer', + }, + { + category: 'mysql', + enum: [], + label: 'default_time_zone', + type: 'string', + }, + ]; + expect(convertEngineConfigToOptions(configs)).toEqual( + expectedConfigOptions + ); + }); +}); + +describe('formatConfigPayload', () => { + it('should correctly format a flat configuration', () => { + const formData = [ + { category: 'other', label: 'binlog_retention_period', value: 600 }, + ]; + const configurations = [ + { category: 'other', label: 'binlog_retention_period' }, + ]; + expect(formatConfigPayload(formData, configurations)).toEqual({ + binlog_retention_period: 600, + }); + }); + + it('should correctly format a nested configuration', () => { + const formData = [ + { category: '', label: 'connect_timeout', value: 10 }, + { category: 'mysql', label: 'default_time_zone', value: '+03:00' }, + ]; + const configurations = [ + { category: 'mysql', label: 'connect_timeout' }, + { category: 'mysql', label: 'default_time_zone' }, + ]; + expect(formatConfigPayload(formData, configurations)).toEqual({ + mysql: { + connect_timeout: 10, + default_time_zone: '+03:00', + }, + }); + }); +}); + +describe('getDefaultConfigValue', () => { + it('should return false for boolean type', () => { + const config: ConfigurationOption = { + category: '', + label: '', + type: 'boolean', + }; + expect(getDefaultConfigValue(config)).toBe(false); + }); + + it('should return first enum value for string with enum', () => { + const config: ConfigurationOption = { + category: '', + enum: ['option1', 'option2'], + label: '', + type: 'string', + }; + expect(getDefaultConfigValue(config)).toBe('option1'); + }); + + it('should return 0 for number type', () => { + const config: ConfigurationOption = { + category: '', + label: '', + type: 'number', + }; + expect(getDefaultConfigValue(config)).toBe(0); + }); + + it('should return 0 for integer type', () => { + const config: ConfigurationOption = { + category: '', + label: '', + type: 'integer', + }; + expect(getDefaultConfigValue(config)).toBe(0); + }); +}); diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 1d4dda6805b..0ef3190b4ba 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -5,10 +5,15 @@ import { DateTime } from 'luxon'; import { useFlags } from 'src/hooks/useFlags'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; +import type { ConfigurationOption } from './DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect'; import type { + ConfigCategoryValues, + ConfigurationItem, DatabaseEngine, DatabaseFork, + DatabaseEngineConfig, DatabaseInstance, + DatabaseInstanceAdvancedConfig, Engine, PendingUpdates, } from '@linode/api-v4'; @@ -264,3 +269,193 @@ export const formatConfigValue = (configValue: string) => : configValue === 'undefined' ? ' - ' : configValue; + +/** + * Converts a nested database engine configuration into a flat array of configuration options. + * + * @param allConfigs + * @returns An array of structured configuration options. + */ +export const convertEngineConfigToOptions = ( + allConfigs: DatabaseEngineConfig | undefined +) => { + const options: ConfigurationOption[] = []; + + const processConfig = ( + config: Record< + string, + ConfigurationItem | Record + >, + parentCategory: string = '' + ) => { + for (const key in config) { + const value = config[key] as ConfigurationItem; + if (typeof value === 'object') { + // If it has "type" property, add option to the list + if ('type' in value) { + // If parentCategory is empty, use 'Other' as the category + const category = parentCategory || 'other'; + options.push({ + ...value, + category: category, + enum: value.enum ?? [], + label: key, + type: value.type, + }); + } + // Else, it's a nested category, so recurse + else { + processConfig(value as Record, key); + } + } + } + }; + + if (allConfigs !== undefined) { + processConfig(allConfigs); + } + + return options; +}; + +/** + * Recursively searches for a configuration item by its key within a nested configuration object. + * + * @param configObject + * @param targetKey + * @returns The found configuration option or `undefined` if not found. + */ +export const findConfigItem = ( + configs: + | Record | ConfigurationItem> + | undefined, + targetKey: string +): ConfigurationOption | undefined => { + for (const key in configs) { + const value = configs[key]; + + if (key === targetKey) { + return value as ConfigurationOption; + } + + if (typeof value === 'object' && value !== null) { + const found = findConfigItem( + value as Record, + targetKey + ); + if (found) return found; + } + } + + return undefined; +}; + +/** + * Converts existing database configurations into an array of configuration options. + * + * @param configs + * @param allConfigs + * @returns An array of structured configuration options with metadata from `allConfigs`. + */ +export const convertExistingConfigsToArray = ( + configs: DatabaseInstanceAdvancedConfig, + allConfigs: DatabaseEngineConfig | undefined +): ConfigurationOption[] => { + const options: ConfigurationOption[] = []; + + for (const key in configs) { + const value = configs[key]; + + if (typeof value === 'object' && value !== null) { + for (const subKey in value) { + const subValue = value[subKey]; + + const foundConfig = findConfigItem(allConfigs, subKey); + if (foundConfig) { + options.push({ + ...foundConfig, + category: '', + label: subKey, + value: subValue, + }); + } + } + } else { + const foundConfig = findConfigItem(allConfigs, key); + if (foundConfig) { + options.push({ + ...foundConfig, + category: '', + label: key, + value: value, + }); + } + } + } + return options; +}; + +/** + * Formats the configuration payload by organizing form data into categorized fields. + * + * @param formData + * @param configurations + * @returns A structured object where configurations are grouped by category. + */ +export const formatConfigPayload = ( + formData: ConfigurationOption[], + configurations: ConfigurationOption[] +) => { + const formattedConfigData: DatabaseInstanceAdvancedConfig = {}; + + configurations.forEach(({ category, label }) => { + // Find the matching config from the formData + const formConfig = formData.find((config) => config.label === label); + + if (formConfig && formConfig.value !== undefined) { + if (category === 'other') { + formattedConfigData[label] = formConfig.value; + } else { + if (!formattedConfigData[category]) { + formattedConfigData[category] = {} as ConfigCategoryValues; + } + (formattedConfigData[category] as ConfigCategoryValues)[label] = + formConfig.value; + } + } + }); + + return formattedConfigData; +}; + +export const isConfigBoolean = (config: ConfigurationOption) => { + return ( + config?.type === 'boolean' || + (Array.isArray(config?.type) && config?.type.includes('boolean')) + ); +}; + +export const isConfigStringWithEnum = (config: ConfigurationOption) => { + return ( + (config?.type === 'string' && config.enum) || + (Array.isArray(config?.type) && + config?.type.includes('string') && + config.enum) + ); +}; + +/** + * Determines the default value for a configuration item based on its type. + * + * @param config - The configuration object + * @returns - The default value for the given configuration + */ +export const getDefaultConfigValue = (config: ConfigurationOption) => { + return isConfigBoolean(config) + ? false + : isConfigStringWithEnum(config) + ? config.enum?.[0] ?? '' + : config?.type === 'number' || config?.type === 'integer' + ? 0 + : ''; +}; diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index 8e5bb821446..1245e43d2cd 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -1,4 +1,5 @@ import { useGrants, useProfile } from '@linode/queries'; +import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, Autocomplete, @@ -22,7 +23,6 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; import { reportException } from 'src/exceptionReporting'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; import { useCreateDomainMutation } from 'src/queries/domains'; import { sendCreateDomainEvent } from 'src/utilities/analytics/customEventAnalytics'; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.tsx index 42b2c0e6aff..2370882a7f8 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.tsx @@ -1,6 +1,6 @@ import produce from 'immer'; -import { maybeCastToNumber } from 'src/utilities/maybeCastToNumber'; +import { maybeCastToNumber } from '@linode/utilities'; import { getInitialIPs } from '../../domainUtils'; diff --git a/packages/manager/src/features/Domains/EditDomainDrawer.test.tsx b/packages/manager/src/features/Domains/EditDomainDrawer.test.tsx index 5d5ed3a886e..53072f13f88 100644 --- a/packages/manager/src/features/Domains/EditDomainDrawer.test.tsx +++ b/packages/manager/src/features/Domains/EditDomainDrawer.test.tsx @@ -1,4 +1,4 @@ -import { linodeFactory } from 'src/factories/linodes'; +import { linodeFactory } from '@linode/utilities'; import { generateDefaultDomainRecords } from './domainUtils'; diff --git a/packages/manager/src/features/Events/utils.tsx b/packages/manager/src/features/Events/utils.tsx index e3390b1fc42..7a35a17048f 100644 --- a/packages/manager/src/features/Events/utils.tsx +++ b/packages/manager/src/features/Events/utils.tsx @@ -1,9 +1,9 @@ +import { formatDuration } from '@linode/utilities'; 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 { parseAPIDate } from 'src/utilities/date'; -import { formatDuration } from 'src/utilities/formatDuration'; import { ACTIONS_WITHOUT_USERNAMES } from './constants'; import { eventMessages } from './factory'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index c2059a723cd..e7b9c51e249 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -4,6 +4,7 @@ import { useGrants, useProfile, } from '@linode/queries'; +import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, Drawer, Notice } from '@linode/ui'; import { useTheme } from '@mui/material'; import { useParams } from '@tanstack/react-router'; @@ -13,7 +14,6 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { NotFound } from 'src/components/NotFound'; import { SupportLink } from 'src/components/SupportLink'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { 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/FirewallDeviceRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx new file mode 100644 index 00000000000..4ed709c6a99 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx @@ -0,0 +1,100 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { firewallDeviceFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { FirewallDeviceRow } from './FirewallDeviceRow'; + +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + +const props = { + device: firewallDeviceFactory.build(), + disabled: false, + handleRemoveDevice: vi.fn(), + isLinodeRelatedDevice: true, +}; + +const INTERFACE_TEXT = 'Configuration Profile Interface'; + +describe('FirewallDeviceRow', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('shows the network interface type if the linodeInterfaces feature flag is enabled for Linode related devices', () => { + const { getAllByRole, getByText } = renderWithTheme( + , + { flags: { linodeInterfaces: { enabled: true } } } + ); + + expect(getByText('entity')).toBeVisible(); + expect(getByText(INTERFACE_TEXT)).toBeVisible(); + expect(getAllByRole('cell')).toHaveLength(3); + expect(getByText('Remove')).toBeVisible(); + }); + + it('does not show the network interface type if the linodeInterfaces feature flag is not enabled for Linode related devices', () => { + const { getAllByRole, getByText, queryByText } = renderWithTheme( + , + { + flags: { linodeInterfaces: { enabled: false } }, + } + ); + + expect(getByText('entity')).toBeVisible(); + expect(queryByText(INTERFACE_TEXT)).not.toBeInTheDocument(); + expect(getAllByRole('cell')).toHaveLength(2); + expect(getByText('Remove')).toBeVisible(); + }); + + it('does not show the network interface type for nodebalancer devices', () => { + const nodeBalancerEntity = firewallDeviceFactory.build({ + entity: { + id: 10, + label: 'entity', + type: 'nodebalancer' as FirewallDeviceEntityType, + url: '/linodes/1', + }, + }); + + const { + getAllByRole, + getByText, + queryByText, + } = renderWithTheme( + , + { flags: { linodeInterfaces: { enabled: true } } } + ); + + expect(getByText('entity')).toBeVisible(); + expect(queryByText(INTERFACE_TEXT)).not.toBeInTheDocument(); + expect(getAllByRole('cell')).toHaveLength(2); + expect(getByText('Remove')).toBeVisible(); + }); + + it('can remove a device with an enabled Remove button', async () => { + const { getByText } = renderWithTheme(, { + flags: { linodeInterfaces: { enabled: true } }, + }); + + const removeButton = getByText('Remove'); + await userEvent.click(removeButton); + expect(props.handleRemoveDevice).toHaveBeenCalledTimes(1); + }); + + it('cannot remove a device with a disabled Remove button', async () => { + const { getByText } = renderWithTheme( + , + { flags: { linodeInterfaces: { enabled: true } } } + ); + + const removeButton = getByText('Remove'); + await userEvent.click(removeButton); + expect(props.handleRemoveDevice).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx index 91d47cbbb80..b0b4014a76d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx @@ -1,29 +1,57 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; +import { Skeleton } from 'src/components/Skeleton'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { FirewallDeviceActionMenu } from './FirewallDeviceActionMenu'; import type { FirewallDeviceActionMenuProps } from './FirewallDeviceActionMenu'; -export const FirewallDeviceRow = React.memo( - (props: FirewallDeviceActionMenuProps) => { - const { device } = props; - const { id, label, type } = device.entity; +interface FirewallDeviceRowProps extends FirewallDeviceActionMenuProps { + isLinodeRelatedDevice: boolean; +} - return ( - - - +export const FirewallDeviceRow = React.memo((props: FirewallDeviceRowProps) => { + const { device, isLinodeRelatedDevice } = props; + const { id, label, type, url } = device.entity; + + const isInterfaceDevice = type === 'interface'; + // for Linode Interfaces, the url comes in as '/v4/linode/instances/:linodeId/interfaces/:interfaceId + // we need the Linode ID to create a link + const entityId = isInterfaceDevice ? Number(url.split('/')[4]) : id; + + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + + const link = isInterfaceDevice + ? `/linodes/${entityId}/networking/interfaces/${id}` + : `/${type}s/${id}/${type === 'linode' ? 'networking' : 'summary'}`; + + return ( + + + {/* The only time a firewall device's label comes in as null is for Linode Interface devices. This label won't stay null - we do some + processing to give the interface device its associated Linode's label. However, processing may take time, so we show a loading indicator first */} + {isInterfaceDevice && !label ? ( + + ) : ( + {label} + )} + + {isLinodeInterfacesEnabled && isLinodeRelatedDevice && ( + + {isInterfaceDevice + ? `Linode Interface (ID: ${id})` + : 'Configuration Profile Interface'} - - - - - ); - } -); + )} + + + + + ); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx index 90f784bc354..6f249c535c3 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx @@ -1,9 +1,13 @@ -import { useAllFirewallDevicesQuery } from '@linode/queries'; +import { + useAllFirewallDevicesQuery, + useAllLinodesQuery, +} from '@linode/queries'; import * as React from 'react'; 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 { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; @@ -11,7 +15,9 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; +import { getLinodeIdFromInterfaceDevice } from '../../shared'; import { formattedTypes } from './constants'; import { FirewallDeviceRow } from './FirewallDeviceRow'; @@ -35,11 +41,52 @@ export const FirewallDeviceTable = React.memo( type, } = props; + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + const { data: allDevices, error, isLoading } = useAllFirewallDevicesQuery( firewallId ); const devices = - allDevices?.filter((device) => device.entity.type === type) || []; + allDevices?.filter((device) => + type === 'linode' && isLinodeInterfacesEnabled + ? device.entity.type !== 'nodebalancer' // include entities with type 'interface' in Linode table + : device.entity.type === type + ) || []; + + const linodeInterfaceDevices = + type === 'linode' + ? allDevices?.filter((device) => device.entity.type === 'interface') + : []; + + // only fire this query if we have linode interface devices. We fetch the Linodes those devices are attached to + // so that we can add a label to the devices for sorting and display purposes + const { data: linodesWithInterfaces } = useAllLinodesQuery( + {}, + {}, + isLinodeInterfacesEnabled && + linodeInterfaceDevices && + linodeInterfaceDevices.length > 0 + ); + + const updatedDevices = devices.map((device) => { + if (device.entity.type === 'interface') { + const linodeId = getLinodeIdFromInterfaceDevice(device.entity); + const associatedLinode = linodesWithInterfaces?.find( + (linode) => linode.id === linodeId + ); + return { + ...device, + entity: { + ...device.entity, + label: associatedLinode?.label ?? null, + }, + }; + } else { + return device; + } + }); + + const isLinodeRelatedDevice = type === 'linode'; const _error = error ? getAPIErrorOrDefault( @@ -56,7 +103,7 @@ export const FirewallDeviceTable = React.memo( orderBy, sortedData: sortedDevices, } = useOrderV2({ - data: devices, + data: updatedDevices, initialRoute: { defaultOrder: { order: 'asc', @@ -85,14 +132,18 @@ export const FirewallDeviceTable = React.memo( {formattedTypes[deviceType]} + {isLinodeInterfacesEnabled && isLinodeRelatedDevice && ( + Network Interface + )} + @@ -106,6 +157,7 @@ export const FirewallDeviceTable = React.memo( device={thisDevice} disabled={disabled} handleRemoveDevice={handleRemoveDevice} + isLinodeRelatedDevice={isLinodeRelatedDevice} key={`device-row-${thisDevice.id}`} /> ))} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index f598a00ea5d..65beddd7d70 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -1,14 +1,16 @@ +import { + linodeQueries, + nodebalancerQueries, + useRemoveFirewallDeviceMutation, +} from '@linode/queries'; import { ActionsPanel, Typography } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { - useRemoveFirewallDeviceMutation, - linodeQueries, - nodebalancerQueries, -} from '@linode/queries'; + +import { formattedTypes } from './constants'; import type { FirewallDevice } from '@linode/api-v4'; @@ -16,17 +18,31 @@ export interface Props { device: FirewallDevice | undefined; firewallId: number; firewallLabel: string; + isFetching?: boolean; onClose: () => void; onService: boolean | undefined; open: boolean; } export const RemoveDeviceDialog = React.memo((props: Props) => { - const { device, firewallId, firewallLabel, onClose, onService, open } = props; + const { + device, + firewallId, + firewallLabel, + isFetching, + onClose, + onService, + open, + } = props; const { enqueueSnackbar } = useSnackbar(); const deviceType = device?.entity.type; + const entityLabelToUse = + deviceType === 'interface' + ? `(ID: ${device?.entity.id})` + : device?.entity.label; + const { error, isPending, mutateAsync } = useRemoveFirewallDeviceMutation( firewallId, device?.id ?? -1 @@ -34,7 +50,7 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { const queryClient = useQueryClient(); - const deviceDialog = deviceType === 'linode' ? 'Linode' : 'NodeBalancer'; + const deviceDialog = formattedTypes[deviceType ?? 'linode']; const onDelete = async () => { if (!device) { @@ -45,7 +61,7 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { const toastMessage = onService ? `Firewall ${firewallLabel} successfully unassigned` - : `${deviceDialog} ${device.entity.label} successfully removed`; + : `${deviceDialog} ${entityLabelToUse} successfully removed`; enqueueSnackbar(toastMessage, { variant: 'success', @@ -75,14 +91,14 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { const dialogTitle = onService ? `Unassign Firewall ${firewallLabel}?` - : `Remove ${deviceDialog} ${device?.entity.label}?`; + : `Remove ${deviceDialog} ${entityLabelToUse}?`; const confirmationText = ( Are you sure you want to{' '} {onService ? `unassign Firewall ${firewallLabel} from ${deviceDialog} ${device?.entity.label}?` - : `remove ${deviceDialog} ${device?.entity.label} from Firewall ${firewallLabel}?`} + : `remove ${deviceDialog} ${entityLabelToUse} from Firewall ${firewallLabel}?`} ); @@ -105,6 +121,7 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { /> } error={error?.[0]?.reason} + isFetching={isFetching} onClose={onClose} open={open} title={dialogTitle} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts index 9234255a29c..87178d2420a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts @@ -1,7 +1,7 @@ import type { FirewallDeviceEntityType } from '@linode/api-v4'; export const formattedTypes: Record = { - interface: 'Interface', // @TODO Linode Interface: double check this when working on UI tickets + interface: 'Linode Interface', linode: 'Linode', nodebalancer: 'NodeBalancer', }; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index 55ef3363546..5344eaaf0c4 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -1,11 +1,13 @@ import { useAllFirewallDevicesQuery, useFirewallQuery, + useFirewallSettingsQuery, useGrants, useMutateFirewall, useProfile, } from '@linode/queries'; -import { CircleProgress, ErrorState } from '@linode/ui'; +import { Chip, CircleProgress, ErrorState, Paper } from '@linode/ui'; +import { Typography } from '@mui/material'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; @@ -15,6 +17,7 @@ import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/Ge import { LandingHeader } from 'src/components/LandingHeader'; import { LinkButton } from 'src/components/LinkButton'; import { NotFound } from 'src/components/NotFound'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; @@ -23,8 +26,16 @@ import { useFlags } from 'src/hooks/useFlags'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { useTabs } from 'src/hooks/useTabs'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; -import { checkIfUserCanModifyFirewall } from '../shared'; +import { + FIREWALL_DEFAULT_ENTITY_TO_READABLE_NAME, + getFirewallDefaultEntities, +} from '../components/FirewallSelectOption.utils'; +import { + checkIfUserCanModifyFirewall, + getLinodeIdFromInterfaceDevice, +} from '../shared'; const FirewallRulesLanding = React.lazy(() => import('./Rules/FirewallRulesLanding').then((module) => ({ @@ -48,11 +59,21 @@ export const FirewallDetail = () => { const flags = useFlags(); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = React.useState(false); + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + const secureVMFirewallBanner = (secureVMNoticesEnabled && flags.secureVmCopy) ?? false; const firewallId = Number(id); + const { data: firewallSettings } = useFirewallSettingsQuery({ + enabled: isLinodeInterfacesEnabled, + }); + + const defaultEntities = + firewallSettings && + getFirewallDefaultEntities(firewallId, firewallSettings); + const userCanModifyFirewall = checkIfUserCanModifyFirewall( firewallId, profile, @@ -67,11 +88,27 @@ export const FirewallDetail = () => { acc.linodeCount += 1; } else if (device.entity.type === 'nodebalancer') { acc.nodebalancerCount += 1; + } else if ( + isLinodeInterfacesEnabled && + device.entity.type === 'interface' + ) { + const linodeId = getLinodeIdFromInterfaceDevice(device.entity); + if (!acc.seenLinodeIdsForInterfaces.has(linodeId)) { + acc.linodeCount += 1; + } + acc.seenLinodeIdsForInterfaces.add(linodeId); } return acc; }, - { linodeCount: 0, nodebalancerCount: 0 } - ) || { linodeCount: 0, nodebalancerCount: 0 }; + { + linodeCount: 0, + nodebalancerCount: 0, + seenLinodeIdsForInterfaces: new Set(), + } + ) || { + linodeCount: 0, + nodebalancerCount: 0, + }; const { handleTabChange, tabIndex, tabs } = useTabs([ { @@ -153,33 +190,65 @@ export const FirewallDetail = () => { {...secureVMFirewallBanner.firewallDetails} /> )} - + {isLinodeInterfacesEnabled && + defaultEntities && + defaultEntities.length > 0 && ( + ({ + alignItems: 'center', + columnGap: 1, + display: 'flex', + flexWrap: 'wrap', + margin: `${theme.spacingFunction(8)} 0`, + padding: `${theme.spacingFunction(8)} ${theme.spacingFunction( + 16 + )}`, + rowGap: 1, + })} + > + ({ marginRight: theme.spacingFunction(8) })} + > + Default + + {defaultEntities.map((defaultEntity) => ( + + ))} + + )} + - - - - - - - - - - - + }> + + + + + + + + + + + + setIsGenerateDialogOpen(false)} diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx index 6101ab30f25..0faa65ba248 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx @@ -1,4 +1,5 @@ import { useAllFirewallsQuery, useGrants } from '@linode/queries'; +import { LinodeSelect } from '@linode/shared'; import { Box, FormControlLabel, @@ -12,7 +13,6 @@ import { Controller, useFormContext } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; @@ -141,11 +141,15 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { value={field.value} > } + control={} label="Accept" value="ACCEPT" /> - } label="Drop" value="DROP" /> + } + label="Drop" + value="DROP" + /> )} control={control} @@ -164,11 +168,15 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { value={field.value} > } + control={} label="Accept" value="ACCEPT" /> - } label="Drop" value="DROP" /> + } + label="Drop" + value="DROP" + /> )} control={control} @@ -206,6 +214,7 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { onSelectionChange={(linodes) => { field.onChange(linodes.map((linode) => linode.id)); }} + disabled={_isRestrictedUser} errorText={fieldState.error?.message} helperText={deviceSelectGuidance} multiple @@ -229,6 +238,7 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { nodebalancers.map((nodebalancer) => nodebalancer.id) ); }} + disabled={_isRestrictedUser} errorText={fieldState.error?.message} helperText={deviceSelectGuidance} multiple diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx index cce9abb8f58..9d16b6f055e 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx @@ -1,13 +1,21 @@ -import { FirewallStatus } from '@linode/api-v4/lib/firewalls'; -import { Theme, useTheme } from '@mui/material/styles'; +import { useGrants, useProfile } from '@linode/queries'; +import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useGrants, useProfile } from '@linode/queries'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { checkIfUserCanModifyFirewall } from '../shared'; +import { + DEFAULT_FIREWALL_TOOLTIP_TEXT, + NO_PERMISSIONS_TOOLTIP_TEXT, +} from './constants'; + +import type { FirewallStatus } from '@linode/api-v4/lib/firewalls'; +import type { Theme } from '@mui/material/styles'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; export interface ActionHandlers { [index: string]: any; @@ -20,21 +28,21 @@ interface Props extends ActionHandlers { firewallID: number; firewallLabel: string; firewallStatus: FirewallStatus; + isDefaultFirewall: boolean; } -export const noPermissionTooltipText = - "You don't have permissions to modify this Firewall."; - export const FirewallActionMenu = React.memo((props: Props) => { const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); const { data: profile } = useProfile(); const { data: grants } = useGrants(); + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); const { firewallID, firewallLabel, firewallStatus, + isDefaultFirewall, triggerDeleteFirewall, triggerDisableFirewall, triggerEnableFirewall, @@ -46,12 +54,15 @@ export const FirewallActionMenu = React.memo((props: Props) => { grants ); - const disabledProps = !userCanModifyFirewall - ? { - disabled: true, - tooltip: noPermissionTooltipText, - } - : {}; + const disabledProps = + !userCanModifyFirewall || (isLinodeInterfacesEnabled && isDefaultFirewall) + ? { + disabled: true, + tooltip: isDefaultFirewall + ? DEFAULT_FIREWALL_TOOLTIP_TEXT + : NO_PERMISSIONS_TOOLTIP_TEXT, + } + : {}; const actions: Action[] = [ { diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 4a8842f12c2..9609bb1e2ff 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -1,3 +1,4 @@ +import { useFirewallsQuery } from '@linode/queries'; import { Button, CircleProgress, ErrorState } from '@linode/ui'; import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -19,7 +20,6 @@ import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; -import { useFirewallsQuery } from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { CreateFirewallDrawer } from './CreateFirewallDrawer'; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx index edd7962269f..5ec4a411e72 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx @@ -20,6 +20,18 @@ import { getRuleString, } from './FirewallRow'; +const queryMocks = vi.hoisted(() => ({ + useFirewallSettingsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useFirewallSettingsQuery: queryMocks.useFirewallSettingsQuery, + }; +}); + beforeAll(() => mockMatchMedia()); describe('FirewallRow', () => { @@ -54,6 +66,27 @@ describe('FirewallRow', () => { triggerEnableFirewall: mockTriggerEnableFirewall, }; + it('renders a TableRow with the default firewall chip, status, rules, and Linodes', () => { + queryMocks.useFirewallSettingsQuery.mockReturnValue({ + data: { + default_firewall_ids: { + linode: null, + nodebalancer: null, + public_interface: 1, + vpc_interface: null, + }, + }, + }); + const { getByTestId, getByText } = render( + wrapWithTableBody(, { + flags: { linodeInterfaces: { enabled: true } }, + }) + ); + getByTestId('firewall-row-1'); + getByText(firewall.label); + getByText('DEFAULT'); + }); + it('renders a TableRow with label, status, rules, and Linodes', () => { const { getByTestId, getByText } = render( wrapWithTableBody() @@ -68,21 +101,33 @@ describe('FirewallRow', () => { describe('getDeviceLinks', () => { it('should return a single Link if one Device is attached', () => { const device = firewallDeviceFactory.build(); - const links = getDeviceLinks([device.entity]); + const links = getDeviceLinks({ + entities: [device.entity], + isLoading: false, + linodesWithInterfaceDevices: undefined, + }); const { getByText } = renderWithTheme(links); - expect(getByText(device.entity.label)); + expect(getByText(device.entity.label ?? '')); }); it('should render up to three comma-separated links', () => { const devices = firewallDeviceFactory.buildList(3); - const links = getDeviceLinks(devices.map((device) => device.entity)); + const links = getDeviceLinks({ + entities: devices.map((device) => device.entity), + isLoading: false, + linodesWithInterfaceDevices: undefined, + }); 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.map((device) => device.entity)); + const links = getDeviceLinks({ + entities: devices.map((device) => device.entity), + isLoading: false, + linodesWithInterfaceDevices: undefined, + }); 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 f5b2c02701c..3d08b8316f1 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx @@ -1,22 +1,60 @@ +import { useAllLinodesQuery } from '@linode/queries'; import { capitalize } from '@linode/utilities'; import React from 'react'; import { Hidden } from 'src/components/Hidden'; import { Link } from 'src/components/Link'; +import { Skeleton } from 'src/components/Skeleton'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { useDefaultFirewallChipInformation } from 'src/hooks/useDefaultFirewallChipInformation'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; +import { DefaultFirewallChip } from '../components/DefaultFirewallChip'; +import { getLinodeIdFromInterfaceDevice } from '../shared'; import { FirewallActionMenu } from './FirewallActionMenu'; import type { ActionHandlers } from './FirewallActionMenu'; -import type { Firewall, FirewallDeviceEntity } from '@linode/api-v4'; +import type { + Filter, + Firewall, + FirewallDeviceEntity, + Linode, +} from '@linode/api-v4'; export interface FirewallRowProps extends Firewall, ActionHandlers {} export const FirewallRow = React.memo((props: FirewallRowProps) => { const { entities, id, label, rules, status, ...actionHandlers } = props; + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + + const { + defaultNumEntities, + isDefault, + tooltipText, + } = useDefaultFirewallChipInformation(id); + + const neededLinodeIdsForInterfaceDevices = entities + .slice(0, 3) // only take the first three entities since we only show those entity links + .filter((entity) => entity.type === 'interface') + .map((entity) => { + return { id: getLinodeIdFromInterfaceDevice(entity) }; + }); + + const filterForInterfaceDeviceLinodes: Filter = { + ['+or']: neededLinodeIdsForInterfaceDevices, + }; + + // only fire this query if we have linode interface devices. We fetch the Linodes those devices are attached to + // so that we can add a label to the devices for sorting and display purposes + const { data: linodesWithInterfaceDevices, isLoading } = useAllLinodesQuery( + {}, + filterForInterfaceDeviceLinodes, + isLinodeInterfacesEnabled && neededLinodeIdsForInterfaceDevices.length > 0 + ); + const count = getCountOfRules(rules); return ( @@ -25,6 +63,13 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => { {label} + {isLinodeInterfacesEnabled && isDefault && ( + + )} @@ -32,7 +77,14 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => { {getRuleString(count)} - {getDevicesCellString(entities)} + + {getDevicesCellString({ + entities, + isLinodeInterfacesEnabled, + isLoading, + linodesWithInterfaceDevices, + })} + { firewallID={id} firewallLabel={label} firewallStatus={status} + isDefaultFirewall={isDefault} {...actionHandlers} /> @@ -77,31 +130,76 @@ export const getCountOfRules = (rules: Firewall['rules']): [number, number] => { return [(rules.inbound || []).length, (rules.outbound || []).length]; }; -const getDevicesCellString = (entities: FirewallDeviceEntity[]) => { - if (entities.length === 0) { +interface DeviceLinkInputs { + entities: FirewallDeviceEntity[]; + isLinodeInterfacesEnabled: boolean; + isLoading: boolean; + linodesWithInterfaceDevices: Linode[] | undefined; +} +const getDevicesCellString = (inputs: DeviceLinkInputs) => { + const { + entities, + isLinodeInterfacesEnabled, + isLoading, + linodesWithInterfaceDevices, + } = inputs; + const filteredEntities = isLinodeInterfacesEnabled + ? entities + : entities.filter((entity) => entity.type !== 'interface'); + + if (filteredEntities.length === 0) { return 'None assigned'; } - return getDeviceLinks(entities); + return getDeviceLinks({ + entities: filteredEntities, + isLoading, + linodesWithInterfaceDevices, + }); }; -export const getDeviceLinks = (entities: FirewallDeviceEntity[]) => { +export const getDeviceLinks = ( + inputs: Omit +) => { + const { entities, isLoading, linodesWithInterfaceDevices } = inputs; const firstThree = entities.slice(0, 3); + if (isLoading) { + return ; + } + return ( <> - {firstThree.map((entity, idx) => ( - - {idx > 0 && ', '} - - {entity.label} - - - ))} + {firstThree.map((entity, idx) => { + // TODO @Linode Interfaces - switch to parent entity when endpoints are updated + const isInterfaceDevice = entity.type === 'interface'; + let entityLabel = entity.label; + let entityLink = `/${entity.type}s/${entity.id}/${ + entity.type === 'linode' ? 'networking' : 'summary' + }`; + + if (isInterfaceDevice) { + const parentEntityId = getLinodeIdFromInterfaceDevice(entity); + entityLabel = + linodesWithInterfaceDevices?.find( + (linode) => linode.id === parentEntityId + )?.label ?? entity.label; + entityLink = `/linodes/${parentEntityId}/networking/interfaces/${entity.id}`; + } + + return ( + + {idx > 0 && ', '} + + {entityLabel} + + + ); + })} {entities.length > 3 && , plus {entities.length - 3} more.} ); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/PublicTemplateRules.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/PublicTemplateRules.tsx index e6972d2207e..dc65cf6a7a3 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/PublicTemplateRules.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/PublicTemplateRules.tsx @@ -1,4 +1,4 @@ -import { Box, List, ListItem, Typography } from '@linode/ui'; +import { Box, List, ListItem, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { STRENGTHEN_TEMPLATE_RULES } from './constants'; @@ -8,22 +8,21 @@ import type { Theme } from '@mui/material'; export const PublicTemplateRules = () => { return ( <> - ({ marginTop: theme.spacing(3) })}> - Allows for login with SSH, and regular networking control data. - - ({ marginTop: theme.spacing(2) })}> - {STRENGTHEN_TEMPLATE_RULES} - - ({ - backgroundColor: theme.tokens.alias.Background.Neutral, - marginTop: theme.spacing(2), - padding: theme.spacing(2), - })} - data-testid="public-template-info" - > - {sharedTemplateRules} - + + + Allows for login with SSH, and regular networking control data. + + {STRENGTHEN_TEMPLATE_RULES} + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(16), + })} + data-testid="public-template-info" + > + {sharedTemplateRules} + + {sharedTemplatePolicies} ); @@ -31,14 +30,13 @@ export const PublicTemplateRules = () => { const templateRuleStyling = (theme: Theme) => ({ backgroundColor: theme.tokens.alias.Background.Neutral, - marginTop: theme.spacing(1), - padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + padding: `${theme.spacingFunction(8)} ${theme.spacingFunction(16)}`, }); export const sharedTemplateRules = ( <> Rules - ({ marginTop: theme.spacing(1) })}> + ({ marginTop: theme.spacingFunction(8) })}> Allow Inbound SSH @@ -52,7 +50,7 @@ export const sharedTemplateRules = ( Sources: All IPv4, IPv6 - ({ marginTop: theme.spacing(2) })}> + ({ marginTop: theme.spacingFunction(16) })}> Allow Inbound ICMP @@ -67,7 +65,7 @@ export const sharedTemplateRules = ( ); export const sharedTemplatePolicies = ( - <> + ({ ...templateRuleStyling(theme), @@ -84,5 +82,5 @@ export const sharedTemplatePolicies = ( Default Outbound Policy: ACCEPT - + ); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/VPCTemplateRules.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/VPCTemplateRules.tsx index f2d7bead5cf..e9ebc709890 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/VPCTemplateRules.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/VPCTemplateRules.tsx @@ -1,4 +1,4 @@ -import { Box, List, ListItem, Typography } from '@linode/ui'; +import { Box, List, ListItem, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { TextTooltip } from 'src/components/TextTooltip'; @@ -12,42 +12,43 @@ import { export const VPCTemplateRules = () => { return ( <> - ({ marginTop: theme.spacing(3) })}> - Allows for login with SSH, regular networking control data, and inbound - traffic from the VPC address space. - - ({ marginTop: theme.spacing(2) })}> - {STRENGTHEN_TEMPLATE_RULES} - - ({ - backgroundColor: theme.tokens.alias.Background.Neutral, - marginTop: theme.spacing(2), - padding: theme.spacing(2), - })} - data-testid="vpc-template-info" - > - {sharedTemplateRules} - ({ marginTop: theme.spacing(2) })}> - Allow traffic for{' '} - {' '} - ranges + + + Allows for login with SSH, regular networking control data, and + inbound traffic from the VPC address space. - - - Protocol: TCP, UDP - - - Ports: All Ports - - - Sources: 10.0.0.0/8, 192.168.0.0/17, 172.16.0.0/12 - - - + {STRENGTHEN_TEMPLATE_RULES} + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(16), + })} + data-testid="vpc-template-info" + > + {sharedTemplateRules} + ({ marginTop: theme.spacingFunction(16) })} + > + Allow traffic for{' '} + {' '} + ranges + + + + Protocol: TCP, UDP + + + Ports: All Ports + + + Sources: 10.0.0.0/8, 192.168.0.0/17, 172.16.0.0/12 + + + + {sharedTemplatePolicies} ); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/constants.ts b/packages/manager/src/features/Firewalls/FirewallLanding/constants.ts index d2523ab4dbf..fee644396f3 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/constants.ts +++ b/packages/manager/src/features/Firewalls/FirewallLanding/constants.ts @@ -12,3 +12,8 @@ export const NODEBALANCER_HELPER_TEXT = export const STRENGTHEN_TEMPLATE_RULES = 'It is recommended to further strengthen these rules by limiting the allowed IPv4 and IPv6 ranges.'; + +export const NO_PERMISSIONS_TOOLTIP_TEXT = + "You don't have permissions to modify this Firewall."; +export const DEFAULT_FIREWALL_TOOLTIP_TEXT = + 'This firewall is used as an interface default and cannot be modified. Change the firewall default assignment in Account Settings to modify the firewall.'; diff --git a/packages/manager/src/features/Firewalls/components/DefaultFirewallChip.tsx b/packages/manager/src/features/Firewalls/components/DefaultFirewallChip.tsx new file mode 100644 index 00000000000..60f9fe8834b --- /dev/null +++ b/packages/manager/src/features/Firewalls/components/DefaultFirewallChip.tsx @@ -0,0 +1,28 @@ +import { Chip, Tooltip } from '@linode/ui'; +import React from 'react'; + +import type { ChipProps } from '@linode/ui'; + +interface Props { + chipProps?: Partial; + defaultNumEntities: number; + tooltipText: React.ReactNode; +} + +export const DefaultFirewallChip = (props: Props) => { + const { chipProps, defaultNumEntities, tooltipText } = props; + return ( + + 1 ? ` (${defaultNumEntities})` : '' + }`} + size="small" + {...chipProps} + /> + + ); +}; diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelect.test.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelect.test.tsx new file mode 100644 index 00000000000..68fa7368856 --- /dev/null +++ b/packages/manager/src/features/Firewalls/components/FirewallSelect.test.tsx @@ -0,0 +1,53 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { firewallFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { FirewallSelect } from './FirewallSelect'; + +describe('FirewallSelect', () => { + it('renders a default label', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Firewall')).toBeVisible(); + }); + + it('renders a custom label', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Assign Firewall')).toBeVisible(); + }); + + it('renders an error', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Firewall is required.')).toBeVisible(); + }); + + it('renders firewalls returned by the API', async () => { + const firewalls = firewallFactory.buildList(3); + + server.use( + http.get('*/v4/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage(firewalls)); + }) + ); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await userEvent.click(getByLabelText('Firewall')); + + for (const firewall of firewalls) { + expect(getByText(firewall.label)).toBeVisible(); + } + }); +}); diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx new file mode 100644 index 00000000000..cf86f651a07 --- /dev/null +++ b/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx @@ -0,0 +1,96 @@ +import { useAllFirewallsQuery } from '@linode/queries'; +import { Autocomplete } from '@linode/ui'; +import React, { useMemo } from 'react'; + +import { useDefaultFirewallChipInformation } from 'src/hooks/useDefaultFirewallChipInformation'; + +import { DefaultFirewallChip } from './DefaultFirewallChip'; +import { FirewallSelectOption } from './FirewallSelectOption'; + +import type { Firewall } from '@linode/api-v4'; +import type { EnhancedAutocompleteProps } from '@linode/ui'; + +interface Props + extends Omit< + EnhancedAutocompleteProps, + 'label' | 'options' | 'value' + > { + disableClearable?: DisableClearable; + /** + * Hide "Default" chips showing which firewalls are defaults + * @default false + */ + hideDefaultChips?: boolean; + /** + * The label applied to the Autocomplete's TextField. + * @default Firewall + */ + label?: string; + /** + * Optionally pass your own array of Firewalls. + * All Firewall will show if this is omitted. + */ + options?: Firewall[]; + /** + * The ID of the selected Firewall + */ + value: null | number | undefined; +} + +/** + * A shared "Firewall Select" component intended to be used when + * a user needs to choose a Firewall + * + * Currently this is only a single select, but can be extended to support more + * Autocomplete features. + */ +export const FirewallSelect = ( + props: Props +) => { + const { errorText, hideDefaultChips, loading, value, ...rest } = props; + + const { data: firewalls, error, isLoading } = useAllFirewallsQuery(); + + const { + defaultNumEntities, + isDefault, + tooltipText, + } = useDefaultFirewallChipInformation(value, hideDefaultChips); + + const selectedFirewall = useMemo( + () => firewalls?.find((firewall) => firewall.id === value) ?? null, + [firewalls, value] + ); + + return ( + + renderOption={({ key, ...props }, option, state) => ( + + )} + textFieldProps={{ + InputProps: { + endAdornment: isDefault && !hideDefaultChips && ( + + ), + }, + }} + errorText={errorText ?? error?.[0].reason} + label="Firewall" + loading={isLoading || loading} + noMarginTop + options={firewalls ?? []} + placeholder="None" + value={selectedFirewall!} + {...rest} + /> + ); +}; diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelectOption.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelectOption.tsx new file mode 100644 index 00000000000..92fdf56934b --- /dev/null +++ b/packages/manager/src/features/Firewalls/components/FirewallSelectOption.tsx @@ -0,0 +1,46 @@ +import { Box, SelectedIcon, Stack } from '@linode/ui'; +import React from 'react'; + +import { useDefaultFirewallChipInformation } from 'src/hooks/useDefaultFirewallChipInformation'; + +import { DefaultFirewallChip } from './DefaultFirewallChip'; + +import type { Firewall } from '@linode/api-v4'; +import type { AutocompleteRenderOptionState } from '@mui/material'; + +interface Props { + /** + * Hide the "Default" chip from showing + * @default false + */ + hideDefaultChip?: boolean; + listItemProps: React.HTMLAttributes; + option: Firewall; + state: AutocompleteRenderOptionState; +} + +export const FirewallSelectOption = (props: Props) => { + const { hideDefaultChip, listItemProps, option, state } = props; + + const { + defaultNumEntities, + isDefault, + tooltipText, + } = useDefaultFirewallChipInformation(option.id); + + return ( +
  • + + {option.label} + + {isDefault && !hideDefaultChip && ( + + )} + {state.selected && } + +
  • + ); +}; diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelectOption.utils.test.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelectOption.utils.test.tsx new file mode 100644 index 00000000000..dd7c9060eab --- /dev/null +++ b/packages/manager/src/features/Firewalls/components/FirewallSelectOption.utils.test.tsx @@ -0,0 +1,104 @@ +import { firewallSettingsFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { + getDefaultFirewallDescription, + getFirewallDefaultEntities, +} from './FirewallSelectOption.utils'; + +describe('getFirewallDefaultEntities', () => { + it('returns entities that a firewall is a default for', () => { + const firewallSettings = firewallSettingsFactory.build({ + default_firewall_ids: { + linode: 4, + nodebalancer: 4, + public_interface: 4, + vpc_interface: 1, + }, + }); + + expect(getFirewallDefaultEntities(4, firewallSettings)).toEqual([ + 'linode', + 'nodebalancer', + 'public_interface', + ]); + }); + + it('returns an empty array if the firewall is not a default for anything', () => { + const firewallSettings = firewallSettingsFactory.build({ + default_firewall_ids: { + linode: 4, + nodebalancer: 4, + public_interface: 4, + vpc_interface: 4, + }, + }); + + expect(getFirewallDefaultEntities(1, firewallSettings)).toEqual([]); + }); + + it('returns an empty array if the user has no default firewalls set', () => { + const firewallSettings = firewallSettingsFactory.build({ + default_firewall_ids: { + linode: null, + nodebalancer: null, + public_interface: null, + vpc_interface: null, + }, + }); + + expect(getFirewallDefaultEntities(1, firewallSettings)).toEqual([]); + }); +}); + +describe('getDefaultFirewallDescription', () => { + it('returns null if a firewall is not a default for anything', () => { + const firewallSettings = firewallSettingsFactory.build({ + default_firewall_ids: { + linode: 4, + nodebalancer: 4, + public_interface: 4, + vpc_interface: 1, + }, + }); + + expect(getDefaultFirewallDescription(2, firewallSettings)).toEqual(null); + }); + + it('returns human readable text when the firewall is a default for one type of entity', () => { + const firewallSettings = firewallSettingsFactory.build({ + default_firewall_ids: { + linode: 4, + nodebalancer: 4, + public_interface: 4, + vpc_interface: 1, + }, + }); + + const { getByText } = renderWithTheme( + getDefaultFirewallDescription(1, firewallSettings) + ); + + expect(getByText('VPC (Linode Interfaces)')).toBeVisible(); + }); + + it('returns human readable text when the firewall is a default for three types of entities', () => { + const firewallSettings = firewallSettingsFactory.build({ + default_firewall_ids: { + linode: 4, + nodebalancer: 4, + public_interface: 4, + vpc_interface: 1, + }, + }); + + const { getByText, queryByText } = renderWithTheme( + getDefaultFirewallDescription(4, firewallSettings) + ); + + expect(getByText('NodeBalancers')).toBeVisible(); + expect(getByText('Public (Linode Interfaces)')).toBeVisible(); + expect(getByText('Configuration Profile Interfaces')).toBeVisible(); + expect(queryByText('VPC (Linode Interfaces)')).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelectOption.utils.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelectOption.utils.tsx new file mode 100644 index 00000000000..9f991daaf3e --- /dev/null +++ b/packages/manager/src/features/Firewalls/components/FirewallSelectOption.utils.tsx @@ -0,0 +1,85 @@ +import { List, ListItem, Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import type { FirewallSettings } from '@linode/api-v4'; + +export type FirewallDefaultEntity = keyof FirewallSettings['default_firewall_ids']; + +/** + * Maps an entity that supports default firewalls to a readable name. + */ +export const FIREWALL_DEFAULT_ENTITY_TO_READABLE_NAME: Record< + FirewallDefaultEntity, + string +> = { + linode: 'Configuration Profile Interfaces', + nodebalancer: 'NodeBalancers', + public_interface: 'Public (Linode Interfaces)', + vpc_interface: 'VPC (Linode Interfaces)', +}; + +/** + * getFirewallDefaultEntities + * + * @param firewallId The ID of the Firewall + * @param firewallSettings The account FirewallSettings from the API + * + * @returns An array of entities that this Firewall is a default for. + * @example ['nodebalancer', 'vpc_interface'] + */ +export function getFirewallDefaultEntities( + firewallId: number, + firewallSettings: FirewallSettings +) { + const defaultFor: FirewallDefaultEntity[] = []; + + for (const key in firewallSettings.default_firewall_ids) { + const entity = key as FirewallDefaultEntity; + if (firewallSettings.default_firewall_ids[entity] === firewallId) { + defaultFor.push(entity); + } + } + + return defaultFor; +} + +/** + * getDefaultFirewallDescription + * + * @param firewallId The ID of the Firewall + * @param firewallSettings The account FirewallSettings from the API + * + * @returns A human readable list that explains what entities this Firewall is a default for. + * It will return `null` if this Firewall is not a default for anything. + */ +export function getDefaultFirewallDescription( + firewallId: number, + firewallSettings: FirewallSettings +) { + const entitiesThatFirewallIsDefaultFor = getFirewallDefaultEntities( + firewallId, + firewallSettings + ); + + if (entitiesThatFirewallIsDefaultFor.length === 0) { + // This means that a Firewall is not a default. + return null; + } + + const readableEntities = entitiesThatFirewallIsDefaultFor.map( + (entity) => FIREWALL_DEFAULT_ENTITY_TO_READABLE_NAME[entity] + ); + + return ( + + Default Firewall for: + + {readableEntities.map((entity) => ( + + {entity} + + ))} + + + ); +} diff --git a/packages/manager/src/features/Firewalls/shared.test.ts b/packages/manager/src/features/Firewalls/shared.test.ts index dfec1c0560f..092edb562c5 100644 --- a/packages/manager/src/features/Firewalls/shared.test.ts +++ b/packages/manager/src/features/Firewalls/shared.test.ts @@ -1,12 +1,16 @@ -import { FirewallRuleType } from '@linode/api-v4/lib/firewalls/types'; - import { allIPv4, allIPv6, generateAddressesLabel, + getLinodeIdFromInterfaceDevice, predefinedFirewallFromRule, } from './shared'; +import type { + FirewallDeviceEntityType, + FirewallRuleType, +} from '@linode/api-v4/lib/firewalls/types'; + const addresses = { ipv4: [allIPv4], ipv6: [allIPv6], @@ -144,3 +148,22 @@ describe('generateAddressLabel', () => { ); }); }); + +describe('getLinodeIdFromInterfaceDevice', () => { + it('returns the ID', () => { + const entity = { + id: 123, + label: null, + type: 'interface' as FirewallDeviceEntityType, + url: '/v4/linode/instances/123/interfaces/123', + }; + + expect(getLinodeIdFromInterfaceDevice(entity)).toEqual(123); + expect( + getLinodeIdFromInterfaceDevice({ + ...entity, + url: '/v4/linode/instances/456/interfaces/123', + }) + ).toEqual(456); + }); +}); diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts index efdf5c7b857..7b80e847261 100644 --- a/packages/manager/src/features/Firewalls/shared.ts +++ b/packages/manager/src/features/Firewalls/shared.ts @@ -2,7 +2,7 @@ import { truncateAndJoinList } from '@linode/utilities'; import { capitalize } from '@linode/utilities'; import type { PORT_PRESETS } from './FirewallDetail/Rules/shared'; -import type { Grants, Profile } from '@linode/api-v4'; +import type { FirewallDeviceEntity, Grants, Profile } from '@linode/api-v4'; import type { Firewall, FirewallRuleProtocol, @@ -268,3 +268,17 @@ export const getFirewallDescription = (firewall: Firewall) => { ]; return description.join(', '); }; + +// TODO @Linode Interfaces - probably get rid of this once the API changes to FirewallDevice come in +/** + * Utility function to extract the Linode ID from firewall interface device entities. For Interface devices, + * the URL is "/v4/linode/instances/123/interfaces/123" + * + * Assumptions: the entity device being passed into this function always has type "interface". The URL is + * always in the above format. + */ +export const getLinodeIdFromInterfaceDevice = ( + entity: FirewallDeviceEntity +): number => { + return Number(entity.url.split('/')[4]); +}; diff --git a/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.test.tsx b/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.test.tsx index 48d3b49d19f..07a55e4ff1a 100644 --- a/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.test.tsx +++ b/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.test.tsx @@ -1,9 +1,9 @@ +import { regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import * as React from 'react'; -import { regionFactory } from 'src/factories/regions'; import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { RegionStatusBanner } from './RegionStatusBanner'; diff --git a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx index cc006a6a7c2..8434a74878a 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx @@ -1,31 +1,32 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; +import { accountEntityFactory } from 'src/factories/accountEntities'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AssignedPermissionsPanel } from './AssignedPermissionsPanel'; import type { + EntityTypePermissions, IamAccessType, - ResourceTypePermissions, Roles, } from '@linode/api-v4/lib/iam/types'; -import type { IamAccountResource } from '@linode/api-v4/lib/resources/types'; interface ExtendedRole extends Roles { access: IamAccessType; - resource_type: ResourceTypePermissions; + entity_type: EntityTypePermissions; } const queryMocks = vi.hoisted(() => ({ - useAccountResources: vi.fn().mockReturnValue({}), + useAccountEntities: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/resources/resources', async () => { - const actual = await vi.importActual('src/queries/resources/resources'); +vi.mock('src/queries/entities/entities', async () => { + const actual = await vi.importActual('src/queries/entities/entities'); return { ...actual, - useAccountResources: queryMocks.useAccountResources, + useAccountEntities: queryMocks.useAccountEntities, }; }); @@ -33,14 +34,15 @@ const mockAccountAcceessRole: ExtendedRole = { access: 'account_access', description: 'Access to perform any supported action on all linode instances in the account', + entity_type: 'account', name: 'account_retail_owner', permissions: ['cancel_account'], - resource_type: 'account', }; -const mockResourcesAcceessRole: ExtendedRole = { - access: 'resource_access', +const mockEntitiesAcceessRole: ExtendedRole = { + access: 'entity_access', description: 'Access to administer a image instance', + entity_type: 'image', name: 'image_admin', permissions: [ 'create_image', @@ -50,30 +52,14 @@ const mockResourcesAcceessRole: ExtendedRole = { 'update_image', 'delete_image', ], - resource_type: 'image', }; -const mockResources: IamAccountResource[] = [ - { - resource_type: 'linode', - resources: [ - { - id: 23456789, - name: 'linode-uk-123', - }, - { - id: 456728, - name: 'db-us-southeast1', - }, - ], - }, - { - resource_type: 'image', - resources: [ - { id: 3, name: 'image-1' }, - { id: 4, name: 'image-2' }, - ], - }, +const mockEntities = [ + accountEntityFactory.build({ + id: 1, + label: 'image-1', + type: 'image', + }), ]; describe('AssignedPermissionsPanel', () => { @@ -104,11 +90,12 @@ describe('AssignedPermissionsPanel', () => { expect(autocomplete[0]).toBeUndefined(); }); - it('renders with the correct context when the access is a resource', () => { - queryMocks.useAccountResources.mockReturnValue({ data: mockResources }); - + it('renders with the correct context when the access is an entity', () => { + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), + }); const { getAllByRole, getAllByTestId, getByText } = renderWithTheme( - + ); const permissions = getAllByTestId('permission'); @@ -126,11 +113,12 @@ describe('AssignedPermissionsPanel', () => { expect(autocomplete[0]).toHaveAttribute('placeholder', 'Select Images'); }); - it('renders the Autocomplete when the access is a resource', () => { - queryMocks.useAccountResources.mockReturnValue({ data: mockResources }); - + it('renders the Autocomplete when the access is an entity', () => { + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), + }); const { getAllByRole, getByText } = renderWithTheme( - + ); // Verify comboboxes exist @@ -138,18 +126,17 @@ describe('AssignedPermissionsPanel', () => { fireEvent.focus(autocomplete); fireEvent.mouseDown(autocomplete); expect(getByText('image-1')).toBeInTheDocument(); - expect(getByText('image-2')).toBeInTheDocument(); }); it('shows all permissions', () => { const { getAllByTestId } = renderWithTheme( - + ); // All chips should now be visible const visibleChips = getAllByTestId('permission'); expect(visibleChips.length).toBe( - mockResourcesAcceessRole.permissions.length + mockEntitiesAcceessRole.permissions.length ); }); }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx index 7fdd7688533..ce78397886a 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx @@ -8,14 +8,14 @@ import { Permissions } from '../Permissions/Permissions'; import type { EntitiesOption } from '../utilities'; import type { + EntityTypePermissions, IamAccessType, - ResourceTypePermissions, Roles, } from '@linode/api-v4/lib/iam/types'; interface ExtendedRole extends Roles { access: IamAccessType; - resource_type: ResourceTypePermissions; + entity_type: EntityTypePermissions; } interface Props { @@ -71,7 +71,7 @@ export const AssignedPermissionsPanel = ({ assignedEntities, role }: Props) => { ); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.test.tsx new file mode 100644 index 00000000000..7ebf05858f5 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AssignedRolesActionMenu } from './AssignedRolesActionMenu'; + +import type { ExtendedRoleMap } from '../utilities'; + +const mockOnChangeRole = vi.fn(); +const mockOnUnassignRole = vi.fn(); +const mockOnViewEntities = vi.fn(); + +const mockAccountRole: ExtendedRoleMap = { + access: 'account_access', + description: + 'Access to perform any supported action on all resources in the account', + entity_ids: null, + entity_type: 'account', + id: 'account_admin', + name: 'account_admin', + permissions: ['create_linode', 'update_linode', 'update_firewall'], +}; + +const mockEntityRole: ExtendedRoleMap = { + access: 'entity_access', + description: 'Access to update a linode instance', + entity_ids: [12345678], + entity_type: 'linode', + id: 'linode_contributor', + name: 'linode_contributor', + permissions: ['update_linode', 'view_linode'], +}; + +describe('AssignedRolesActionMenu', () => { + it('should render actions for account access roles correctly', () => { + const { getByRole, queryByText } = renderWithTheme( + + ); + + const actionBtn = getByRole('button'); + expect(actionBtn).toBeInTheDocument(); + fireEvent.click(actionBtn); + + expect(queryByText('Change Role')).toBeInTheDocument(); + expect(queryByText('Unassign Role')).toBeInTheDocument(); + expect(queryByText('Update List of Entities')).not.toBeInTheDocument(); + expect(queryByText('View Entities')).not.toBeInTheDocument(); + }); + + it('should render actions for entity access roles correctly', () => { + const { getByRole, queryByText } = renderWithTheme( + + ); + + // Check if "Manage Access" action is present + const actionBtn = getByRole('button'); + expect(actionBtn).toBeInTheDocument(); + fireEvent.click(actionBtn); + + expect(queryByText('View Entities')).toBeInTheDocument(); + expect(queryByText('Update List of Entities')).toBeInTheDocument(); + expect(queryByText('Change Role')).toBeInTheDocument(); + expect(queryByText('Unassign Role')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx new file mode 100644 index 00000000000..b79e160a9ae --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { ExtendedRoleMap } from '../utilities'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +interface Props { + handleChangeRole: (role: ExtendedRoleMap) => void; + handleUnassignRole: (role: ExtendedRoleMap) => void; + handleViewEntities: (role: string) => void; + role: ExtendedRoleMap; +} + +export const AssignedRolesActionMenu = ({ + handleChangeRole, + handleUnassignRole, + handleViewEntities, + role, +}: Props) => { + const accountMenu: Action[] = [ + { + onClick: () => { + handleChangeRole(role); + }, + title: 'Change Role', + }, + { + onClick: () => { + handleUnassignRole(role); + }, + title: 'Unassign Role', + }, + ]; + + const entitiesMenu: Action[] = [ + { + onClick: () => handleViewEntities(role.name), + title: 'View Entities', + }, + { + onClick: () => { + // mock + }, + title: 'Update List of Entities', + }, + { + onClick: () => { + handleChangeRole(role); + }, + title: 'Change Role', + }, + { + onClick: () => { + handleUnassignRole(role); + }, + title: 'Unassign Role', + }, + ]; + + const actions = role.access === 'account_access' ? accountMenu : entitiesMenu; + + return ; +}; diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx index 61ff830378a..366b3e94831 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx @@ -1,16 +1,17 @@ import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; +import { accountEntityFactory } from 'src/factories/accountEntities'; import { accountPermissionsFactory } from 'src/factories/accountPermissions'; -import { accountResourcesFactory } from 'src/factories/accountResources'; import { userPermissionsFactory } from 'src/factories/userPermissions'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AssignedRolesTable } from './AssignedRolesTable'; const queryMocks = vi.hoisted(() => ({ + useAccountEntities: vi.fn().mockReturnValue({}), useAccountPermissions: vi.fn().mockReturnValue({}), - useAccountResources: vi.fn().mockReturnValue({}), useAccountUserPermissions: vi.fn().mockReturnValue({}), })); @@ -23,14 +24,26 @@ vi.mock('src/queries/iam/iam', async () => { }; }); -vi.mock('src/queries/resources/resources', async () => { - const actual = await vi.importActual('src/queries/resources/resources'); +vi.mock('src/queries/entities/entities', async () => { + const actual = await vi.importActual('src/queries/entities/entities'); return { ...actual, - useAccountResources: queryMocks.useAccountResources, + useAccountEntities: queryMocks.useAccountEntities, }; }); +const mockEntities = [ + accountEntityFactory.build({ + id: 7, + type: 'linode', + }), + accountEntityFactory.build({ + id: 1, + label: 'firewall-1', + type: 'firewall', + }), +]; + describe('AssignedRolesTable', () => { it('should display no roles text if there are no roles assigned to user', async () => { queryMocks.useAccountUserPermissions.mockReturnValue({ @@ -51,8 +64,8 @@ describe('AssignedRolesTable', () => { data: accountPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getAllByLabelText, getAllByText, getByText } = renderWithTheme( @@ -79,8 +92,8 @@ describe('AssignedRolesTable', () => { data: accountPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getByPlaceholderText, getByText } = renderWithTheme( @@ -104,8 +117,8 @@ describe('AssignedRolesTable', () => { data: accountPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getByPlaceholderText, queryByText } = renderWithTheme( @@ -131,8 +144,8 @@ describe('AssignedRolesTable', () => { data: accountPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getByPlaceholderText, queryByText } = renderWithTheme( diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index f4ed21636f7..75b59fe68cc 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -4,11 +4,11 @@ import { StyledLinkButton, Typography, } from '@linode/ui'; +import { capitalize, truncate } from '@linode/utilities'; import { Grid, useTheme } from '@mui/material'; import React from 'react'; import { useHistory, useParams } from 'react-router-dom'; -import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { CollapsibleTable } from 'src/components/CollapsibleTable/CollapsibleTable'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { TableCell } from 'src/components/TableCell'; @@ -16,28 +16,29 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { useOrder } from 'src/hooks/useOrder'; +import { useAccountEntities } from 'src/queries/entities/entities'; import { useAccountPermissions, useAccountUserPermissions, } from 'src/queries/iam/iam'; -import { useAccountResources } from 'src/queries/resources/resources'; +import { AssignedEntities } from '../../Users/UserRoles/AssignedEntities'; import { Permissions } from '../Permissions/Permissions'; import { - addResourceNamesToRoles, + addEntitiesNamesToRoles, combineRoles, getFilteredRoles, mapEntityTypes, mapRolesToPermissions, + transformedAccountEntities, } from '../utilities'; -import { AssignedEntities } from '../../Users/UserRoles/AssignedEntities'; +import { AssignedRolesActionMenu } from './AssignedRolesActionMenu'; +import { ChangeRoleDrawer } from './ChangeRoleDrawer'; +import { UnassignRoleConfirmationDialog } from './UnassignRoleConfirmationDialog'; import type { EntitiesType, ExtendedRoleMap, RoleMap } from '../utilities'; import type { AccountAccessType, RoleType } from '@linode/api-v4'; -import type { Action } from 'src/components/ActionMenu/ActionMenu'; import type { TableItem } from 'src/components/CollapsibleTable/CollapsibleTable'; -import { capitalize, truncate } from '@linode/utilities'; -import { ChangeRoleDrawer } from './ChangeRoleDrawer'; export const AssignedRolesTable = () => { const { username } = useParams<{ username: string }>(); @@ -50,20 +51,26 @@ export const AssignedRolesTable = () => { setIsChangeRoleDrawerOpen, ] = React.useState(false); const [selectedRole, setSelectedRole] = React.useState(); + const [ + isUnassignRoleDialogOpen, + setIsUnassignRoleDialogOpen, + ] = React.useState(false); const handleChangeRole = (role: ExtendedRoleMap) => { setIsChangeRoleDrawerOpen(true); setSelectedRole(role); }; + const handleUnassignRole = (role: ExtendedRoleMap) => { + setIsUnassignRoleDialogOpen(true); + setSelectedRole(role); + }; + const { data: accountPermissions, isLoading: accountPermissionsLoading, } = useAccountPermissions(); - const { - data: resources, - isLoading: resourcesLoading, - } = useAccountResources(); + const { data: entities, isLoading: entitiesLoading } = useAccountEntities(); const { data: assignedRoles, isLoading: assignedRolesLoading, @@ -79,12 +86,14 @@ export const AssignedRolesTable = () => { const resourceTypes = getResourceTypes(roles); - if (resources) { - roles = addResourceNamesToRoles(roles, resources); + if (entities) { + const transformedEntities = transformedAccountEntities(entities.data); + + roles = addEntitiesNamesToRoles(roles, transformedEntities); } return { resourceTypes, roles }; - }, [assignedRoles, accountPermissions, resources]); + }, [assignedRoles, accountPermissions, entities]); const [query, setQuery] = React.useState(''); @@ -92,7 +101,7 @@ export const AssignedRolesTable = () => { const [showFullDescription, setShowFullDescription] = React.useState(false); - const handleClick = (roleName: AccountAccessType | RoleType) => { + const handleViewEntities = (roleName: AccountAccessType | RoleType) => { const selectedRole = roleName; history.push({ pathname: `/iam/users/${username}/entities`, @@ -109,70 +118,32 @@ export const AssignedRolesTable = () => { }); return filteredRoles.map((role: ExtendedRoleMap) => { - const accountMenu: Action[] = [ - { - onClick: () => { - handleChangeRole(role); - }, - title: 'Change Role', - }, - { - onClick: () => { - // mock - }, - title: 'Unassign Role', - }, - ]; - - const entitiesMenu: Action[] = [ - { - onClick: () => handleClick(role.name), - title: 'View Entities', - }, - { - onClick: () => { - // mock - }, - title: 'Update List of Entities', - }, - { - onClick: () => { - handleChangeRole(role); - }, - title: 'Change Role', - }, - { - onClick: () => { - // mock - }, - title: 'Unassign Role', - }, - ]; - - const actions = - role.access === 'account_access' ? accountMenu : entitiesMenu; - const OuterTableCells = ( <> {role.access === 'account_access' ? ( - {role.resource_type === 'account' + {role.entity_type === 'account' ? 'All Entities' - : `All ${capitalize(role.resource_type)}s`} + : `All ${capitalize(role.entity_type)}s`} ) : ( )} - + ); @@ -225,7 +196,7 @@ export const AssignedRolesTable = () => { }); }, [roles, query, entityType, showFullDescription]); - if (accountPermissionsLoading || resourcesLoading || assignedRolesLoading) { + if (accountPermissionsLoading || entitiesLoading || assignedRolesLoading) { return ; } @@ -307,6 +278,11 @@ export const AssignedRolesTable = () => { open={isChangeRoleDrawerOpen} role={selectedRole} /> + setIsUnassignRoleDialogOpen(false)} + open={isUnassignRoleDialogOpen} + role={selectedRole} + /> ); }; @@ -315,14 +291,14 @@ const getResourceTypes = (data: RoleMap[]): EntitiesType[] => mapEntityTypes(data, ' Roles'); const getSearchableFields = (role: ExtendedRoleMap): string[] => { - const resourceNames = role.resource_names || []; + const entityNames = role.entity_names || []; return [ String(role.id), - role.resource_type, + role.entity_type, role.name, role.access, role.description, - ...resourceNames, + ...entityNames, ...role.permissions, ]; }; diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx index 78ffa5bad44..04bee99845a 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx @@ -2,8 +2,8 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { accountEntityFactory } from 'src/factories/accountEntities'; import { accountPermissionsFactory } from 'src/factories/accountPermissions'; -import { accountResourcesFactory } from 'src/factories/accountResources'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ChangeRoleDrawer } from './ChangeRoleDrawer'; @@ -11,8 +11,8 @@ import { ChangeRoleDrawer } from './ChangeRoleDrawer'; import type { ExtendedRoleMap } from '../utilities'; const queryMocks = vi.hoisted(() => ({ + useAccountEntities: vi.fn().mockReturnValue({}), useAccountPermissions: vi.fn().mockReturnValue({}), - useAccountResources: vi.fn().mockReturnValue({}), useAccountUserPermissions: vi.fn().mockReturnValue({}), })); @@ -25,11 +25,11 @@ vi.mock('src/queries/iam/iam', async () => { }; }); -vi.mock('src/queries/resources/resources', async () => { - const actual = await vi.importActual('src/queries/resources/resources'); +vi.mock('src/queries/entities/entities', async () => { + const actual = await vi.importActual('src/queries/entities/entities'); return { ...actual, - useAccountResources: queryMocks.useAccountResources, + useAccountEntities: queryMocks.useAccountEntities, }; }); @@ -37,11 +37,11 @@ const mockRole: ExtendedRoleMap = { access: 'account_access', description: 'Access to perform any supported action on all resources in the account', + entity_ids: null, + entity_type: 'account', id: 'account_admin', name: 'account_admin', permissions: ['create_linode', 'update_linode', 'update_firewall'], - resource_ids: null, - resource_type: 'account', }; const props = { @@ -73,11 +73,11 @@ describe('ChangeRoleDrawer', () => { queryMocks.useAccountUserPermissions.mockReturnValue({ data: { account_access: ['account_linode_admin', 'account_admin'], - resource_access: [ + entity_access: [ { - resource_id: 12345678, - resource_type: 'linode', + id: 12345678, roles: ['linode_contributor'], + type: 'linode', }, ], }, @@ -87,21 +87,14 @@ describe('ChangeRoleDrawer', () => { data: accountPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: accountEntityFactory.build(), }); const { getByText } = renderWithTheme(); const autocomplete = screen.getByRole('combobox'); - await waitFor( - () => { - expect(autocomplete).toHaveValue('account_admin'); - }, - { interval: 100, timeout: 5000 } - ); - act(() => { // Open the dropdown fireEvent.click(autocomplete); @@ -125,11 +118,11 @@ describe('ChangeRoleDrawer', () => { await waitFor(() => { expect(mockUpdateUserRole).toHaveBeenCalledWith({ account_access: ['account_linode_admin', 'account_viewer'], - resource_access: [ + entity_access: [ { - resource_id: 12345678, - resource_type: 'linode', + id: 12345678, roles: ['linode_contributor'], + type: 'linode', }, ], }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx index d96af9517f5..4e9b85ac420 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx @@ -45,25 +45,24 @@ export const ChangeRoleDrawer = ({ onClose, open, role }: Props) => { } = useAccountUserPermissionsMutation(username); const formattedAssignedEntities: EntitiesOption[] = React.useMemo(() => { - if (!role || !role.resource_names || !role.resource_ids) { + if (!role || !role.entity_names || !role.entity_ids) { return []; } - return role.resource_names.map((name, index) => ({ + return role.entity_names.map((name, index) => ({ label: name, - value: role.resource_ids![index], + value: role.entity_ids![index], })); }, [role]); - // filtered roles by resource_type and access + // filtered roles by entity_type and access const allRoles = React.useMemo(() => { if (!accountPermissions) { return []; } return getAllRoles(accountPermissions).filter( - (el) => - el.resource_type === role?.resource_type && el.access === role?.access + (el) => el.entity_type === role?.entity_type && el.access === role?.access ); }, [accountPermissions, role]); @@ -76,24 +75,9 @@ export const ChangeRoleDrawer = ({ onClose, open, role }: Props) => { watch, } = useForm<{ roleName: RolesType }>({ defaultValues: { - roleName: { - access: role?.access, - label: role?.name, - resource_type: role?.resource_type, - value: role?.name, - }, + roleName: undefined, }, mode: 'onBlur', - values: role - ? { - roleName: { - access: role.access, - label: role.name, - resource_type: role.resource_type, - value: role.name, - }, - } - : undefined, }); // Watch the selected role @@ -157,25 +141,26 @@ export const ChangeRoleDrawer = ({ onClose, open, role }: Props) => { Learn more about roles and permissions.
    - - Change from role{' '} - {role?.name} to: + + Change from role {role?.name} to: ( + render={({ field, fieldState }) => ( field.onChange(value)} options={allRoles} placeholder="Select a Role" textFieldProps={{ hideLabel: true, noMarginTop: true }} - value={field.value} + value={field.value || null} /> )} control={control} name="roleName" + rules={{ required: 'Role is required.' }} /> {selectedRole && ( diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx new file mode 100644 index 00000000000..188c2cef9ec --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx @@ -0,0 +1,139 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import { accountPermissionsFactory } from 'src/factories/accountPermissions'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UnassignRoleConfirmationDialog } from './UnassignRoleConfirmationDialog'; + +import type { ExtendedRoleMap } from '../utilities'; + +const mockRole: ExtendedRoleMap = { + access: 'account_access', + description: + 'Access to perform any supported action on all resources in the account', + entity_ids: null, + entity_type: 'account', + id: 'account_admin', + name: 'account_admin', + permissions: ['create_linode', 'update_linode', 'update_firewall'], +}; + +const props = { + onClose: vi.fn(), + onSuccess: vi.fn(), + open: true, + role: mockRole, +}; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => ({ username: 'test_user' }), + }; +}); + +const queryMocks = vi.hoisted(() => ({ + useAccountPermissions: vi.fn().mockReturnValue({}), + useAccountUserPermissions: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/iam/iam', async () => { + const actual = await vi.importActual('src/queries/iam/iam'); + return { + ...actual, + useAccountPermissions: queryMocks.useAccountPermissions, + useAccountUserPermissions: queryMocks.useAccountUserPermissions, + }; +}); + +const mockDeleteUserRole = vi.fn(); +vi.mock('@linode/api-v4', async () => { + return { + ...(await vi.importActual('@linode/api-v4')), + updateUserPermissions: (username: string, data: any) => { + mockDeleteUserRole(data); + return Promise.resolve(props); + }, + }; +}); + +describe('UnassignRoleConfirmationDialog', () => { + it('should render', () => { + const { getAllByRole, getByText } = renderWithTheme( + + {' '} + + ); + + const headerText = getByText('Unassign the account_admin role?'); + expect(headerText).toBeVisible(); + + const paragraph = getByText(/You’re about to remove the/i).closest('p'); + + expect(paragraph).toBeInTheDocument(); + expect(paragraph).toHaveTextContent(/account_admin/i); + expect(paragraph).toHaveTextContent(/test_user/i); + expect( + getByText(/The change will be applied immediately./i) + ).toBeInTheDocument(); + + const buttons = getAllByRole('button'); + expect(buttons?.length).toBe(3); + }); + + it('calls the corresponding functions when buttons are clicked', async () => { + const { getByText } = renderWithTheme( + + ); + + const deleteButton = getByText('Remove'); + expect(deleteButton).toBeVisible(); + + const cancelButton = getByText('Cancel'); + expect(cancelButton).toBeVisible(); + fireEvent.click(cancelButton); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('should allow unassign `account_admin` role', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: { + account_access: ['account_linode_admin', 'account_admin'], + resource_access: [ + { + resource_id: 12345678, + resource_type: 'linode', + roles: ['linode_contributor'], + }, + ], + }, + }); + + queryMocks.useAccountPermissions.mockReturnValue({ + data: accountPermissionsFactory.build(), + }); + + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('Remove')); + + await waitFor(() => { + expect(mockDeleteUserRole).toHaveBeenCalledWith({ + account_access: ['account_linode_admin'], + resource_access: [ + { + resource_id: 12345678, + resource_type: 'linode', + roles: ['linode_contributor'], + }, + ], + }); + }); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx new file mode 100644 index 00000000000..cfec1cb9a86 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx @@ -0,0 +1,93 @@ +import { ActionsPanel, Notice, Typography } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { + useAccountUserPermissions, + useAccountUserPermissionsMutation, +} from 'src/queries/iam/iam'; + +import { deleteUserRole } from '../utilities'; + +import type { ExtendedRoleMap } from '../utilities'; + +interface Props { + onClose: () => void; + onSuccess?: () => void; + open: boolean; + role: ExtendedRoleMap | undefined; +} + +export const UnassignRoleConfirmationDialog = (props: Props) => { + const { onClose: _onClose, onSuccess, open, role } = props; + const { username } = useParams<{ username: string }>(); + + const { enqueueSnackbar } = useSnackbar(); + + const { + error, + isPending, + mutateAsync: updateUserPermissions, + reset, + } = useAccountUserPermissionsMutation(username); + + const { data: assignedRoles } = useAccountUserPermissions(username ?? ''); + + const onClose = () => { + reset(); // resets the error state of the useMutation + _onClose(); + }; + + const onDelete = async () => { + const initialRole = role?.name; + const access = role?.access; + + const updatedUserRoles = deleteUserRole({ + access, + assignedRoles, + initialRole, + }); + + await updateUserPermissions(updatedUserRoles); + + enqueueSnackbar(`Role ${role?.name} has been deleted successfully.`, { + variant: 'success', + }); + if (onSuccess) { + onSuccess(); + } + onClose(); + }; + + return ( + + } + error={error?.[0].reason} + onClose={onClose} + open={open} + title={`Unassign the ${role?.name} role?`} + > + + + You’re about to remove the {role?.name} role from{' '} + {username}. The change will be applied immediately. + + + + ); +}; diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx index 15304503266..d4fe2241ce5 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx @@ -1,48 +1,37 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; +import { accountEntityFactory } from 'src/factories/accountEntities'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { Entities } from './Entities'; -import type { IamAccountResource } from '@linode/api-v4/lib/resources/types'; - const queryMocks = vi.hoisted(() => ({ - useAccountResources: vi.fn().mockReturnValue({}), + useAccountEntities: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/resources/resources', async () => { - const actual = await vi.importActual('src/queries/resources/resources'); +vi.mock('src/queries/entities/entities', async () => { + const actual = await vi.importActual('src/queries/entities/entities'); return { ...actual, - useAccountResources: queryMocks.useAccountResources, + useAccountEntities: queryMocks.useAccountEntities, }; }); -const mockResources: IamAccountResource[] = [ - { - resource_type: 'linode', - resources: [ - { - id: 23456789, - name: 'linode-uk-123', - }, - { - id: 456728, - name: 'db-us-southeast1', - }, - ], - }, - { - resource_type: 'image', - resources: [ - { id: 3, name: 'image-1' }, - { id: 4, name: 'image-2' }, - ], - }, +const mockEntities = [ + accountEntityFactory.build({ + id: 7, + type: 'linode', + }), + accountEntityFactory.build({ + id: 1, + label: 'firewall-1', + type: 'firewall', + }), ]; -describe('Resources', () => { +describe('Entities', () => { it('renders correct data when it is an account access and type is an account', () => { const { getByText, queryAllByRole } = renderWithTheme( @@ -73,11 +62,13 @@ describe('Resources', () => { expect(autocomplete[0]).toBeUndefined(); }); - it('renders correct data when it is a resources access', () => { - queryMocks.useAccountResources.mockReturnValue({ data: mockResources }); + it('renders correct data when it is an entity access', () => { + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), + }); const { getAllByRole, getByText } = renderWithTheme( - + ); expect(getByText('Entities')).toBeInTheDocument(); @@ -89,11 +80,13 @@ describe('Resources', () => { expect(autocomplete[0]).toHaveAttribute('placeholder', 'Select Images'); }); - it('renders correct options in Autocomplete dropdown when it is a resources access', () => { - queryMocks.useAccountResources.mockReturnValue({ data: mockResources }); + it('renders correct options in Autocomplete dropdown when it is an entity access', () => { + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), + }); const { getAllByRole, getByText } = renderWithTheme( - + ); expect(getByText('Entities')).toBeInTheDocument(); @@ -101,18 +94,17 @@ describe('Resources', () => { const autocomplete = getAllByRole('combobox')[0]; fireEvent.focus(autocomplete); fireEvent.mouseDown(autocomplete); - expect(getByText('image-1')).toBeInTheDocument(); - expect(getByText('image-2')).toBeInTheDocument(); + expect(getByText('firewall-1')).toBeInTheDocument(); }); - it('updates selected options when Autocomplete value changes when it is a resources access', () => { + it('updates selected options when Autocomplete value changes when it is an entity access', () => { const { getAllByRole, getByText } = renderWithTheme( - + ); const autocomplete = getAllByRole('combobox')[0]; - fireEvent.change(autocomplete, { target: { value: 'linode-uk-123' } }); + fireEvent.change(autocomplete, { target: { value: 'linode7' } }); fireEvent.keyDown(autocomplete, { key: 'Enter' }); - expect(getByText('linode-uk-123')).toBeInTheDocument(); + expect(getByText('test-1')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx index fba40c631c6..72e6bf2be6d 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx @@ -4,27 +4,27 @@ import { useTheme } from '@mui/material'; import React from 'react'; import { FormLabel } from 'src/components/FormLabel'; -import { useAccountResources } from 'src/queries/resources/resources'; +import { useAccountEntities } from 'src/queries/entities/entities'; -import { placeholderMap } from '../utilities'; +import { placeholderMap, transformedAccountEntities } from '../utilities'; import type { EntitiesOption } from '../utilities'; import type { + AccountEntity, + EntityType, + EntityTypePermissions, IamAccessType, - IamAccountResource, - Resource, - ResourceType, - ResourceTypePermissions, } from '@linode/api-v4/lib/iam/types'; interface Props { access: IamAccessType; assignedEntities?: EntitiesOption[]; - type: ResourceType | ResourceTypePermissions; + type: EntityType | EntityTypePermissions; } export const Entities = ({ access, assignedEntities, type }: Props) => { - const { data: resources } = useAccountResources(); + const { data: entities } = useAccountEntities(); + const theme = useTheme(); const [selectedEntities, setSelectedEntities] = React.useState< @@ -38,12 +38,12 @@ export const Entities = ({ access, assignedEntities, type }: Props) => { }, [assignedEntities]); const memoizedEntities = React.useMemo(() => { - if (access !== 'resource_access' || !resources) { + if (access !== 'entity_access' || !entities) { return []; } - const typeResources = getEntitiesByType(type, resources); - return typeResources ? transformedEntities(typeResources.resources) : []; - }, [resources, access, type]); + const typeEntities = getEntitiesByType(type, entities.data); + return typeEntities ? transformedEntities(typeEntities) : []; + }, [entities, access, type]); if (access === 'account_access') { return ( @@ -86,24 +86,24 @@ export const Entities = ({ access, assignedEntities, type }: Props) => { ); }; -const getPlaceholder = (type: ResourceType | ResourceTypePermissions): string => +const getPlaceholder = (type: EntityType | EntityTypePermissions): string => placeholderMap[type] || 'Select'; -const transformedEntities = (entities: Resource[]): EntitiesOption[] => { +const transformedEntities = ( + entities: { id: number; label: string }[] +): EntitiesOption[] => { return entities.map((entity) => ({ - label: entity.name, + label: entity.label, value: entity.id, })); }; const getEntitiesByType = ( - roleResourceType: ResourceType | ResourceTypePermissions, - resources: IamAccountResource -): IamAccountResource | undefined => { - const entitiesArray: IamAccountResource[] = Object.values(resources); + roleEntityType: EntityType | EntityTypePermissions, + entities: AccountEntity[] +): Pick[] | undefined => { + const entitiesMap = transformedAccountEntities(entities); - // Find the first matching entity by resource_type - return entitiesArray.find( - (item: IamAccountResource) => item.resource_type === roleResourceType - ); + // Find the first matching entity by type + return entitiesMap.get(roleEntityType as EntityType); }; diff --git a/packages/manager/src/features/IAM/Shared/utilities.test.ts b/packages/manager/src/features/IAM/Shared/utilities.test.ts index cc1fd2ac7f0..0b82dd05865 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.test.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.test.ts @@ -1,5 +1,8 @@ +import { userPermissionsFactory } from 'src/factories/userPermissions'; + import { combineRoles, + deleteUserRole, getAllRoles, getRoleByName, mapRolesToPermissions, @@ -10,12 +13,11 @@ import type { CombinedRoles } from './utilities'; import type { IamAccountPermissions, IamUserPermissions } from '@linode/api-v4'; const accountAccess = 'account_access'; -const resourceAccess = 'resource_access'; +const entityAccess = 'entity_access'; const accountPermissions: IamAccountPermissions = { account_access: [ { - resource_type: 'account', roles: [ { description: @@ -24,9 +26,9 @@ const accountPermissions: IamAccountPermissions = { permissions: ['create_linode', 'update_linode', 'update_firewall'], }, ], + type: 'account', }, { - resource_type: 'linode', roles: [ { description: @@ -35,11 +37,11 @@ const accountPermissions: IamAccountPermissions = { permissions: ['create_linode', 'update_linode', 'delete_linode'], }, ], + type: 'linode', }, ], - resource_access: [ + entity_access: [ { - resource_type: 'linode', roles: [ { description: 'Access to update a linode instance', @@ -47,17 +49,18 @@ const accountPermissions: IamAccountPermissions = { permissions: ['update_linode', 'view_linode'], }, ], + type: 'linode', }, ], }; const userPermissions: IamUserPermissions = { account_access: ['account_linode_admin', 'linode_creator'], - resource_access: [ + entity_access: [ { - resource_id: 12345678, - resource_type: 'linode', + id: 12345678, roles: ['linode_contributor'], + type: 'linode', }, ], }; @@ -67,20 +70,20 @@ describe('getAllRoles', () => { const expectedRoles = [ { access: accountAccess, + entity_type: 'account', label: 'account_admin', - resource_type: 'account', value: 'account_admin', }, { access: accountAccess, + entity_type: 'linode', label: 'account_linode_admin', - resource_type: 'linode', value: 'account_linode_admin', }, { - access: resourceAccess, + access: entityAccess, + entity_type: 'linode', label: 'linode_contributor', - resource_type: 'linode', value: 'linode_contributor', }, ]; @@ -96,22 +99,22 @@ describe('getRoleByName', () => { access: accountAccess, description: 'Access to perform any supported action on all resources in the account', + entity_type: 'account', name: 'account_admin', permissions: ['create_linode', 'update_linode', 'update_firewall'], - resource_type: 'account', }; expect(getRoleByName(accountPermissions, roleName)).toEqual(expectedRole); }); - it('should return an object with details about this role resource_access', () => { + it('should return an object with details about this role entity_access', () => { const roleName = 'linode_contributor'; const expectedRole = { - access: resourceAccess, + access: entityAccess, description: 'Access to update a linode instance', + entity_type: 'linode', name: 'linode_contributor', permissions: ['update_linode', 'view_linode'], - resource_type: 'linode', }; expect(getRoleByName(accountPermissions, roleName)).toEqual(expectedRole); @@ -143,30 +146,30 @@ describe('mapRolesToPermissions', () => { access: accountAccess, description: 'Access to perform any supported action on all resources in the account', + entity_ids: null, + entity_type: 'account', id: 'account_admin', name: 'account_admin', permissions: ['create_linode', 'update_linode', 'update_firewall'], - resource_ids: null, - resource_type: 'account', }, { access: accountAccess, description: 'Access to perform any supported action on all linode instances in the account', + entity_ids: null, + entity_type: 'linode', id: 'account_linode_admin', name: 'account_linode_admin', permissions: ['create_linode', 'update_linode', 'delete_linode'], - resource_ids: null, - resource_type: 'linode', }, { - access: resourceAccess, + access: entityAccess, description: 'Access to update a linode instance', + entity_ids: [12345678], + entity_type: 'linode', id: 'linode_contributor', name: 'linode_contributor', permissions: ['update_linode', 'view_linode'], - resource_ids: [12345678], - resource_type: 'linode', }, ]; @@ -180,11 +183,11 @@ describe('updateUserRoles', () => { it('should return an object of updated users roles with resource access', () => { const expectedRoles = { account_access: ['account_linode_admin', 'linode_creator'], - resource_access: [ + entity_access: [ { - resource_id: 12345678, - resource_type: 'linode', + id: 12345678, roles: ['linode_admin'], + type: 'linode', }, ], }; @@ -193,7 +196,7 @@ describe('updateUserRoles', () => { const newRole = 'linode_admin'; expect( updateUserRoles({ - access: resourceAccess, + access: entityAccess, assignedRoles: userPermissions, initialRole, newRole, @@ -201,3 +204,81 @@ describe('updateUserRoles', () => { ).toEqual(expectedRoles); }); }); + +describe('deleteUserRole', () => { + it('should return an object of updated users roles with resource access', () => { + const initialRole = 'linode_contributor'; + + const expectedRoles = { + account_access: ['account_linode_admin', 'linode_creator'], + entity_access: [], + }; + + expect( + deleteUserRole({ + access: entityAccess, + assignedRoles: userPermissions, + initialRole, + }) + ).toEqual(expectedRoles); + }); + + it('should return an object of updated users roles with resource access', () => { + const initialRole = 'linode_contributor'; + + const userPermissions = userPermissionsFactory.build(); + + const expectedRoles = { + account_access: [ + 'account_linode_admin', + 'linode_creator', + 'firewall_creator', + 'account_admin', + 'account_viewer', + ], + entity_access: [ + { + id: 10, + type: 'linode', + roles: ['linode_viewer'], + }, + { + id: 1, + type: 'firewall', + roles: ['firewall_admin'], + }, + ], + }; + + expect( + deleteUserRole({ + access: entityAccess, + assignedRoles: userPermissions, + initialRole, + }) + ).toEqual(expectedRoles); + }); + + it('should return an object of updated users roles with account access', () => { + const initialRole = 'account_linode_admin'; + + const expectedRoles = { + account_access: ['linode_creator'], + entity_access: [ + { + id: 12345678, + type: 'linode', + roles: ['linode_contributor'], + }, + ], + }; + + expect( + deleteUserRole({ + access: accountAccess, + assignedRoles: userPermissions, + initialRole, + }) + ).toEqual(expectedRoles); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index 62ad79a97b4..7af4a834042 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -5,15 +5,15 @@ import { useFlags } from 'src/hooks/useFlags'; import type { AccountAccessType, + AccountEntity, + EntityAccess, + EntityType, + EntityTypePermissions, IamAccess, IamAccessType, IamAccountPermissions, - IamAccountResource, IamUserPermissions, PermissionType, - ResourceAccess, - ResourceType, - ResourceTypePermissions, RoleType, Roles, } from '@linode/api-v4'; @@ -50,20 +50,20 @@ export const placeholderMap: Record = { }; export interface RoleMap { - access: 'account_access' | 'resource_access'; + access: 'account_access' | 'entity_access'; description: string; + entity_ids: null | number[]; + entity_type: EntityTypePermissions; id: AccountAccessType | RoleType; name: AccountAccessType | RoleType; permissions: PermissionType[]; - resource_ids: null | number[]; - resource_type: ResourceTypePermissions; } export interface ExtendedRoleMap extends RoleMap { - resource_names?: string[]; + entity_names?: string[]; } interface FilteredRolesOptions { - entityType?: ResourceType | ResourceTypePermissions; + entityType?: EntityType | EntityTypePermissions; getSearchableFields: (role: EntitiesRole | ExtendedRoleMap) => string[]; query: string; roles: EntitiesRole[] | RoleMap[]; @@ -100,10 +100,10 @@ export const getFilteredRoles = (options: FilteredRolesOptions) => { * @returns true if the given role has the given type */ const getDoesRolesMatchType = ( - resourceType: ResourceType | ResourceTypePermissions, + resourceType: EntityType | EntityTypePermissions, role: ExtendedRoleMap ) => { - return role.resource_type === resourceType; + return role.entity_type === resourceType; }; /** @@ -130,27 +130,27 @@ const getDoesRolesMatchQuery = ( export interface RolesType { access: IamAccessType; + entity_type: EntityTypePermissions; label: string; - resource_type: ResourceTypePermissions; value: string; } interface ExtendedRole extends Roles { access: IamAccessType; - resource_type: ResourceTypePermissions; + entity_type: EntityTypePermissions; } export const getAllRoles = ( permissions: IamAccountPermissions ): RolesType[] => { - const accessTypes: IamAccessType[] = ['account_access', 'resource_access']; + const accessTypes: IamAccessType[] = ['account_access', 'entity_access']; return accessTypes.flatMap((accessType: IamAccessType) => permissions[accessType].flatMap((resource: IamAccess) => resource.roles.map((role: Roles) => ({ access: accessType, + entity_type: resource.type, label: role.name, - resource_type: resource.resource_type, value: role.name, })) ) @@ -161,7 +161,7 @@ export const getRoleByName = ( accountPermissions: IamAccountPermissions, roleName: string ): ExtendedRole | null => { - const accessTypes: IamAccessType[] = ['account_access', 'resource_access']; + const accessTypes: IamAccessType[] = ['account_access', 'entity_access']; for (const permissionType of accessTypes) { const resources = accountPermissions[permissionType]; @@ -171,7 +171,7 @@ export const getRoleByName = ( return { ...role, access: permissionType, // Include access type (account or resource) - resource_type: resource.resource_type, + entity_type: resource.type, }; } } @@ -180,16 +180,16 @@ export const getRoleByName = ( }; export interface EntitiesRole { + entity_type: EntityType | EntityTypePermissions; id: string; resource_id: number; resource_name: string; - resource_type: ResourceType | ResourceTypePermissions; role_name: RoleType; } export interface EntitiesType { label: string; - rawValue: ResourceType | ResourceTypePermissions; + rawValue: EntityType | EntityTypePermissions; value?: string; } @@ -197,7 +197,7 @@ export const mapEntityTypes = ( data: EntitiesRole[] | RoleMap[], suffix: string ): EntitiesType[] => { - const resourceTypes = Array.from(new Set(data.map((el) => el.resource_type))); + const resourceTypes = Array.from(new Set(data.map((el) => el.entity_type))); return resourceTypes.map((resource) => ({ label: capitalize(resource) + suffix, @@ -212,7 +212,7 @@ export interface CombinedRoles { } /** - * Group account_access and resource_access roles of the user + * Group account_access and entity_access roles of the user * */ export const combineRoles = (data: IamUserPermissions): CombinedRoles[] => { @@ -227,20 +227,18 @@ export const combineRoles = (data: IamUserPermissions): CombinedRoles[] => { }); // Add resource access roles with their respective resource_id - data.resource_access.forEach( - (resource: { resource_id: number; roles: RoleType[] }) => { - resource.roles?.forEach((role: RoleType) => { - if (roleMap.has(role)) { - const existingResourceIds = roleMap.get(role); - if (existingResourceIds && existingResourceIds !== null) { - existingResourceIds.push(resource.resource_id); - } - } else { - roleMap.set(role, [resource.resource_id]); + data.entity_access.forEach((resource: { id: number; roles: RoleType[] }) => { + resource.roles?.forEach((role: RoleType) => { + if (roleMap.has(role)) { + const existingResourceIds = roleMap.get(role); + if (existingResourceIds && existingResourceIds !== null) { + existingResourceIds.push(resource.id); } - }); - } - ); + } else { + roleMap.set(role, [resource.id]); + } + }); + }); // Convert the Map into the final combinedRoles array roleMap.forEach((id, name) => { @@ -252,7 +250,7 @@ export const combineRoles = (data: IamUserPermissions): CombinedRoles[] => { interface AllResources { resource: IamAccess; - type: 'account_access' | 'resource_access'; + type: 'account_access' | 'entity_access'; } /** @@ -270,9 +268,9 @@ export const mapRolesToPermissions = ( resource, type: 'account_access' as const, })), - ...accountPermissions.resource_access.map((resource) => ({ + ...accountPermissions.entity_access.map((resource) => ({ resource, - type: 'resource_access' as const, + type: 'entity_access' as const, })), ]; @@ -292,11 +290,11 @@ export const mapRolesToPermissions = ( roleMap.set(name, { access: type, description: role.description, + entity_ids: id, + entity_type: resource.type, id: name, name, permissions: role.permissions, - resource_ids: id, - resource_type: resource.resource_type, }); } }); @@ -307,32 +305,28 @@ export const mapRolesToPermissions = ( /** * Add assigned entities to role */ -export const addResourceNamesToRoles = ( + +export const addEntitiesNamesToRoles = ( roles: ExtendedRoleMap[], - resources: IamAccountResource + entities: Map[]> ): ExtendedRoleMap[] => { - const resourcesArray: IamAccountResource[] = Object.values(resources); - return roles.map((role) => { - // Find the resource group by resource_type - const resourceGroup = resourcesArray.find( - (res) => res.resource_type === role.resource_type - ); + // Find the resource group by entity_type + const resourceGroup = entities.get(role.entity_type as EntityType); - if (resourceGroup && role.resource_ids) { - // Map resource_ids to their names - const resourceNames = role.resource_ids + if (resourceGroup && role.entity_ids) { + // Map entity_ids to their names + const resourceNames = role.entity_ids .map( - (id) => - resourceGroup.resources.find((resource) => resource.id === id)?.name + (id) => resourceGroup.find((resource) => resource.id === id)?.label ) - .filter((name): name is string => name !== undefined); // Remove undefined values + .filter((label): label is string => label !== undefined); // Remove undefined values - return { ...role, resource_names: resourceNames }; + return { ...role, entity_names: resourceNames }; } - // If no matching resource_type, return the role unchanged - return { ...role, resource_names: [] }; + // If no matching entity_type, return the role unchanged + return { ...role, entity_names: [] }; }); }; @@ -385,7 +379,7 @@ export interface EntitiesOption { } interface UpdateUserRolesProps { - access: 'account_access' | 'resource_access'; + access: 'account_access' | 'entity_access'; assignedRoles?: IamUserPermissions; initialRole?: string; newRole: string; @@ -407,11 +401,11 @@ export const updateUserRoles = ({ }; } - if (access === 'resource_access' && assignedRoles) { + if (access === 'entity_access' && assignedRoles) { return { ...assignedRoles, - resource_access: assignedRoles.resource_access.map( - (resource: ResourceAccess) => ({ + entity_access: assignedRoles.entity_access.map( + (resource: EntityAccess) => ({ ...resource, roles: resource.roles.map((role: RoleType) => role === initialRole ? (newRole as RoleType) : role @@ -425,7 +419,79 @@ export const updateUserRoles = ({ return ( assignedRoles ?? { account_access: [], - resource_access: [], + entity_access: [], } ); }; + +export interface AssignNewRoleFormValues { + roles: { + role: RolesType | null; + }[]; +} +interface DeleteUserRolesProps { + access?: 'account_access' | 'entity_access'; + assignedRoles?: IamUserPermissions; + initialRole?: string; +} + +export const deleteUserRole = ({ + access, + assignedRoles, + initialRole, +}: DeleteUserRolesProps): IamUserPermissions => { + if (!assignedRoles) { + return { + account_access: [], + entity_access: [], + }; + } + + if (access === 'account_access') { + return { + ...assignedRoles, + account_access: assignedRoles.account_access.filter( + (role: AccountAccessType) => role !== initialRole + ), + }; + } + + if (access === 'entity_access') { + return { + ...assignedRoles, + entity_access: assignedRoles.entity_access + .map((resource: EntityAccess) => ({ + ...resource, + roles: resource.roles.filter( + (role: RoleType) => role !== initialRole + ), + })) + .filter((resource: EntityAccess) => resource.roles.length > 0), + }; + } + + // If access type is invalid, return unchanged object + return assignedRoles; +}; + +export const transformedAccountEntities = ( + entities: AccountEntity[] +): Map[]> => { + const result: Map< + EntityType, + Pick[] + > = new Map(); + + entities.forEach((item) => { + if (!result.has(item.type)) { + result.set(item.type, []); + } + + result.get(item.type)?.push({ + id: item.id, + label: item.label, + }); + }); + + return result; +}; diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.test.tsx index fe6e848e6a2..107f66df245 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.test.tsx @@ -10,7 +10,7 @@ import type { IamUserPermissions } from '@linode/api-v4'; describe('UserDetailsPanel', () => { it("renders the user's username and email", async () => { const user = accountUserFactory.build(); - const assignedRoles = { account_access: [], resource_access: [] }; + const assignedRoles = { account_access: [], entity_access: [] }; const { getByText } = renderWithTheme( @@ -25,7 +25,7 @@ describe('UserDetailsPanel', () => { it("renders 'No Roles Assigned' if the user doesn't have the assigned roles", async () => { const user = accountUserFactory.build({ restricted: true }); - const assignedRoles = { account_access: [], resource_access: [] }; + const assignedRoles = { account_access: [], entity_access: [] }; const { getByText } = renderWithTheme( @@ -43,15 +43,15 @@ describe('UserDetailsPanel', () => { 'linode_creator', 'firewall_creator', ], - resource_access: [ + entity_access: [ { - resource_id: 12345678, - resource_type: 'linode', + id: 12345678, + type: 'linode', roles: ['linode_contributor', 'linode_creator'], }, { - resource_id: 45678901, - resource_type: 'firewall', + id: 45678901, + type: 'firewall', roles: ['firewall_admin', 'firewall_creator'], }, ], @@ -73,10 +73,10 @@ describe('UserDetailsPanel', () => { 'linode_creator', 'linode_contributor', ], - resource_access: [ + entity_access: [ { - resource_id: 12345678, - resource_type: 'linode', + id: 12345678, + type: 'linode', roles: ['linode_contributor', 'linode_creator'], }, ], @@ -94,7 +94,7 @@ describe('UserDetailsPanel', () => { const user = accountUserFactory.build({ verified_phone_number: '+17040000000', }); - const assignedRoles = { account_access: [], resource_access: [] }; + const assignedRoles = { account_access: [], entity_access: [] }; const { getByText } = renderWithTheme( @@ -106,7 +106,7 @@ describe('UserDetailsPanel', () => { it("renders the user's 2FA status", async () => { const user = accountUserFactory.build({ tfa_enabled: true }); - const assignedRoles = { account_access: [], resource_access: [] }; + const assignedRoles = { account_access: [], entity_access: [] }; const { getByText } = renderWithTheme( diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.tsx index d080c2d69e1..21f90657f00 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserDetailsPanel.tsx @@ -10,8 +10,8 @@ import { TextTooltip } from 'src/components/TextTooltip'; import type { AccountAccessType, + EntityAccess, IamUserPermissions, - ResourceAccess, RoleType, User, } from '@linode/api-v4'; @@ -152,9 +152,9 @@ export const UserDetailsPanel = ({ assignedRoles, user }: Props) => { const getAssignRoles = (assignedRoles: IamUserPermissions): number => { const accountAccessRoles = assignedRoles.account_access || []; - const resourceAccessRoles = assignedRoles.resource_access - ? assignedRoles.resource_access - .map((resource: ResourceAccess) => resource.roles) + const resourceAccessRoles = assignedRoles.entity_access + ? assignedRoles.entity_access + .map((resource: EntityAccess) => resource.roles) .flat() : []; diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx index 8086b0b1c5a..5b096ac32ff 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx @@ -1,14 +1,15 @@ import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; -import { accountResourcesFactory } from 'src/factories/accountResources'; +import { accountEntityFactory } from 'src/factories/accountEntities'; import { userPermissionsFactory } from 'src/factories/userPermissions'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AssignedEntitiesTable } from '../../Users/UserEntities/AssignedEntitiesTable'; const queryMocks = vi.hoisted(() => ({ - useAccountResources: vi.fn().mockReturnValue({}), + useAccountEntities: vi.fn().mockReturnValue({}), useAccountUserPermissions: vi.fn().mockReturnValue({}), })); @@ -20,14 +21,22 @@ vi.mock('src/queries/iam/iam', async () => { }; }); -vi.mock('src/queries/resources/resources', async () => { - const actual = await vi.importActual('src/queries/resources/resources'); +vi.mock('src/queries/entities/entities', async () => { + const actual = await vi.importActual('src/queries/entities/entities'); return { ...actual, - useAccountResources: queryMocks.useAccountResources, + useAccountEntities: queryMocks.useAccountEntities, }; }); +const mockEntities = [ + accountEntityFactory.build({ + id: 1, + label: 'no_devices', + type: 'firewall', + }), +]; + describe('AssignedEntitiesTable', () => { it('should display no roles text if there are no roles assigned to user', async () => { queryMocks.useAccountUserPermissions.mockReturnValue({ @@ -44,17 +53,17 @@ describe('AssignedEntitiesTable', () => { data: userPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getAllByLabelText, getByText } = renderWithTheme( ); - expect(getByText('firewall-us-123')).toBeInTheDocument(); + expect(getByText('no_devices')).toBeInTheDocument(); expect(getByText('Firewall')).toBeInTheDocument(); - expect(getByText('update_firewall')).toBeInTheDocument(); + expect(getByText('firewall_admin')).toBeInTheDocument(); const actionMenuButton = getAllByLabelText('action menu')[0]; expect(actionMenuButton).toBeInTheDocument(); @@ -69,8 +78,8 @@ describe('AssignedEntitiesTable', () => { data: userPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getByPlaceholderText, getByText } = renderWithTheme( @@ -90,8 +99,8 @@ describe('AssignedEntitiesTable', () => { data: userPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getByPlaceholderText, queryByText } = renderWithTheme( @@ -100,11 +109,11 @@ describe('AssignedEntitiesTable', () => { const searchInput = getByPlaceholderText('Search'); fireEvent.change(searchInput, { - target: { value: 'firewall-us-123' }, + target: { value: 'no_devices' }, }); await waitFor(() => { - expect(queryByText('firewall-us-123')).toBeInTheDocument(); + expect(queryByText('no_devices')).toBeInTheDocument(); }); }); @@ -113,8 +122,8 @@ describe('AssignedEntitiesTable', () => { data: userPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getByPlaceholderText, queryByText } = renderWithTheme( @@ -125,7 +134,7 @@ describe('AssignedEntitiesTable', () => { fireEvent.change(autocomplete, { target: { value: 'Firewalls' } }); await waitFor(() => { - expect(queryByText('firewall-us-123')).toBeInTheDocument(); + expect(queryByText('no_devices')).toBeInTheDocument(); }); }); }); diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx index 21801ed1c6b..821e88a5f38 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx @@ -16,17 +16,21 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { useOrder } from 'src/hooks/useOrder'; +import { useAccountEntities } from 'src/queries/entities/entities'; import { useAccountUserPermissions } from 'src/queries/iam/iam'; -import { useAccountResources } from 'src/queries/resources/resources'; -import { getFilteredRoles, mapEntityTypes } from '../../Shared/utilities'; +import { + getFilteredRoles, + mapEntityTypes, + transformedAccountEntities, +} from '../../Shared/utilities'; import type { EntitiesRole, EntitiesType } from '../../Shared/utilities'; import type { - IamAccountResource, + AccountEntity, + EntityAccess, + EntityType, IamUserPermissions, - Resource, - ResourceAccess, RoleType, } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -48,10 +52,11 @@ export const AssignedEntitiesTable = () => { const [entityType, setEntityType] = React.useState(null); const { - data: resources, - error: resourcesError, - isLoading: resourcesLoading, - } = useAccountResources(); + data: entities, + error: entitiesError, + isLoading: entitiesLoading, + } = useAccountEntities(); + const { data: assignedRoles, error: assignedRolesError, @@ -59,15 +64,17 @@ export const AssignedEntitiesTable = () => { } = useAccountUserPermissions(username ?? ''); const { entityTypes, roles } = React.useMemo(() => { - if (!assignedRoles || !resources) { + if (!assignedRoles || !entities) { return { entityTypes: [], roles: [] }; } + const transformedEntities = transformedAccountEntities(entities.data); + + const roles = addEntityNamesToRoles(assignedRoles, transformedEntities); - const roles = addResourceNamesToRoles(assignedRoles, resources); const entityTypes = getEntityTypes(roles); return { entityTypes, roles }; - }, [assignedRoles, resources]); + }, [assignedRoles, entities]); const actions: Action[] = [ { @@ -85,11 +92,11 @@ export const AssignedEntitiesTable = () => { ]; const renderTableBody = () => { - if (resourcesLoading || assignedRolesLoading) { + if (entitiesLoading || assignedRolesLoading) { return ; } - if (resourcesError || assignedRolesError) { + if (entitiesError || assignedRolesError) { return ( { roles, }); - if (!resources || !assignedRoles || filteredRoles.length === 0) { + if (!entities || !assignedRoles || filteredRoles.length === 0) { return ( ); } - if (assignedRoles && resources) { + if (assignedRoles && entities) { return ( <> {filteredRoles.map((el: EntitiesRole) => ( @@ -120,7 +127,7 @@ export const AssignedEntitiesTable = () => { {el.resource_name} - {capitalize(el.resource_type)} + {capitalize(el.entity_type)} {el.role_name} @@ -217,30 +224,26 @@ export const AssignedEntitiesTable = () => { const getEntityTypes = (data: EntitiesRole[]): EntitiesType[] => mapEntityTypes(data, 's'); -const addResourceNamesToRoles = ( +const addEntityNamesToRoles = ( assignedRoles: IamUserPermissions, - resources: IamAccountResource + entities: Map[]> ): EntitiesRole[] => { - const resourcesRoles = assignedRoles.resource_access; - - const resourcesArray: IamAccountResource[] = Object.values(resources); + const entitiesRoles = assignedRoles.entity_access; - return resourcesRoles.flatMap((resourceRole: ResourceAccess) => { - const resourceByType = resourcesArray.find( - (r: IamAccountResource) => r.resource_type === resourceRole.resource_type - ); + return entitiesRoles.flatMap((entityRole: EntityAccess) => { + const entityByType = entities.get(entityRole.type as EntityType); - if (resourceByType) { - const resource = resourceByType.resources.find( - (res: Resource) => res.id === resourceRole.resource_id + if (entityByType) { + const entity = entityByType.find( + (res: AccountEntity) => res.id === entityRole.id ); - if (resource) { - return resourceRole.roles.map((r: RoleType) => ({ - id: `${r}-${resourceRole.resource_id}`, - resource_id: resourceRole.resource_id, - resource_name: resource.name, - resource_type: resourceRole.resource_type, + if (entity) { + return entityRole.roles.map((r: RoleType) => ({ + entity_type: entityRole.type, + id: `${r}-${entityRole.id}`, + resource_id: entityRole.id, + resource_name: entity.label, role_name: r, })); } @@ -253,6 +256,6 @@ const addResourceNamesToRoles = ( const getSearchableFields = (role: EntitiesRole): string[] => [ String(role.resource_id), role.resource_name, - role.resource_type, + role.entity_type, role.role_name, ]; diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx index 75d8db02052..3bc9e68864e 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx @@ -1,14 +1,18 @@ -import { Autocomplete, Drawer, Typography } from '@linode/ui'; +import { ActionsPanel, Drawer, Typography } from '@linode/ui'; +import { useTheme } from '@mui/material'; import React from 'react'; +import { FormProvider, useFieldArray, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; +import { LinkButton } from 'src/components/LinkButton'; import { NotFound } from 'src/components/NotFound'; +import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; +import { AssignSingleRole } from 'src/features/IAM/Users/UserRoles/AssignSingleRole'; import { useAccountPermissions } from 'src/queries/iam/iam'; -import { AssignedPermissionsPanel } from '../../Shared/AssignedPermissionsPanel/AssignedPermissionsPanel'; -import { getAllRoles, getRoleByName } from '../../Shared/utilities'; +import { getAllRoles } from '../../Shared/utilities'; -import type { RolesType } from '../../Shared/utilities'; +import type { AssignNewRoleFormValues } from '../../Shared/utilities'; interface Props { onClose: () => void; @@ -16,31 +20,44 @@ interface Props { } export const AssignNewRoleDrawer = ({ onClose, open }: Props) => { - const [ - selectedOptions, - setSelectedOptions, - ] = React.useState(null); + const theme = useTheme(); - const { - data: accountPermissions, - isLoading: accountPermissionsLoading, - } = useAccountPermissions(); + const { data: accountPermissions } = useAccountPermissions(); + + const form = useForm({ + defaultValues: { + roles: [{ role: null }], + }, + }); + + const { control, handleSubmit, reset, watch } = form; + const { append, fields, remove } = useFieldArray({ + control, + name: 'roles', + }); + + // to watch changes to this value since we're conditionally rendering "Add another role" + const roles = watch('roles'); const allRoles = React.useMemo(() => { if (!accountPermissions) { return []; } - return getAllRoles(accountPermissions); }, [accountPermissions]); - // Get the selected role based on the `selectedOptions` - const selectedRole = React.useMemo(() => { - if (!selectedOptions || !accountPermissions) { - return null; - } - return getRoleByName(accountPermissions, selectedOptions.value); - }, [selectedOptions, accountPermissions]); + const onSubmit = handleSubmit(async (values: AssignNewRoleFormValues) => { + // TODO - make this really do something apart from console logging - UIE-8590 + + // const selectedRoles = values.roles.map((r) => r.role).filter(Boolean); + handleClose(); + }); + + const handleClose = () => { + reset(); + + onClose(); + }; // TODO - add a link 'Learn more" - UIE-8534 return ( @@ -50,31 +67,49 @@ export const AssignNewRoleDrawer = ({ onClose, open }: Props) => { open={open} title="Assign New Roles" > - - Select a role you want to assign to a user. Some roles require selecting - resources they should apply to. Configure the first role and continue - adding roles or save the assignment. - Learn more about roles and permissions. - - - ( -
  • - {option.label} -
  • - )} - label="Assign New Roles" - loading={accountPermissionsLoading} - onChange={(_, value) => setSelectedOptions(value)} - options={allRoles} - placeholder="Select a Role" - textFieldProps={{ hideLabel: true, noMarginTop: true }} - value={selectedOptions} - /> - - {selectedRole && ( - - )} + {' '} + +
    + + Select a role you want to assign to a user. Some roles require + selecting resources they should apply to. Configure the first role + and continue adding roles or save the assignment. + Learn more about roles and permissions. + + + {!!accountPermissions && + fields.map((field, index) => ( + remove(index)} + options={allRoles} + permissions={accountPermissions} + /> + ))} + + {/* If all roles are filled, allow them to add another */} + {roles.length > 0 && roles.every((field) => field.role) && ( + + append({ role: null })}> + Add another role + + + )} + + +
    ); }; diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx new file mode 100644 index 00000000000..2b82c0e3882 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx @@ -0,0 +1,83 @@ +import { Autocomplete, Button } from '@linode/ui'; +import Close from '@mui/icons-material/Close'; +import { Divider, useTheme } from '@mui/material'; +import Box from '@mui/material/Box'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { AssignedPermissionsPanel } from 'src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel'; +import { getRoleByName } from 'src/features/IAM/Shared/utilities'; + +import type { IamAccountPermissions } from '@linode/api-v4'; +import type { + AssignNewRoleFormValues, + RolesType, +} from 'src/features/IAM/Shared/utilities'; + +interface Props { + index: number; + onRemove: (idx: number) => void; + options: RolesType[]; + permissions: IamAccountPermissions; +} + +export const AssignSingleRole = ({ + index, + onRemove, + options, + permissions, +}: Props) => { + const theme = useTheme(); + + const { control } = useFormContext(); + + return ( + + + {index !== 0 && ( + + )} + + ( + <> + { + onChange(newValue); + }} + label="Assign New Roles" + options={options} + placeholder="Select a Role" + textFieldProps={{ hideLabel: true }} + value={value || null} + /> + {value && ( + + )} + + )} + control={control} + name={`roles.${index}.role`} + /> + + + + + + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 94cbb7fdbce..a31a94e2d51 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -54,7 +54,10 @@ export const AssignedEntities = ({ > ({ + useAccountEntities: vi.fn().mockReturnValue({}), useAccountPermissions: vi.fn().mockReturnValue({}), - useAccountResources: vi.fn().mockReturnValue({}), useAccountUserPermissions: vi.fn().mockReturnValue({}), })); @@ -24,11 +32,11 @@ vi.mock('src/queries/iam/iam', async () => { }; }); -vi.mock('src/queries/resources/resources', async () => { - const actual = await vi.importActual('src/queries/resources/resources'); +vi.mock('src/queries/entities/entities', async () => { + const actual = await vi.importActual('src/queries/entities/entities'); return { ...actual, - useAccountResources: queryMocks.useAccountResources, + useAccountEntities: queryMocks.useAccountEntities, }; }); @@ -54,8 +62,8 @@ describe('UserRoles', () => { data: accountPermissionsFactory.build(), }); - queryMocks.useAccountResources.mockReturnValue({ - data: accountResourcesFactory.build(), + queryMocks.useAccountEntities.mockReturnValue({ + data: makeResourcePage(mockEntities), }); const { getAllByLabelText, getAllByText, getByText } = renderWithTheme( diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx index 663411c353d..cf584fab3d9 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx @@ -1,3 +1,4 @@ +import { useProfile } from '@linode/queries'; import { Box, Chip, Stack, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; @@ -10,7 +11,6 @@ import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useProfile } from '@linode/queries'; import { UsersActionMenu } from './UsersActionMenu'; diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx index 5c67f4c31c0..7005156946f 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -1,13 +1,9 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; 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 { imageFactory, linodeDiskFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 7c36bbf4345..1f4d975f7b8 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -1,4 +1,11 @@ import { yupResolver } from '@hookform/resolvers/yup'; +import { + useAllLinodeDisksQuery, + useLinodeQuery, + useGrants, + useRegionsQuery, +} from '@linode/queries'; +import { LinodeSelect } from '@linode/shared'; import { Autocomplete, Box, @@ -20,17 +27,10 @@ import { Controller, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useCreateImageMutation } from 'src/queries/images'; -import { - useAllLinodeDisksQuery, - useLinodeQuery, - useGrants, - useRegionsQuery, -} from '@linode/queries'; import type { CreateImagePayload } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index 46a2794842b..20e7bedc8cf 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -5,6 +5,7 @@ import { useProfile, useRegionsQuery, } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { ActionsPanel, Box, @@ -59,6 +60,10 @@ export const ImageUpload = () => { const dispatch = useDispatch(); const hasPendingUpload = usePendingUpload(); const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const [uploadProgress, setUploadProgress] = useState(); const cancelRef = React.useRef<(() => void) | null>(null); @@ -268,6 +273,7 @@ export const ImageUpload = () => { disableClearable errorText={fieldState.error?.message} ignoreAccountAvailability + isGeckoLAEnabled={isGeckoLAEnabled} label="Region" onChange={(e, region) => field.onChange(region.id)} regionFilter="core" // Images service will not be supported for Gecko Beta diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx index da58325b963..21c0167d16f 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx @@ -1,7 +1,7 @@ +import { regionFactory } from '@linode/utilities'; 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'; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx index 0d6f2124885..0a87e5d6899 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx @@ -1,7 +1,8 @@ +import { regionFactory } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { imageFactory, regionFactory } from 'src/factories'; +import { imageFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx index 383d14dfdda..fbc8791bdff 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx @@ -1,3 +1,5 @@ +import { useRegionsQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { ActionsPanel, Notice, Paper, Stack, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import React from 'react'; @@ -5,8 +7,8 @@ import { useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; +import { useFlags } from 'src/hooks/useFlags'; import { useUpdateImageRegionsMutation } from 'src/queries/images'; -import { useRegionsQuery } from '@linode/queries'; import { ImageRegionRow } from './ImageRegionRow'; @@ -16,8 +18,8 @@ import type { Region, UpdateImageRegionsPayload, } from '@linode/api-v4'; +import type { DisableItemOption } from '@linode/ui'; import type { Resolver } from 'react-hook-form'; -import type { DisableItemOption } from 'src/components/ListItemOption'; interface Props { image: Image | undefined; @@ -31,6 +33,12 @@ interface Context { export const ManageImageReplicasForm = (props: Props) => { const { image, onClose } = props; + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + const imageRegionIds = image?.regions.map(({ region }) => region) ?? []; const { enqueueSnackbar } = useSnackbar(); @@ -116,6 +124,7 @@ export const ManageImageReplicasForm = (props: Props) => { disabledRegions={disabledRegions} errorText={errors.regions?.message} ignoreAccountAvailability // Ignore the account capability because we are just using "Object Storage" for region compatibility + isGeckoLAEnabled={isGeckoLAEnabled} label="Add Regions" placeholder="Select regions or type to search" regions={regions?.filter((r) => r.site_type === 'core') ?? []} diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx index d809685e05c..e9a07149a7e 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx @@ -1,7 +1,8 @@ +import { linodeFactory } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { imageFactory, linodeFactory } from 'src/factories'; +import { imageFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx index 60db38ca5b8..5bac747db58 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -1,3 +1,4 @@ +import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, Divider, Drawer, Notice, Stack } from '@linode/ui'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -7,7 +8,6 @@ import { useHistory } from 'react-router-dom'; import { DescriptionList } from 'src/components/DescriptionList/DescriptionList'; import { NotFound } from 'src/components/NotFound'; import { REBUILD_LINODE_IMAGE_PARAM_NAME } from 'src/features/Linodes/LinodesDetail/LinodeRebuild/utils'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { useImageAndLinodeGrantCheck } from '../utils'; diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index eba0b26ca23..875078cb129 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -1,4 +1,6 @@ -import { eventFactory, imageFactory, linodeFactory } from 'src/factories'; +import { linodeFactory } from '@linode/utilities'; + +import { eventFactory, imageFactory } from 'src/factories'; import { getEventsForImages, getImageLabelForLinode } from './utils'; diff --git a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx index d8ba2d4ea45..a79bcc91d48 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx @@ -1,7 +1,8 @@ +import { regionFactory } from '@linode/utilities'; import { render } from '@testing-library/react'; import * as React from 'react'; -import { kubernetesClusterFactory, regionFactory } from 'src/factories'; +import { kubernetesClusterFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { wrapWithTableBody, wrapWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx new file mode 100644 index 00000000000..232b5e30cba --- /dev/null +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx @@ -0,0 +1,14 @@ +import { Stack, Typography } from '@linode/ui'; +import React from 'react'; + +export const ClusterNetworkingPanel = () => { + return ( + + VPC & Firewall + + A VPC and Firewall are automatically generated for LKE Enterprise + customers. + + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTierPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTierPanel.tsx index cae4ce58950..6a0db68e26c 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTierPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterTierPanel.tsx @@ -1,10 +1,10 @@ +import { useAccount } from '@linode/queries'; import { Stack, Typography } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; import React from 'react'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; -import { useAccount } from '@linode/queries'; import { CLUSTER_TIER_DOCS_LINK } from '../constants'; import { diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 3fbb9cc1bd2..7f36ee0609a 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -3,6 +3,7 @@ import { useMutateAccountAgreements, useRegionsQuery, } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Autocomplete, Box, @@ -36,6 +37,7 @@ import { useIsLkeEnterpriseEnabled, useLkeStandardOrEnterpriseVersions, } from 'src/features/Kubernetes/kubeUtils'; +import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useCreateKubernetesClusterBetaMutation, @@ -57,6 +59,7 @@ import { reportAgreementSigningError } from 'src/utilities/reportAgreementSignin import { CLUSTER_VERSIONS_DOCS_LINK } from '../constants'; import KubeCheckoutBar from '../KubeCheckoutBar'; import { ApplicationPlatform } from './ApplicationPlatform'; +import { ClusterNetworkingPanel } from './ClusterNetworkingPanel'; import { ClusterTierPanel } from './ClusterTierPanel'; import { ControlPlaneACLPane } from './ControlPlaneACLPane'; import { @@ -78,6 +81,11 @@ import type { APIError } from '@linode/api-v4/lib/types'; import type { ExtendedIP } from 'src/utilities/ipUtils'; export const CreateCluster = () => { + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const { classes } = useStyles(); const [selectedRegion, setSelectedRegion] = React.useState< Region | undefined @@ -399,7 +407,7 @@ export const CreateCluster = () => { /> {isLkeEnterpriseLAFlagEnabled && ( <> - + { disableClearable disabled={isCreateClusterRestricted} errorText={errorMap.region} + isGeckoLAEnabled={isGeckoLAEnabled} onChange={(e, region) => setSelectedRegion(region)} regions={regionsData} value={selectedRegion?.id} @@ -485,7 +494,12 @@ export const CreateCluster = () => { )} - + {showHighAvailability && selectedTier !== 'enterprise' && ( { /> )} + {selectedTier === 'enterprise' && } {showControlPlaneACL && ( <> - {selectedTier !== 'enterprise' && } + { setIPv4Addr(newIpV4Addr); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx index 4365416e97a..8d96b91b3e9 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx @@ -1,10 +1,11 @@ import { useRegionsQuery } from '@linode/queries'; import { CircleProgress, ErrorState } from '@linode/ui'; +import { doesRegionSupportFeature } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; import { useIsAcceleratedPlansEnabled } from 'src/features/components/PlansPanel/utils'; -import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { extendType } from 'src/utilities/extendType'; import { @@ -72,6 +73,8 @@ const Panel = (props: NodePoolPanelProps) => { types, } = props; + const flags = useFlags(); + const { isAcceleratedLKEPlansEnabled } = useIsAcceleratedPlansEnabled(); const regions = useRegionsQuery().data ?? []; @@ -114,7 +117,8 @@ const Panel = (props: NodePoolPanelProps) => { if (selectedTier === 'enterprise') { return `${ADD_NODE_POOLS_ENTERPRISE_DESCRIPTION} ${ADD_NODE_POOLS_NO_ENCRYPTION_DESCRIPTION}`; } - return regionSupportsDiskEncryption + // @TODO LDE: once LDE has been fully rolled out and is in GA in all regions, remove the feature flag condition + return regionSupportsDiskEncryption && flags.linodeDiskEncryption ? `${ADD_NODE_POOLS_DESCRIPTION} ${ADD_NODE_POOLS_ENCRYPTION_DESCRIPTION}` : ADD_NODE_POOLS_DESCRIPTION; }; diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx index 632b192b026..c8907f87587 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx @@ -1,6 +1,7 @@ +import { regionFactory } from '@linode/utilities'; import * as React from 'react'; -import { regionFactory, typeFactory } from 'src/factories'; +import { typeFactory } from 'src/factories'; import { nodePoolFactory } from 'src/factories/kubernetesCluster'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE } from 'src/utilities/pricing/constants'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.test.tsx index d169d0cb110..42a4abfb234 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.test.tsx @@ -28,8 +28,8 @@ const queryMocks = vi.hoisted(() => ({ data: { acl: { addresses: { - ipv4: [''], - ipv6: [''], + ipv4: [], + ipv6: [], }, enabled: false, 'revision-id': '', @@ -49,9 +49,12 @@ vi.mock('src/queries/kubernetes', async () => { describe('KubeControlPaneACLDrawer', () => { it('renders the drawer as expected when the cluster is migrated', async () => { - const { getAllByText, getByText, queryByText } = renderWithTheme( - - ); + const { + getAllByTestId, + getAllByText, + getByText, + queryByText, + } = renderWithTheme(); expect(getByText('Control Plane ACL for Test')).toBeVisible(); expect(getByText(ACL_DRAWER_STANDARD_TIER_ACL_COPY)).toBeVisible(); @@ -83,6 +86,13 @@ describe('KubeControlPaneACLDrawer', () => { expect(getByText('IPv6 Addresses or CIDRs')).toBeVisible(); expect(getByText('Add IPv6 Address')).toBeVisible(); + // Confirm text input is disabled when ACL is disabled + const inputs = getAllByTestId('textfield-input'); + expect(inputs).toHaveLength(3); + inputs.forEach((input) => { + expect(input).toBeDisabled(); + }); + // Confirm notice does not display expect( queryByText( @@ -91,6 +101,21 @@ describe('KubeControlPaneACLDrawer', () => { ).not.toBeInTheDocument(); }); + it('confirms the revision ID and IP address fields are enabled when ACL is enabled', async () => { + const { getAllByTestId, getByText } = renderWithTheme( + + ); + + const toggle = getByText('Enable Control Plane ACL'); + await userEvent.click(toggle); + + const inputs = getAllByTestId('textfield-input'); + expect(inputs).toHaveLength(3); + inputs.forEach((input) => { + expect(input).toBeEnabled(); + }); + }); + it('shows a notice and hides revision ID if cluster is not migrated', () => { const { getByText, queryByText } = renderWithTheme( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx index 875e16026b8..4e0a40985c9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx @@ -87,6 +87,7 @@ export const KubeControlPlaneACLDrawer = ( handleSubmit, reset, setError, + setValue, watch, } = useForm({ defaultValues: aclData, @@ -99,8 +100,12 @@ export const KubeControlPlaneACLDrawer = ( values: { acl: { addresses: { - ipv4: aclPayload?.addresses?.ipv4 ?? [''], - ipv6: aclPayload?.addresses?.ipv6 ?? [''], + ipv4: aclPayload?.addresses?.ipv4?.length + ? aclPayload?.addresses?.ipv4 + : [''], + ipv6: aclPayload?.addresses?.ipv6?.length + ? aclPayload?.addresses?.ipv6 + : [''], }, enabled: aclPayload?.enabled ?? false, 'revision-id': aclPayload?.['revision-id'] ?? '', @@ -150,12 +155,12 @@ export const KubeControlPlaneACLDrawer = ( acl: { enabled: acl.enabled, 'revision-id': acl['revision-id'], - ...((ipv4.length > 0 || ipv6.length > 0) && { + ...{ addresses: { - ...(ipv4.length > 0 && { ipv4 }), - ...(ipv6.length > 0 && { ipv6 }), + ipv4, + ipv6, }, - }), + }, }, }; @@ -233,10 +238,37 @@ export const KubeControlPlaneACLDrawer = ( checked={ isEnterpriseCluster ? true : field.value ?? false } + onChange={() => { + setValue('acl.enabled', !field.value, { + shouldDirty: true, + }); + // Disabling ACL should clear the revision-id and any addresses (see LKE-6205). + if (!acl.enabled) { + setValue('acl.revision-id', ''); + setValue('acl.addresses.ipv6', ['']); + setValue('acl.addresses.ipv4', ['']); + } else { + setValue( + 'acl.revision-id', + aclPayload?.['revision-id'] + ); + setValue( + 'acl.addresses.ipv6', + aclPayload?.addresses?.ipv6?.length + ? aclPayload?.addresses?.ipv6 + : [''] + ); + setValue( + 'acl.addresses.ipv4', + aclPayload?.addresses?.ipv4?.length + ? aclPayload?.addresses?.ipv4 + : [''] + ); + } + }} disabled={isEnterpriseCluster} name="ipacl-checkbox" onBlur={field.onBlur} - onChange={field.onChange} /> } label="Enable Control Plane ACL" @@ -260,6 +292,7 @@ export const KubeControlPlaneACLDrawer = ( ( ( ( { const location = useLocation(); const { showAPL } = useAPLAvailability(); - const { data: cluster, error, isLoading } = useKubernetesClusterQuery(id); + const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); + + const { data: cluster, error, isLoading } = useKubernetesClusterQuery({ + id, + isUsingBetaEndpoint, + }); const { data: regionsData } = useRegionsQuery(); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx index 6b581166619..e0b54231505 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx @@ -1,3 +1,4 @@ +import { usePreferences } from '@linode/queries'; import { Box, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; @@ -9,7 +10,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { transitionText } from 'src/features/Linodes/transitions'; import { useInProgressEvents } from 'src/queries/events/events'; -import { usePreferences } from '@linode/queries'; import NodeActionMenu from './NodeActionMenu'; 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 a6610035774..6dd7533fcab 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -1,8 +1,8 @@ +import { linodeFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import * as React from 'react'; import { kubeLinodeFactory } from 'src/factories/kubernetesCluster'; -import { linodeFactory } from 'src/factories/linodes'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx index 158d5b0fecd..0678faa9436 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx @@ -1,3 +1,4 @@ +import { useProfile } from '@linode/queries'; import { CircleProgress, ErrorState, Typography } from '@linode/ui'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; @@ -23,11 +24,11 @@ import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay' import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; -import { useProfile } from '@linode/queries'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { KubernetesClusterRow } from '../ClusterList/KubernetesClusterRow'; import { DeleteKubernetesClusterDialog } from '../KubernetesClusterDetail/DeleteKubernetesClusterDialog'; +import { useKubernetesBetaEndpoint } from '../kubeUtils'; import UpgradeVersionModal from '../UpgradeVersionModal'; import { KubernetesEmptyState } from './KubernetesLandingEmptyState'; @@ -98,14 +99,16 @@ export const KubernetesLanding = () => { const isRestricted = profile?.restricted ?? false; - const { data, error, isLoading } = useKubernetesClustersQuery( - { + const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); + const { data, error, isLoading } = useKubernetesClustersQuery({ + enabled: !isRestricted, + filter, + params: { page: pagination.page, page_size: pagination.pageSize, }, - filter, - !isRestricted - ); + isUsingBetaEndpoint, + }); const { isDiskEncryptionFeatureEnabled, diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index 9ea22c18369..7bfcbbded8d 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -1,3 +1,4 @@ +import { linodeTypeFactory } from '@linode/utilities'; import { renderHook } from '@testing-library/react'; import { @@ -5,7 +6,6 @@ import { kubeLinodeFactory, kubernetesEnterpriseTierVersionFactory, kubernetesVersionFactory, - linodeTypeFactory, nodePoolFactory, } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index 446ce46b54b..bdec58da941 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -266,3 +266,14 @@ export const useLkeStandardOrEnterpriseVersions = ( versionsError: enterpriseTierVersionsError || versionsError, }; }; + +export const useKubernetesBetaEndpoint = () => { + const { isLoading: isAPLAvailabilityLoading, showAPL } = useAPLAvailability(); + const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); + const isUsingBetaEndpoint = showAPL || isLkeEnterpriseLAFeatureEnabled; + + return { + isAPLAvailabilityLoading, + isUsingBetaEndpoint, + }; +}; diff --git a/packages/manager/src/features/Linodes/AccessTable.test.tsx b/packages/manager/src/features/Linodes/AccessTable.test.tsx index 92f564fb722..be440b75923 100644 --- a/packages/manager/src/features/Linodes/AccessTable.test.tsx +++ b/packages/manager/src/features/Linodes/AccessTable.test.tsx @@ -1,7 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { linodeFactory } from 'src/factories'; import { PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIPAddressesTooltip'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/CloneLanding/Details.tsx b/packages/manager/src/features/Linodes/CloneLanding/Details.tsx index b39c9e304b9..4c37df080cb 100644 --- a/packages/manager/src/features/Linodes/CloneLanding/Details.tsx +++ b/packages/manager/src/features/Linodes/CloneLanding/Details.tsx @@ -1,3 +1,5 @@ +import { useRegionsQuery } from '@linode/queries'; +import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, Button, @@ -13,8 +15,6 @@ import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { useRegionsQuery } from '@linode/queries'; import { StyledButton, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.test.tsx index 2199037af5e..b7a738f560f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.test.tsx @@ -1,6 +1,6 @@ +import { regionFactory } from '@linode/utilities'; import React from 'react'; -import { regionFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.test.tsx index 227568b9ce6..537aa716d70 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.test.tsx @@ -1,11 +1,8 @@ +import { regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import React from 'react'; -import { - accountSettingsFactory, - profileFactory, - regionFactory, -} from 'src/factories'; +import { accountSettingsFactory, profileFactory } from 'src/factories'; import { grantsFactory } from 'src/factories/grants'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.test.tsx index 9f5ba35f0c9..a57756aee8a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.test.tsx @@ -1,7 +1,8 @@ +import { regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import React from 'react'; -import { profileFactory, regionFactory } from 'src/factories'; +import { profileFactory } from 'src/factories'; import { grantsFactory } from 'src/factories/grants'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/IntegrationsTabPanel.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/IntegrationsTabPanel.test.tsx index b62fdd695a5..9e69e382c51 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/IntegrationsTabPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/IntegrationsTabPanel.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; +import { Tabs } from 'src/components/Tabs/Tabs'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { gettingStartedGuides as ansibleResources } from './AnsibleIntegrationResources'; @@ -30,13 +31,21 @@ vi.mock('@reach/tabs', async () => { describe('IntegrationsTabPanel', () => { it('Should render IntegrationsTabPanel', () => { - renderWithTheme(); + renderWithTheme( + + + + ); expect( screen.getByPlaceholderText('Select Integration') ).toBeInTheDocument(); }); it('Should update the state correctly and render relevant resources when Ansible is selected', async () => { - renderWithTheme(); + renderWithTheme( + + + + ); // Check initial value of the Inegrations field expect(screen.getByPlaceholderText('Select Integration')).toHaveValue(''); @@ -69,7 +78,11 @@ describe('IntegrationsTabPanel', () => { }); }); it('Should update the state correctly and render relevant resources when Terraform is selected', async () => { - renderWithTheme(); + renderWithTheme( + + + + ); // Check initial value of the Inegrations field expect(screen.getByPlaceholderText('Select Integration')).toHaveValue(''); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/SDKTabPanel.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/SDKTabPanel.test.tsx index 036a8726bdd..01daab3a62f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/SDKTabPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/SDKTabPanel.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; +import { Tabs } from 'src/components/Tabs/Tabs'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { gettingStartedGuides as goResources } from './GoSDKResources'; @@ -30,11 +31,19 @@ vi.mock('@reach/tabs', async () => { describe('SDKTabPanel', () => { it('Should render SDKTabPanel', () => { - renderWithTheme(); + renderWithTheme( + + + + ); expect(screen.getByPlaceholderText('Select An SDK')).toBeInTheDocument(); }); it('Should update the state correctly and render relevant resources when Go is selected', async () => { - renderWithTheme(); + renderWithTheme( + + + + ); // Check initial value of the SDK field expect(screen.getByPlaceholderText('Select An SDK')).toHaveValue(''); @@ -67,7 +76,11 @@ describe('SDKTabPanel', () => { }); }); it('Should update the state correctly and render relevant resources when Python is selected', async () => { - renderWithTheme(); + renderWithTheme( + + + + ); // Check initial value of the SDK field expect(screen.getByPlaceholderText('Select An SDK')).toHaveValue(''); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Details/PlacementGroupPanel.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Details/PlacementGroupPanel.test.tsx index db7616cbed8..bd499104896 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Details/PlacementGroupPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Details/PlacementGroupPanel.test.tsx @@ -1,6 +1,6 @@ +import { regionFactory } from '@linode/utilities'; import React from 'react'; -import { regionFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.test.tsx index fd4f89a0519..a9d01a7bc80 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.test.tsx @@ -1,6 +1,7 @@ +import { regionFactory } from '@linode/utilities'; import React from 'react'; -import { accountAgreementsFactory, regionFactory } from 'src/factories'; +import { accountAgreementsFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx index 1bf9cd7df3a..2b03e445eab 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, Box, Paper, Stack, Typography } from '@linode/ui'; +import { Box, Paper, Stack, Typography } from '@linode/ui'; import React, { useState } from 'react'; import { useController, useFormContext } from 'react-hook-form'; @@ -7,11 +7,11 @@ import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/Ge import { Link } from 'src/components/Link'; import { LinkButton } from 'src/components/LinkButton'; import { FIREWALL_GET_STARTED_LINK } from 'src/constants'; +import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; import { CreateFirewallDrawer } from 'src/features/Firewalls/FirewallLanding/CreateFirewallDrawer'; import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; -import { useAllFirewallsQuery } from '@linode/queries'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { useLinodeCreateQueryParams } from './utilities'; @@ -27,8 +27,6 @@ export const Firewall = () => { 'firewall_id' >({ name: 'firewall_id' }); - const { data: firewalls, error, isLoading } = useAllFirewallsQuery(); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = React.useState(false); @@ -44,9 +42,6 @@ export const Firewall = () => { globalGrantType: 'add_linodes', }); - const selectedFirewall = - firewalls?.find((firewall) => firewall.id === field.value) ?? null; - const onChange = (firewallId: number | undefined) => { if (firewallId !== undefined) { clearErrors('firewallOverride'); @@ -98,7 +93,7 @@ export const Firewall = () => { /> )} - { onChange(firewall?.id); if (!firewall?.id) { @@ -118,14 +113,11 @@ export const Firewall = () => { } }} disabled={isLinodeCreateRestricted} - errorText={fieldState.error?.message ?? error?.[0].reason} + errorText={fieldState.error?.message} label="Assign Firewall" - loading={isLoading} - noMarginTop onBlur={field.onBlur} - options={firewalls ?? []} placeholder="None" - value={selectedFirewall} + value={field.value} /> { name: 'firewall_id', }); - const { data: firewalls, error, isLoading } = useAllFirewallsQuery(); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', }); - const selectedFirewall = - firewalls?.find((firewall) => firewall.id === field.value) ?? null; - return ( - field.onChange(firewall?.id ?? null)} - options={firewalls ?? []} placeholder="None" - value={selectedFirewall} + value={field.value} /> { name: `linodeInterfaces.${index}.firewall_id`, }); - const { data: firewalls, error, isLoading } = useAllFirewallsQuery(); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', }); - const selectedFirewall = - firewalls?.find((firewall) => firewall.id === field.value) ?? null; - return ( - field.onChange(firewall?.id ?? null)} - options={firewalls ?? []} placeholder="None" - value={selectedFirewall} + value={field.value} /> { - const { control } = useFormContext(); + const { + control, + setValue, + getFieldState, + } = useFormContext(); + + const { data: firewallSettings } = useFirewallSettingsQuery(); const { field } = useController({ control, @@ -24,8 +34,26 @@ export const InterfaceType = ({ index }: Props) => { Network Connection { + // Change the interface purpose (Public, VPC, VLAN) + field.onChange(value); + + const defaultFirewall = getDefaultFirewallForInterfacePurpose( + value as InterfacePurpose, + firewallSettings + ); + + // Set the Firewall based on defaults if: + // - there is a default firewall for this interface type + // - the user has not touched the Firewall field + if ( + defaultFirewall && + !getFieldState(`linodeInterfaces.${index}.firewall_id`).isTouched + ) { + setValue(`linodeInterfaces.${index}.firewall_id`, defaultFirewall); + } + }} aria-labelledby="network-interface" - onChange={field.onChange} row sx={{ mb: '0px !important' }} value={field.value} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Networking.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Networking.tsx index 9ecbf859fd3..bd1ebda2ab0 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Networking.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Networking.tsx @@ -1,3 +1,4 @@ +import { useFirewallSettingsQuery } from '@linode/queries'; import { Button, Divider, @@ -13,6 +14,7 @@ import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; import { Firewall } from './Firewall'; import { InterfaceGeneration } from './InterfaceGeneration'; import { LinodeInterface } from './LinodeInterface'; +import { getDefaultInterfacePayload } from './utilities'; import type { LinodeCreateFormValues } from '../utilities'; @@ -22,6 +24,8 @@ export const Networking = () => { formState: { errors }, } = useFormContext(); + const { data: firewallSettings } = useFirewallSettingsQuery(); + const { append, fields, remove } = useFieldArray({ control, name: 'linodeInterfaces', @@ -42,16 +46,9 @@ export const Networking = () => { > Networking
    +
    Address @@ -235,7 +240,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { {...ipDisplay} {...handlers} isVPCOnlyLinode={ - isVPCOnlyLinode && ipDisplay.type === 'IPv4 – Public' + isVPCOnlyLinode && ipDisplay.type === 'Public – IPv4' } key={`${ipDisplay.address}-${ipDisplay.type}`} linodeId={linodeID} @@ -339,7 +344,7 @@ export const vpcConfigInterfaceToDisplayRows = ( if (ipv4?.vpc) { ipDisplay.push({ address: ipv4.vpc, - type: 'IPv4 – VPC', + type: 'VPC – IPv4', ...emptyProps, }); } @@ -347,7 +352,7 @@ export const vpcConfigInterfaceToDisplayRows = ( if (ipv4?.nat_1_1) { ipDisplay.push({ address: ipv4.nat_1_1, - type: 'VPC IPv4 – NAT', + type: 'VPC NAT – IPv4', ...emptyProps, }); } @@ -356,7 +361,7 @@ export const vpcConfigInterfaceToDisplayRows = ( ip_ranges.forEach((ip_range) => { ipDisplay.push({ address: ip_range, - type: 'IPv4 – VPC – Range', + type: 'VPC – Range – IPv4', ...emptyProps, }); }); @@ -423,7 +428,7 @@ export const ipResponseToDisplayRows = ( gateway: '', rdns: '', subnetMask: '', - type: 'IPv6 – Range' as IPDisplay['type'], + type: 'Range – IPv6' as IPDisplay['type'], }; }) ); @@ -455,16 +460,13 @@ const ipToDisplay = (ip: IPAddress, key: ipKey): IPDisplay => { }; export const createType = (ip: IPAddress, key: ipKey) => { - let type = ''; - type += ip.type === 'ipv4' ? 'IPv4' : 'IPv6'; - - type += ' – '; + if (key === 'Reserved' && ip.type === 'ipv4') { + return ip.public ? 'Reserved IPv4 (public)' : 'Reserved IPv4 (private)'; + } - if (key === 'Reserved') { - type += ip.public ? 'Reserved (public)' : 'Reserved (private)'; - } else { - type += key; + if (key === 'SLAAC') { + return 'Public – IPv6 – SLAAC'; } - return type; + return `${key} – ${ip.type === 'ipv4' ? 'IPv4' : 'IPv6'}`; }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceDrawer.tsx index 87b03bf1461..d6e9d13d3bb 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceDrawer.tsx @@ -9,10 +9,11 @@ interface Props { linodeId: number; onClose: () => void; open: boolean; + regionId: string; } export const AddInterfaceDrawer = (props: Props) => { - const { linodeId, onClose, open } = props; + const { linodeId, onClose, open, regionId } = props; return ( { open={open} title="Add Network Interface" > - + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx index 698f7023443..70473642500 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx @@ -12,16 +12,18 @@ import { InterfaceFirewall } from './InterfaceFirewall'; import { InterfaceType } from './InterfaceType'; import { CreateLinodeInterfaceFormSchema } from './utilities'; import { VLANInterface } from './VLANInterface'; +import { VPCInterface } from './VPCInterface'; import type { CreateInterfaceFormValues } from './utilities'; interface Props { linodeId: number; onClose: () => void; + regionId: string; } export const AddInterfaceForm = (props: Props) => { - const { linodeId, onClose } = props; + const { linodeId, onClose, regionId } = props; const { enqueueSnackbar } = useSnackbar(); const { mutateAsync } = useCreateLinodeInterfaceMutation(linodeId); @@ -76,6 +78,9 @@ export const AddInterfaceForm = (props: Props) => { )} {selectedInterfacePurpose === 'vlan' && } + {selectedInterfacePurpose === 'vpc' && ( + + )} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceFirewall.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceFirewall.tsx index 95e370035df..e697993df09 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceFirewall.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceFirewall.tsx @@ -1,31 +1,25 @@ -import { useAllFirewallsQuery } from '@linode/queries'; -import { Autocomplete } from '@linode/ui'; import React from 'react'; import { useController } from 'react-hook-form'; +import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; + import type { CreateInterfaceFormValues } from './utilities'; export const InterfaceFirewall = () => { - const { field, fieldState } = useController({ + const { field, fieldState } = useController< + CreateInterfaceFormValues, + 'firewall_id' + >({ name: 'firewall_id', }); - const { data: firewalls, error, isLoading } = useAllFirewallsQuery(); - - const selectedFirewall = - firewalls?.find((firewall) => firewall.id === field.value) ?? null; - return ( - field.onChange(firewall?.id ?? null)} - options={firewalls ?? []} placeholder="None" - value={selectedFirewall} + value={field.value} /> ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx index 740b34c1d7f..d549b840c53 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx @@ -1,3 +1,4 @@ +import { firewallQueries } from '@linode/queries'; import { FormControl, FormControlLabel, @@ -5,21 +6,46 @@ import { Radio, RadioGroup, } from '@linode/ui'; +import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; -import { useController } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; + +import { INTERFACE_PURPOSE_TO_DEFAULT_FIREWALL_KEY } from './utilities'; import type { CreateInterfaceFormValues } from './utilities'; +import type { InterfacePurpose } from '@linode/api-v4'; export const InterfaceType = () => { + const queryClient = useQueryClient(); + const { setValue } = useFormContext(); const { field, fieldState } = useController({ name: 'purpose', }); + const onChange = async (value: InterfacePurpose) => { + // Change the selected interface type (Public, VPC, VLAN) + field.onChange(value); + + // Update the form's `firewall_id` based on the defaults + const firewallSettings = await queryClient.ensureQueryData( + firewallQueries.settings + ); + const firewallSettingKey = INTERFACE_PURPOSE_TO_DEFAULT_FIREWALL_KEY[value]; + if (firewallSettingKey) { + setValue( + 'firewall_id', + firewallSettings.default_firewall_ids[firewallSettingKey] + ); + } else { + setValue('firewall_id', null); + } + }; + return ( onChange(value as InterfacePurpose)} sx={{ my: `0 !important` }} value={field.value ?? null} > diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPCInterface.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPCInterface.tsx new file mode 100644 index 00000000000..e6ab426291b --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPCInterface.tsx @@ -0,0 +1,82 @@ +import { useAllVPCsQuery } from '@linode/queries'; +import { Autocomplete, Stack } from '@linode/ui'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import type { CreateInterfaceFormValues } from './utilities'; + +interface Props { + regionId: string; +} + +export const VPCInterface = (props: Props) => { + const { regionId } = props; + const { + control, + resetField, + setValue, + } = useFormContext(); + + const { data: vpcs, error, isLoading } = useAllVPCsQuery({ + filter: { region: regionId }, + }); + + const [vpcId] = useWatch({ control, name: ['vpc.vpc_id'] }); + + const selectedVPC = vpcs?.find((vpc) => vpc.id === vpcId) ?? null; + + return ( + + ( + { + field.onChange(vpc?.id ?? null); + + if (vpc && vpc.subnets.length === 1) { + // If the user selectes a VPC and the VPC only has one subnet, + // preselect that subnet for the user. + setValue('vpc.subnet_id', vpc.subnets[0].id, { + shouldValidate: true, + }); + } else { + // Otherwise, just clear the selected subnet + resetField('vpc.subnet_id'); + } + }} + errorText={fieldState.error?.message ?? error?.[0].reason} + label="VPC" + loading={isLoading} + noMarginTop + onBlur={field.onBlur} + options={vpcs ?? []} + placeholder="Select a VPC" + value={selectedVPC} + /> + )} + control={control} + name="vpc.vpc_id" + /> + ( + s.id === field.value) ?? null + } + disabled={!selectedVPC} + errorText={fieldState.error?.message} + label="Subnet" + loading={isLoading} + noMarginTop + onBlur={field.onBlur} + onChange={(e, subnet) => field.onChange(subnet?.id ?? null)} + options={selectedVPC?.subnets ?? []} + placeholder="Select a Subnet" + /> + )} + control={control} + name="vpc.subnet_id" + /> + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts index 8ef16196c63..a3be2fa7ae2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts @@ -1,7 +1,9 @@ +import { InterfacePurpose } from '@linode/api-v4'; import { CreateLinodeInterfaceSchema, CreateVPCInterfaceSchema, } from '@linode/validation'; +import { FirewallDefaultEntity } from 'src/features/Firewalls/components/FirewallSelectOption.utils'; import { number, object, string } from 'yup'; import type { InferType } from 'yup'; @@ -23,3 +25,12 @@ export const CreateLinodeInterfaceFormSchema = CreateLinodeInterfaceSchema.conca export type CreateInterfaceFormValues = InferType< typeof CreateLinodeInterfaceFormSchema >; + +export const INTERFACE_PURPOSE_TO_DEFAULT_FIREWALL_KEY: Record< + InterfacePurpose, + FirewallDefaultEntity | null +> = { + public: 'public_interface', + vlan: null, + vpc: 'vpc_interface', +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/DeleteInterfaceDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/DeleteInterfaceDialog.tsx index 48a77a540dd..e621d5ec828 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/DeleteInterfaceDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/DeleteInterfaceDialog.tsx @@ -26,7 +26,7 @@ export const DeleteInterfaceDialog = (props: Props) => { data: linodeInterface, error: interfaceError, isLoading, - } = useLinodeInterfaceQuery(linodeId, interfaceId ?? -1); + } = useLinodeInterfaceQuery(linodeId, interfaceId, open); const { error, isPending, mutate } = useDeleteLinodeInterfaceMutation( linodeId, @@ -63,7 +63,7 @@ export const DeleteInterfaceDialog = (props: Props) => { isFetching={isLoading} onClose={onClose} open={open} - title={`Delete ${type} Interface?`} + title={`Delete ${type} Interface (ID: ${interfaceId})?`} > Are you sure you want to delete this {type} interface? diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsContent.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsContent.test.tsx new file mode 100644 index 00000000000..2e9f9faefde --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsContent.test.tsx @@ -0,0 +1,61 @@ +import { + linodeInterfaceFactoryPublic, + linodeInterfaceFactoryVPC, + linodeInterfaceFactoryVlan, +} from '@linode/utilities'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { InterfaceDetailsContent } from './InterfaceDetailsContent'; + +describe('InterfaceDetailsContent', () => { + it('shows the information for a Public Interface', () => { + const publicInterface = linodeInterfaceFactoryPublic.build(); + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Type')).toBeVisible(); + expect(getByText('Public')).toBeVisible(); + expect(getByText('ID')).toBeVisible(); + expect(getByText('MAC Address')).toBeVisible(); + expect(getByText('IPv4 Addresses')).toBeVisible(); + expect(getByText('IPv6 Addresses')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); + expect(getByText('Modified')).toBeVisible(); + }); + + it('shows the information for a VPC Interface', () => { + const vpcInterface = linodeInterfaceFactoryVPC.build(); + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Type')).toBeVisible(); + expect(getByText('VPC')).toBeVisible(); + expect(getByText('ID')).toBeVisible(); + expect(getByText('MAC Address')).toBeVisible(); + expect(getByText('VPC Label')).toBeVisible(); + expect(getByText('Subnet Label')).toBeVisible(); + expect(getByText('IPv4 Addresses')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); + expect(getByText('Modified')).toBeVisible(); + }); + + it('shows the information for a VLAN Interface', () => { + const vlanInterface = linodeInterfaceFactoryVlan.build(); + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Type')).toBeVisible(); + expect(getByText('VLAN')).toBeVisible(); + expect(getByText('ID')).toBeVisible(); + expect(getByText('MAC Address')).toBeVisible(); + expect(getByText('VLAN Label')).toBeVisible(); + expect(getByText('IPAM Address')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); + expect(getByText('Modified')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsContent.tsx new file mode 100644 index 00000000000..3e6453f1989 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsContent.tsx @@ -0,0 +1,69 @@ +import { Box, Chip, Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; + +import { getLinodeInterfaceType } from '../utilities'; +import { PublicInterfaceDetailsContent } from './PublicInterfaceDetailsContent'; +import { VlanInterfaceDetailsContent } from './VlanInterfaceDetailsContent'; +import { VPCInterfaceDetailsContent } from './VPCInterfaceDetailsContent'; + +import type { LinodeInterface } from '@linode/api-v4'; + +export const InterfaceDetailsContent = (props: LinodeInterface) => { + const { created, default_route, id, mac_address, updated } = props; + const type = getLinodeInterfaceType(props); + + return ( + + {(default_route.ipv4 || default_route.ipv6) && ( + + {default_route.ipv4 && ( + + )} + {default_route.ipv6 && ( + + )} + + )} + + + Type + + {type} + + + + ID + + {id} + + + + MAC Address + + + + {props.public && } + {props.vpc && } + {props.vlan && } + + + Created + + + + + + + + Modified + + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsDrawer.tsx new file mode 100644 index 00000000000..bb8d0d9c0f5 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/InterfaceDetailsDrawer.tsx @@ -0,0 +1,47 @@ +import { useLinodeInterfaceQuery } from '@linode/queries'; +import { Box, Button, Drawer } from '@linode/ui'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; + +import { NotFound } from 'src/components/NotFound'; + +import { InterfaceDetailsContent } from './InterfaceDetailsContent'; + +interface Props { + interfaceId: number | undefined; + linodeId: number; + onClose: () => void; + open: boolean; +} + +export const InterfaceDetailsDrawer = (props: Props) => { + const location = useLocation(); + const interfaceIdFromLocation = +location.pathname.split('/').slice(-1); + + const { interfaceId: id, linodeId, onClose, open } = props; + const interfaceId = id ?? interfaceIdFromLocation; + + const { data: linodeInterface, error, isLoading } = useLinodeInterfaceQuery( + linodeId, + interfaceId, + open + ); + + return ( + + {linodeInterface && } + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/PublicInterfaceDetailsContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/PublicInterfaceDetailsContent.tsx new file mode 100644 index 00000000000..e5a0a9c9f61 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/PublicInterfaceDetailsContent.tsx @@ -0,0 +1,72 @@ +import { Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { MaskableText } from 'src/components/MaskableText/MaskableText'; + +import type { PublicInterfaceData } from '@linode/api-v4'; + +export const PublicInterfaceDetailsContent = (props: PublicInterfaceData) => { + const { ipv4, ipv6 } = props; + + const ipv4ToTypography = ( + <> + {ipv4.addresses.map((address) => ( + + ))} + {ipv4.shared.map((shared) => ( + + ))} + + ); + + const ipv6ToTypography = ( + <> + {ipv6.slaac.map((slaac) => ( + + ))} + {ipv6.shared.map((shared) => ( + + ))} + {ipv6.ranges.map((range) => ( + + ))} + + ); + + return ( + <> + + + IPv4 Addresses + + {ipv4ToTypography} + + + + IPv6 Addresses + + {ipv6ToTypography} + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VPCInterfaceDetailsContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VPCInterfaceDetailsContent.tsx new file mode 100644 index 00000000000..9bed4dcac59 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VPCInterfaceDetailsContent.tsx @@ -0,0 +1,78 @@ +import { useVPCQuery } from '@linode/queries'; +import { CircleProgress, Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { Link } from 'src/components/Link'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; + +import type { VPCInterfaceData } from '@linode/api-v4'; + +export const VPCInterfaceDetailsContent = (props: VPCInterfaceData) => { + const { ipv4, subnet_id, vpc_id } = props; + const { data: vpc } = useVPCQuery(vpc_id, Boolean(vpc_id)); + + const subnet = vpc?.subnets.find((subnet) => subnet.id === subnet_id); + + const ipv4ToTypography = ( + <> + {ipv4.addresses.map((address) => + address.nat_1_1_address ? ( + + + + + ) : ( + + ) + )} + {ipv4.ranges.map((range) => ( + + ))} + + ); + + return ( + <> + + + VPC Label + + {vpc ? ( + {vpc.label} + ) : ( + + )} + + + + Subnet Label + + {subnet ? ( + {subnet.label} + ) : ( + + )} + + + + IPv4 Addresses + + {ipv4ToTypography} + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx new file mode 100644 index 00000000000..e7561e27138 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx @@ -0,0 +1,29 @@ +import { Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { MaskableText } from 'src/components/MaskableText/MaskableText'; + +export const VlanInterfaceDetailsContent = (props: { + ipam_address: string; + vlan_label: string; +}) => { + const { ipam_address, vlan_label } = props; + return ( + <> + + + VLAN Label + + {vlan_label} + + + + IPAM Address + + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceActionMenu.tsx index e8a51b8897b..a4fcbb705d6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceActionMenu.tsx @@ -12,13 +12,14 @@ interface Props { export interface InterfaceActionHandlers { onDelete: (interfaceId: number) => void; + onShowDetails: (interfaceId: number) => void; } export const LinodeInterfaceActionMenu = (props: Props) => { const { handlers, id, type } = props; const actions = [ - { onClick: () => alert(`Details ${id}`), title: 'Details' }, + { onClick: () => handlers.onShowDetails(id), title: 'Details' }, { onClick: () => alert(`Edit ${id}`), title: 'Edit' }, { onClick: () => handlers.onDelete(id), title: 'Delete' }, ]; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaces.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaces.tsx index bbb1434f67f..3fcff6f703b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaces.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaces.tsx @@ -1,22 +1,33 @@ import { Box, Button, Paper, Typography } from '@linode/ui'; import React, { useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; import { AddInterfaceDrawer } from './AddInterfaceDrawer/AddInterfaceDrawer'; import { DeleteInterfaceDialog } from './DeleteInterfaceDialog'; +import { InterfaceDetailsDrawer } from './InterfaceDetailsDrawer/InterfaceDetailsDrawer'; import { LinodeInterfacesTable } from './LinodeInterfacesTable'; interface Props { linodeId: number; + regionId: string; } -export const LinodeInterfaces = ({ linodeId }: Props) => { +export const LinodeInterfaces = ({ linodeId, regionId }: Props) => { + const location = useLocation(); + const history = useHistory(); + const [isAddDrawerOpen, setIsAddDrawerOpen] = useState(false); - const [isDeleteDrawerOpen, setIsDeleteDrawerOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [selectedInterfaceId, setSelectedInterfaceId] = useState(); const onDelete = (interfaceId: number) => { setSelectedInterfaceId(interfaceId); - setIsDeleteDrawerOpen(true); + setIsDeleteDialogOpen(true); + }; + + const onShowDetails = (interfaceId: number) => { + setSelectedInterfaceId(interfaceId); + history.replace(`${location.pathname}/interfaces/${interfaceId}`); }; return ( @@ -36,17 +47,29 @@ export const LinodeInterfaces = ({ linodeId }: Props) => { Add Network Interface - + setIsAddDrawerOpen(false)} open={isAddDrawerOpen} + regionId={regionId} /> setIsDeleteDrawerOpen(false)} - open={isDeleteDrawerOpen} + onClose={() => setIsDeleteDialogOpen(false)} + open={isDeleteDialogOpen} + /> + { + history.replace(`/linodes/${linodeId}/networking`); + }} + interfaceId={selectedInterfaceId} + linodeId={linodeId} + open={location.pathname.includes('networking/interfaces')} /> ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworking.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworking.tsx index 7ca65b50b68..b3cd3179807 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworking.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworking.tsx @@ -36,7 +36,9 @@ export const LinodeNetworking = () => { {showFirewallsTable && } - {showInterfacesTable && } + {showInterfacesTable && ( + + )} ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.test.tsx index bcb1d6373dc..08acd5b4331 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.test.tsx @@ -38,7 +38,7 @@ describe('LinodeNetworkingActionMenu', () => { ); @@ -54,7 +54,7 @@ describe('LinodeNetworkingActionMenu', () => { ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index efcf57476af..e2b2748ad26 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -25,7 +25,6 @@ interface Props { export const LinodeNetworkingActionMenu = (props: Props) => { const theme = useTheme(); const matchesMdDown = useMediaQuery(theme.breakpoints.down('lg')); - const { ipAddress, ipType, @@ -37,15 +36,15 @@ export const LinodeNetworkingActionMenu = (props: Props) => { } = props; const showEdit = ![ - 'IPv4 – Private', - 'IPv4 – Reserved (private)', - 'IPv4 – Reserved (public)', - 'IPv4 – VPC', - 'IPv6 – Link Local', - 'VPC IPv4 – NAT', + 'Link Local – IPv6', + 'Private – IPv4', + 'Reserved IPv4 (private)', + 'Reserved IPv4 (public)', + 'VPC NAT – IPv4', + 'VPC – IPv4', ].includes(ipType); - const deletableIPTypes = ['IPv4 – Public', 'IPv4 – Private', 'IPv6 – Range']; + const deletableIPTypes = ['Private – IPv4', 'Public – IPv4', 'Range – IPv6']; // if we have a 116 we don't want to give the option to remove it const is116Range = ipAddress?.prefix === 116; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.test.tsx index de7cd04dfe4..ebb18467c96 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.test.tsx @@ -1,10 +1,7 @@ +import { linodeTransferFactory, regionFactory } from '@linode/utilities'; import React from 'react'; -import { - accountTransferFactory, - linodeTransferFactory, - regionFactory, -} from 'src/factories'; +import { accountTransferFactory } from 'src/factories'; import { typeFactory } from 'src/factories/types'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx index cca3fd1146b..b3eef1e7b96 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx @@ -1,10 +1,11 @@ +import { useLinodeQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Paper } from '@linode/ui'; import Grid from '@mui/material/Grid2'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; -import { useLinodeQuery } from '@linode/queries'; +import { useFlags } from 'src/hooks/useFlags'; import { DNSResolvers } from './DNSResolvers'; import { NetworkTransfer } from './NetworkTransfer'; @@ -15,10 +16,14 @@ interface Props { } export const LinodeNetworkingSummaryPanel = React.memo((props: Props) => { + const flags = useFlags(); + const theme = useTheme(); // @todo maybe move this query closer to the consuming component const { data: linode } = useLinodeQuery(props.linodeId); - const { isGeckoLAEnabled } = useIsGeckoEnabled(); - const theme = useTheme(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); if (!linode) { return null; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts index f12c35c7c2a..b51c09fe137 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/types.ts @@ -1,12 +1,12 @@ export type IPTypes = - | 'IPv4 – Private' - | 'IPv4 – Public' - | 'IPv4 – Reserved (private)' - | 'IPv4 – Reserved (public)' - | 'IPv4 – Shared' - | 'IPv4 – VPC – Range' - | 'IPv4 – VPC' - | 'IPv6 – Link Local' - | 'IPv6 – Range' - | 'IPv6 – SLAAC' - | 'VPC IPv4 – NAT'; + | 'Link Local – IPv6' + | 'Private – IPv4' + | 'Public – IPv4' + | 'Public – IPv6 – SLAAC' + | 'Range – IPv6' + | 'Reserved IPv4 (private)' + | 'Reserved IPv4 (public)' + | 'Shared – IPv4' + | 'VPC NAT – IPv4' + | 'VPC – IPv4' + | 'VPC – Range – IPv4'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx index 57400db3ae4..801295298f4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx @@ -1,8 +1,8 @@ +import { useStackScriptQuery } from '@linode/queries'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; -import { useStackScriptQuery } from 'src/queries/stackscripts'; import type { RebuildLinodeFormValues } from './utils'; import type { Image as ImageType, StackScript } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDefinedFields.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDefinedFields.tsx index 2022716de2f..65d6114f0dc 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDefinedFields.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDefinedFields.tsx @@ -1,9 +1,9 @@ +import { useStackScriptQuery } from '@linode/queries'; import { Box, Notice, Stack, Typography } from '@linode/ui'; import React from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { ShowMoreExpansion } from 'src/components/ShowMoreExpansion'; -import { useStackScriptQuery } from 'src/queries/stackscripts'; import { UserDefinedFieldInput } from '../../LinodeCreate/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput'; import { separateUDFsByRequiredStatus } from '../../LinodeCreate/Tabs/StackScripts/UserDefinedFields/utilities'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts index 102ce9feaf5..03cdc61b34a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts @@ -1,10 +1,9 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { isEmpty } from '@linode/api-v4'; +import { stackscriptQueries } from '@linode/queries'; import { RebuildLinodeSchema } from '@linode/validation'; import { boolean, number, object, string } from 'yup'; -import { stackscriptQueries } from 'src/queries/stackscripts'; - import { getIsUDFRequired } from '../../LinodeCreate/Tabs/StackScripts/UserDefinedFields/utilities'; import type { RebuildRequest, StackScript } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/RescueDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/RescueDialog.test.tsx index acc5bd0bdc8..b6abe693b1c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/RescueDialog.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/RescueDialog.test.tsx @@ -1,6 +1,6 @@ +import { linodeFactory } from '@linode/utilities'; import * as React from 'react'; -import { linodeFactory } from 'src/factories/linodes'; import { typeFactory } from 'src/factories/types'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx index 78942da9251..5d7bf10b6a6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx @@ -15,13 +15,12 @@ import { Paper, clamp, } from '@linode/ui'; -import { usePrevious } from '@linode/utilities'; +import { usePrevious, createDevicesFromStrings } from '@linode/utilities'; import { styled, useTheme } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useEventsPollingActions } from 'src/queries/events/events'; -import { createDevicesFromStrings } from 'src/utilities/createDevicesFromStrings'; import { LinodePermissionsError } from '../LinodePermissionsError'; import { DeviceSelection } from './DeviceSelection'; @@ -29,7 +28,7 @@ import { RescueDescription } from './RescueDescription'; import type { ExtendedDisk } from './DeviceSelection'; import type { APIError } from '@linode/api-v4/lib/types'; -import type { DevicesAsStrings } from 'src/utilities/createDevicesFromStrings'; +import type { DevicesAsStrings } from '@linode/utilities'; interface Props { linodeId: number | undefined; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.test.tsx index 789f3ff2d28..9d5a382025c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.test.tsx @@ -1,7 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import * as React from 'react'; -import { linodeFactory } from 'src/factories'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx index 74e9117610c..7357606aeb0 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx @@ -1,7 +1,8 @@ +import { regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import * as React from 'react'; -import { accountFactory, regionFactory } from 'src/factories'; +import { accountFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx index 2f82fb923f1..524bae19482 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx @@ -10,7 +10,10 @@ import { TooltipIcon, Typography, } from '@linode/ui'; -import { scrollErrorIntoView } from '@linode/utilities'; +import { + doesRegionSupportFeature, + scrollErrorIntoView, +} from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -20,7 +23,6 @@ import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP, } from 'src/features/VPCs/constants'; import { AssignIPRanges } from 'src/features/VPCs/VPCDetail/AssignIPRanges'; -import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import type { ExtendedIP } from 'src/utilities/ipUtils'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.test.tsx index c7f1ca7228a..a81e22e3f4b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.test.tsx @@ -1,6 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import React from 'react'; -import { linodeDiskFactory, linodeFactory } from 'src/factories'; +import { linodeDiskFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.test.tsx index a2abaeeee7f..1744c8e6eb3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.test.tsx @@ -1,9 +1,9 @@ +import { linodeFactory } from '@linode/utilities'; import React from 'react'; import { linodeDiskFactory } from 'src/factories'; -import { linodeFactory } from 'src/factories/linodes'; import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { LinodeDisks } from './LinodeDisks'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx index 05cf8a05dc7..60c05b64ddd 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx @@ -1,6 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import * as React from 'react'; -import { accountFactory, linodeFactory, volumeFactory } from 'src/factories'; +import { accountFactory, volumeFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/ResizeDiskDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/ResizeDiskDrawer.test.tsx index c0d28d55a60..18d4b4873f0 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/ResizeDiskDrawer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/ResizeDiskDrawer.test.tsx @@ -1,6 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import React from 'react'; -import { linodeDiskFactory, linodeFactory } from 'src/factories'; +import { linodeDiskFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx index 7a9f7f2990f..7f96ee9c2a5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx @@ -1,3 +1,4 @@ +import { useLinodeQuery } from '@linode/queries'; import { CircleProgress, ErrorState } from '@linode/ui'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; @@ -16,7 +17,6 @@ import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { SMTPRestrictionText } from 'src/features/Linodes/SMTPRestrictionText'; -import { useLinodeQuery } from '@linode/queries'; import { useTypeQuery } from 'src/queries/types'; const LinodeSummary = React.lazy(() => import('./LinodeSummary/LinodeSummary')); @@ -87,7 +87,10 @@ const LinodesDetailNavigation = () => { ].filter((thisTab) => !thisTab.hidden); const matches = (p: string) => { - return Boolean(matchPath(p, { path: location.pathname })); + return ( + Boolean(matchPath(p, { path: location.pathname })) || + location.pathname.includes(p) + ); }; const getIndex = () => { diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx index 1c6dd625ca2..ebb11f4a394 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx @@ -1,5 +1,12 @@ -import { Box, CircleProgress, Paper, Tooltip, Typography } from '@linode/ui'; -import { IconButton } from '@linode/ui'; +import { useIsGeckoEnabled } from '@linode/shared'; +import { + Box, + CircleProgress, + IconButton, + Paper, + Tooltip, + Typography, +} from '@linode/ui'; import { groupByTags, sortGroups } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; @@ -10,12 +17,12 @@ import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; import { getMinimumPageSizeForNumberOfItems } from 'src/components/PaginationFooter/PaginationFooter.utils'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { useFlags } from 'src/hooks/useFlags'; import { useInfinitePageSize } from 'src/hooks/useInfinitePageSize'; import { @@ -94,7 +101,12 @@ export const DisplayGroupedLinodes = (props: DisplayGroupedLinodesProps) => { return acc; }, 0); - const { isGeckoLAEnabled } = useIsGeckoEnabled(); + const flags = useFlags(); + + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); if (display === 'grid') { return ( diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DisplayLinodes.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DisplayLinodes.tsx index 521aec2df9b..d1b8018fa93 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DisplayLinodes.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DisplayLinodes.tsx @@ -1,5 +1,5 @@ -import { Box, CircleProgress, Paper, Tooltip } from '@linode/ui'; -import { IconButton } from '@linode/ui'; +import { useIsGeckoEnabled } from '@linode/shared'; +import { Box, CircleProgress, IconButton, Paper, Tooltip } from '@linode/ui'; import { getQueryParamsFromQueryString } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; @@ -10,9 +10,9 @@ import GroupByTag from 'src/assets/icons/group-by-tag.svg'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { getMinimumPageSizeForNumberOfItems } from 'src/components/PaginationFooter/PaginationFooter.utils'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TableBody } from 'src/components/TableBody'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { useFlags } from 'src/hooks/useFlags'; import { useInfinitePageSize } from 'src/hooks/useInfinitePageSize'; import { StyledControlHeader } from './DisplayLinodes.styles'; @@ -82,6 +82,7 @@ export const DisplayLinodes = React.memo((props: DisplayLinodesProps) => { const displayViewDescriptionId = React.useId(); const groupByDescriptionId = React.useId(); const { infinitePageSize, setInfinitePageSize } = useInfinitePageSize(); + const flags = useFlags(); const numberOfLinodesWithMaintenance = React.useMemo(() => { return data.reduce((acc, thisLinode) => { @@ -104,7 +105,10 @@ export const DisplayLinodes = React.memo((props: DisplayLinodesProps) => { const params = getQueryParamsFromQueryString(search); const queryPage = Math.min(Number(params.page), maxPageNumber) || 1; - const { isGeckoLAEnabled } = useIsGeckoEnabled(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); return ( { const csvRef = React.useRef(); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index 5f5fc1c9202..51f646ae2cf 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -1,3 +1,5 @@ +import { useRegionsQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Notice, Typography } from '@linode/ui'; import * as React from 'react'; @@ -7,7 +9,6 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { NO_PLACEMENT_GROUPS_IN_SELECTED_REGION_MESSAGE } from 'src/features/PlacementGroups/constants'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; -import { useRegionsQuery } from '@linode/queries'; import { useTypeQuery } from 'src/queries/types'; import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import { getLinodeBackupPrice } from 'src/utilities/pricing/backups'; @@ -55,6 +56,10 @@ export const ConfigureForm = React.memo((props: Props) => { } = props; const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { data: regions } = useRegionsQuery(); @@ -179,6 +184,7 @@ export const ConfigureForm = React.memo((props: Props) => { currentCapability="Linodes" disableClearable errorText={errorText} + isGeckoLAEnabled={isGeckoLAEnabled} label="New Region" onChange={(e, region) => handleSelectRegion(region.id)} value={selectedRegion} diff --git a/packages/manager/src/features/Linodes/SMTPRestrictionText.test.tsx b/packages/manager/src/features/Linodes/SMTPRestrictionText.test.tsx index 7884196c7ce..4ae6c09252b 100644 --- a/packages/manager/src/features/Linodes/SMTPRestrictionText.test.tsx +++ b/packages/manager/src/features/Linodes/SMTPRestrictionText.test.tsx @@ -1,7 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import * as React from 'react'; -import { linodeFactory } from 'src/factories'; import { accountFactory } from 'src/factories/account'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Linodes/index.tsx b/packages/manager/src/features/Linodes/index.tsx index 0be63223a2c..dbd963a0b1b 100644 --- a/packages/manager/src/features/Linodes/index.tsx +++ b/packages/manager/src/features/Linodes/index.tsx @@ -1,13 +1,14 @@ +import { + useAllAccountMaintenanceQuery, + useAllLinodesQuery, +} from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { createLazyRoute } from '@tanstack/react-router'; import React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; -import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { - useAllAccountMaintenanceQuery, - useAllLinodesQuery, -} from '@linode/queries'; +import { useFlags } from 'src/hooks/useFlags'; import { useInProgressEvents } from 'src/queries/events/events'; import { addMaintenanceToLinodes } from 'src/utilities/linodes'; import { storage } from 'src/utilities/storage'; @@ -54,8 +55,12 @@ export const LinodesLandingWrapper = React.memo(() => { {}, PENDING_MAINTENANCE_FILTER ); + const flags = useFlags(); - const { isGeckoLAEnabled } = useIsGeckoEnabled(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const [regionFilter, setRegionFilter] = React.useState< RegionFilter | undefined diff --git a/packages/manager/src/features/Linodes/types.ts b/packages/manager/src/features/Linodes/types.ts index 39718d3acee..a55e038b791 100644 --- a/packages/manager/src/features/Linodes/types.ts +++ b/packages/manager/src/features/Linodes/types.ts @@ -1,5 +1,4 @@ -import type { LinodeCreateType } from './LinodeCreate/types'; -import type { BaseQueryParams } from '@linode/utilities'; +import type { BaseQueryParams, LinodeCreateType } from '@linode/utilities'; export type DialogType = | 'delete' diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/Apache.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/Apache.tsx index 1daf1246856..2713b560ed6 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/Apache.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Apache/Apache.tsx @@ -1,11 +1,11 @@ import { Box, Notice, Typography } from '@linode/ui'; +import { isToday as _isToday } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; import { TimeRangeSelect } from 'src/features/Longview/shared/TimeRangeSelect'; -import { isToday as _isToday } from 'src/utilities/isToday'; import { StyledTypography } from '../CommonStyles.styles'; import { useGraphs } from '../OverviewGraphs/useGraphs'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Disks/Graphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Disks/Graphs.tsx index 597279b49de..48f6fcf8dc8 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Disks/Graphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Disks/Graphs.tsx @@ -1,9 +1,9 @@ import { Typography } from '@linode/ui'; +import { isToday as _isToday } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { LongviewLineGraph } from 'src/components/LongviewLineGraph/LongviewLineGraph'; -import { isToday as _isToday } from 'src/utilities/isToday'; import { convertData } from '../../../shared/formatters'; import GraphCard from '../../GraphCard'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx index c015f97a0c7..9f6fbaf6462 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx @@ -1,5 +1,5 @@ import { Box, Stack, Typography } from '@linode/ui'; -import { readableBytes } from '@linode/utilities'; +import { formatUptime, readableBytes } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; @@ -9,7 +9,6 @@ import PackageIcon from 'src/assets/icons/longview/package-icon.svg'; import RamIcon from 'src/assets/icons/longview/ram-sticks.svg'; import ServerIcon from 'src/assets/icons/longview/server-icon.svg'; import { IconTextLink } from 'src/components/IconTextLink/IconTextLink'; -import { formatUptime } from 'src/utilities/formatUptime'; import { getPackageNoticeText, diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLLanding.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLLanding.tsx index fd9b403eb9a..cf45330be89 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/MySQL/MySQLLanding.tsx @@ -1,11 +1,11 @@ import { Box, Notice, Typography } from '@linode/ui'; +import { isToday as _isToday } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; import { TimeRangeSelect } from 'src/features/Longview/shared/TimeRangeSelect'; -import { isToday as _isToday } from 'src/utilities/isToday'; import { StyledTypography } from '../CommonStyles.styles'; import { useGraphs } from '../OverviewGraphs/useGraphs'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx index 56683109ec2..8b637126438 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx @@ -1,11 +1,11 @@ import { Box, Notice, Typography } from '@linode/ui'; +import { isToday as _isToday } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; import { TimeRangeSelect } from 'src/features/Longview/shared/TimeRangeSelect'; -import { isToday as _isToday } from 'src/utilities/isToday'; import { StyledTypography } from '../CommonStyles.styles'; import { useGraphs } from '../OverviewGraphs/useGraphs'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Network/NetworkLanding.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Network/NetworkLanding.tsx index b0c95a7f3e6..58a926f7d38 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Network/NetworkLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Network/NetworkLanding.tsx @@ -1,19 +1,19 @@ +import { isToday as _isToday } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { isToday as _isToday } from 'src/utilities/isToday'; +import { TimeRangeSelect } from 'src/features/Longview/shared/TimeRangeSelect'; -import type { - LongviewNetworkInterface, - WithStartAndEnd, -} from '../../../request.types'; import { StyledBox } from '../Disks/Disks.styles'; import { useGraphs } from '../OverviewGraphs/useGraphs'; import { NetworkGraphs } from './NetworkGraphs'; +import type { + LongviewNetworkInterface, + WithStartAndEnd, +} from '../../../request.types'; import type { APIError } from '@linode/api-v4'; -import { TimeRangeSelect } from 'src/features/Longview/shared/TimeRangeSelect'; interface Props { clientAPIKey: string; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx index a2bf6a51716..9196cd4d419 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx @@ -1,10 +1,9 @@ import { Paper } from '@linode/ui'; +import { isToday as _isToday } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { isToday as _isToday } from 'src/utilities/isToday'; - import { TimeRangeSelect } from '../../../shared/TimeRangeSelect'; import { StyledTypography } from '../CommonStyles.styles'; import { CPUGraph } from './CPUGraph'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx index c37e4e523f6..5e8ed70fb86 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx @@ -1,11 +1,11 @@ import { TextField } from '@linode/ui'; +import { isToday as _isToday } from '@linode/utilities'; import { escapeRegExp } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { statAverage, statMax } from 'src/features/Longview/shared/utilities'; -import { isToday as _isToday } from 'src/utilities/isToday'; import { useGraphs } from '../OverviewGraphs/useGraphs'; import { ProcessesGraphs } from './ProcessesGraphs'; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx index 839a63bab8d..2a60e5b9c9b 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx @@ -1,5 +1,6 @@ import { useProfile } from '@linode/queries'; import { Typography } from '@linode/ui'; +import { formatUptime } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { compose } from 'recompose'; @@ -9,7 +10,6 @@ import { Link } from 'src/components/Link'; import withClientStats from 'src/containers/longview.stats.container'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; -import { formatUptime } from 'src/utilities/formatUptime'; import { getPackageNoticeText } from '../shared/utilities'; import { diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts index e95b738d915..aa02cc8cedb 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.test.ts @@ -1,4 +1,4 @@ -import { linodeFactory } from 'src/factories'; +import { linodeFactory } from '@linode/utilities'; import { getPrivateIPOptions } from './ConfigNodeIPSelect.utils'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx index 3a2f0a171aa..8c2ee800cd0 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx @@ -4,6 +4,18 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import NodeBalancerCreate from './NodeBalancerCreate'; +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + // Note: see nodeblaancers-create-in-complex-form.spec.ts for an e2e test of this flow describe('NodeBalancerCreate', () => { it('renders all parts of the NodeBalancerCreate page', () => { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 6f897919c98..a32be080cc6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -6,6 +6,7 @@ import { useProfile, useRegionsQuery, } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Accordion, ActionsPanel, @@ -20,10 +21,9 @@ import { import { scrollErrorIntoView } from '@linode/utilities'; import { useTheme } from '@mui/material'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import { append, clone, compose, defaultTo, lensPath, over } from 'ramda'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { CheckoutSummary } from 'src/components/CheckoutSummary/CheckoutSummary'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; @@ -38,6 +38,7 @@ import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperT import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { FIREWALL_GET_STARTED_LINK } from 'src/constants'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendCreateNodeBalancerEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -98,6 +99,12 @@ const defaultFieldsStates = { }; const NodeBalancerCreate = () => { + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + const navigate = useNavigate(); const { data: agreements } = useAccountAgreements(); const { data: profile } = useProfile(); const { data: regions } = useRegionsQuery(); @@ -109,8 +116,6 @@ const NodeBalancerCreate = () => { mutateAsync: createNodeBalancer, } = useNodebalancerCreateMutation(); - const history = useHistory(); - const [ nodeBalancerFields, setNodeBalancerFields, @@ -303,7 +308,10 @@ const NodeBalancerCreate = () => { createNodeBalancer(nodeBalancerRequestData) .then((nodeBalancer) => { - history.push(`/nodebalancers/${nodeBalancer.id}/summary`); + navigate({ + params: { id: String(nodeBalancer.id) }, + to: '/nodebalancers/$id/summary', + }); // Analytics Event sendCreateNodeBalancerEvent(`Region: ${nodeBalancer.region}`); }) @@ -542,6 +550,7 @@ const NodeBalancerCreate = () => { currentCapability="NodeBalancers" disableClearable errorText={hasErrorFor('region')} + isGeckoLAEnabled={isGeckoLAEnabled} noMarginTop onChange={(e, region) => regionChange(region?.id ?? '')} regions={regions ?? []} @@ -807,10 +816,4 @@ export const fieldErrorsToNodePathErrors = (errors: APIError[]) => { }, []); }; -export const nodeBalancerCreateLazyRoute = createLazyRoute( - '/nodebalancers/create' -)({ - component: NodeBalancerCreate, -}); - export default NodeBalancerCreate; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx index 765997408f1..24491fc02f4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx @@ -1,3 +1,4 @@ +import { nodeBalancerFactory } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -8,15 +9,17 @@ import { NodeBalancerDeleteDialog } from './NodeBalancerDeleteDialog'; import type { ManagerPreferences } from '@linode/utilities'; const props = { - id: 1, - label: 'nb-1', - onClose: vi.fn(), + isFetching: false, open: true, + selectedNodeBalancer: nodeBalancerFactory.build(), }; const preference: ManagerPreferences['type_to_confirm'] = true; +const navigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ + useMatch: vi.fn(() => ({})), + useNavigate: vi.fn(() => navigate), usePreferences: vi.fn().mockReturnValue({}), })); @@ -28,6 +31,15 @@ vi.mock('@linode/queries', async () => { }; }); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useMatch: queryMocks.useMatch, + useNavigate: queryMocks.useNavigate, + }; +}); + queryMocks.usePreferences.mockReturnValue({ data: preference, }); @@ -50,7 +62,7 @@ describe('NodeBalancerDeleteDialog', () => { 'Traffic will no longer be routed through this NodeBalancer. Please check your DNS settings and either provide the IP address of another active NodeBalancer, or route traffic directly to your Linode.' ) ).toBeVisible(); - expect(getByText('Delete nb-1?')).toBeVisible(); + expect(getByText('Delete nodebalancer-id-1?')).toBeVisible(); expect(getByText('NodeBalancer Label')).toBeVisible(); expect(getByText('Cancel')).toBeVisible(); expect(getByText('Delete')).toBeVisible(); @@ -62,6 +74,6 @@ describe('NodeBalancerDeleteDialog', () => { ); await userEvent.click(getByText('Cancel')); - expect(props.onClose).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx index 42afe967835..1ac0f721fa4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx @@ -1,30 +1,36 @@ +import { useNodebalancerDeleteMutation } from '@linode/queries'; import { Notice, Typography } from '@linode/ui'; +import { useMatch, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; -import { useNodebalancerDeleteMutation } from '@linode/queries'; + +import type { NodeBalancer } from '@linode/api-v4'; interface Props { - id: number; - label: string; - onClose: () => void; + isFetching: boolean; open: boolean; + selectedNodeBalancer: NodeBalancer | undefined; } export const NodeBalancerDeleteDialog = ({ - id, - label, - onClose, + isFetching, open, + selectedNodeBalancer, }: Props) => { - const { error, isPending, mutateAsync } = useNodebalancerDeleteMutation(id); - const { push } = useHistory(); + const navigate = useNavigate(); + const match = useMatch({ + strict: false, + }); + const { error, isPending, mutateAsync } = useNodebalancerDeleteMutation( + selectedNodeBalancer?.id ?? -1 + ); + + const label = selectedNodeBalancer?.label; const onDelete = async () => { await mutateAsync(); - onClose(); - push('/nodebalancers'); + navigate({ to: '/nodebalancers' }); }; return ( @@ -35,12 +41,20 @@ export const NodeBalancerDeleteDialog = ({ primaryBtnText: 'Delete', type: 'NodeBalancer', }} + onClose={ + match.routeId === '/nodebalancers/$id/settings/delete' + ? () => + navigate({ + params: { id: String(selectedNodeBalancer?.id) }, + to: '/nodebalancers/$id/settings', + }) + : () => navigate({ to: '/nodebalancers' }) + } errors={error ?? undefined} expand label={'NodeBalancer Label'} - loading={isPending} + loading={isPending || isFetching} onClick={onDelete} - onClose={onClose} open={open} title={`Delete ${label}?`} typographyStyle={{ marginTop: '20px' }} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx index 037fea00ea3..7a8835ae565 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx @@ -1,11 +1,11 @@ +import { + nodeBalancerConfigFactory, + nodeBalancerConfigNodeFactory, +} from '@linode/utilities'; import { waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { - nodeBalancerConfigFactory, - nodeBalancerConfigNodeFactory, -} from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -16,6 +16,9 @@ const props = { grants: undefined, nodeBalancerLabel: 'nb-1', nodeBalancerRegion: 'us-east', + params: { + nodeBalancerId: '1', + }, }; const loadingTestId = 'circle-progress'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index fe5cc8986a4..e94811cd831 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -23,7 +23,6 @@ import { view, } from 'ramda'; import * as React from 'react'; -import { withRouter } from 'react-router-dom'; import { compose as composeC } from 'recompose'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; @@ -54,7 +53,6 @@ import type { NodeBalancerConfigNode, } from '@linode/api-v4'; import type { Lens } from 'ramda'; -import type { RouteComponentProps } from 'react-router-dom'; import type { PromiseLoaderResponse } from 'src/components/PromiseLoader/PromiseLoader'; import type { WithQueryClientProps } from 'src/containers/withQueryClient.container'; @@ -81,17 +79,18 @@ const StyledConfigsButton = styled(Button, { }, })); -interface Props { +export interface NodeBalancerConfigurationsBaseProps { grants: Grants | undefined; nodeBalancerLabel: string; nodeBalancerRegion: string; } -interface MatchProps { - configId?: string; - nodeBalancerId?: string; +interface Params { + params: { + configId?: string; + id: string; + }; } -type RouteProps = RouteComponentProps; interface PreloadedProps { configs: PromiseLoaderResponse; @@ -122,8 +121,8 @@ interface NodeBalancerConfigWithNodes extends NodeBalancerConfig { } interface NodeBalancerConfigurationsProps - extends Props, - RouteProps, + extends NodeBalancerConfigurationsBaseProps, + Params, PreloadedProps, WithQueryClientProps {} @@ -268,11 +267,7 @@ class NodeBalancerConfigurations extends React.Component< .join(','); createNode = (configIdx: number, nodeIdx: number) => { - const { - match: { - params: { nodeBalancerId }, - }, - } = this.props; + const { id: nodeBalancerId } = this.props.params; const config = this.state.configs[configIdx]; const node = this.state.configs[configIdx].nodes[nodeIdx]; @@ -330,11 +325,7 @@ class NodeBalancerConfigurations extends React.Component< }, }); - const { - match: { - params: { nodeBalancerId }, - }, - } = this.props; + const { id: nodeBalancerId } = this.props.params; if (!nodeBalancerId) { return; @@ -377,11 +368,7 @@ class NodeBalancerConfigurations extends React.Component< }; deleteNode = (configIdx: number, nodeIdx: number) => { - const { - match: { - params: { nodeBalancerId }, - }, - } = this.props; + const { id: nodeBalancerId } = this.props.params; if (!nodeBalancerId) { return; @@ -491,7 +478,7 @@ class NodeBalancerConfigurations extends React.Component< isNodeBalancerReadOnly = () => { const { grants } = this.props; - const { nodeBalancerId } = this.props.match.params; + const { id: nodeBalancerId } = this.props.params; return Boolean( grants?.nodebalancer?.some( (grant) => @@ -579,7 +566,7 @@ class NodeBalancerConfigurations extends React.Component< const lensTo = lensFrom(['configs', idx]); // Check whether config is expended based on the URL - const expandedConfigId = this.props.match.params.configId; + const expandedConfigId = this.props.params.configId; const isExpanded = expandedConfigId ? parseInt(expandedConfigId, 10) === config.id : false; @@ -734,11 +721,7 @@ class NodeBalancerConfigurations extends React.Component< * subsequent saves. */ - const { - match: { - params: { nodeBalancerId }, - }, - } = this.props; + const { id: nodeBalancerId } = this.props.params; if (!nodeBalancerId) { return; @@ -839,11 +822,7 @@ class NodeBalancerConfigurations extends React.Component< configPayload: NodeBalancerConfigFieldsWithStatus ) => { /* Update a config and its nodes simultaneously */ - const { - match: { - params: { nodeBalancerId }, - }, - } = this.props; + const { id: nodeBalancerId } = this.props.params; if (!nodeBalancerId) { return; @@ -1021,11 +1000,7 @@ class NodeBalancerConfigurations extends React.Component< }; updateNode = (configIdx: number, nodeIdx: number) => { - const { - match: { - params: { nodeBalancerId }, - }, - } = this.props; + const { id: nodeBalancerId } = this.props.params; const config = this.state.configs[configIdx]; const node = this.state.configs[configIdx].nodes[nodeIdx]; @@ -1152,19 +1127,14 @@ class NodeBalancerConfigurations extends React.Component< const preloaded = PromiseLoader({ configs: (props) => { - const { - match: { - params: { nodeBalancerId }, - }, - } = props; + const { id: nodeBalancerId } = props.params; return getConfigsWithNodes(+nodeBalancerId!); }, }); -const enhanced = composeC( - withRouter, - preloaded, - withQueryClient -); +const enhanced = composeC< + NodeBalancerConfigurationsProps, + NodeBalancerConfigurationsBaseProps +>(preloaded, withQueryClient); export default enhanced(NodeBalancerConfigurations); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx index 8bf4e870eb6..2d6c882e4b4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx @@ -4,42 +4,42 @@ import { useNodebalancerUpdateMutation, } from '@linode/queries'; import { CircleProgress, ErrorState, Notice } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useMatch, useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { - matchPath, - useHistory, - useLocation, - useParams, -} from 'react-router-dom'; import { LandingHeader } from 'src/components/LandingHeader'; +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 { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { useTabs } from 'src/hooks/useTabs'; import { getErrorMap } from 'src/utilities/errorUtils'; import NodeBalancerConfigurations from './NodeBalancerConfigurations'; import { NodeBalancerSettings } from './NodeBalancerSettings'; import { NodeBalancerSummary } from './NodeBalancerSummary/NodeBalancerSummary'; +import type { NodeBalancerConfigurationsBaseProps } from './NodeBalancerConfigurations'; + export const NodeBalancerDetail = () => { - const history = useHistory(); - const location = useLocation(); - const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); - const id = Number(nodeBalancerId); + const { id } = useParams({ + strict: false, + }); const [label, setLabel] = React.useState(); const { data: grants } = useGrants(); const { error: updateError, mutateAsync: updateNodeBalancer, - } = useNodebalancerUpdateMutation(id); + } = useNodebalancerUpdateMutation(Number(id)); - const { data: nodebalancer, error, isLoading } = useNodeBalancerQuery(id); + const { data: nodebalancer, error, isLoading } = useNodeBalancerQuery( + Number(id), + Boolean(id) + ); const isNodeBalancerReadOnly = useIsResourceRestricted({ grantLevel: 'read_only', @@ -57,23 +57,20 @@ export const NodeBalancerDetail = () => { setLabel(nodebalancer?.label); }; - const tabs = [ + const { handleTabChange, tabIndex, tabs } = useTabs([ { - routeName: `/nodebalancers/${id}/summary`, title: 'Summary', + to: '/nodebalancers/$id/summary', }, { - routeName: `/nodebalancers/${id}/configurations`, title: 'Configurations', + to: '/nodebalancers/$id/configurations', }, { - routeName: `/nodebalancers/${id}/settings`, title: 'Settings', + to: '/nodebalancers/$id/settings', }, - ]; - - const matches = (pathName: string) => - Boolean(matchPath(location.pathname, { path: pathName })); + ]); if (isLoading) { return ; @@ -94,10 +91,6 @@ export const NodeBalancerDetail = () => { const nodeBalancerLabel = label !== undefined ? label : nodebalancer?.label; - const navToURL = (index: number) => { - history.push(tabs[index].routeName); - }; - return ( { variant="warning" /> )} - matches(tab.routeName)), - 0 - )} - onChange={navToURL} - > - - - - - - - - - - - - - + + + }> + + + + + + + + + + + + ); }; -export const nodeBalancerDetailLazyRoute = createLazyRoute( - '/nodebalancers/$nodeBalancerId' -)({ - component: NodeBalancerDetail, -}); +const NodeBalancerConfigurationWrapper = ( + props: NodeBalancerConfigurationsBaseProps +) => { + const { configId, id: nodeBalancerId } = useParams({ + strict: false, + }); + const match = useMatch({ + strict: false, + }); + + if ( + (match.routeId === '/nodebalancers/$id/configurations' && + !nodeBalancerId) || + (!configId && + match.routeId === '/nodebalancers/$id/configurations/$configId') + ) { + return null; + } + + const matchProps = { + params: { + configId, + id: nodeBalancerId, + }, + }; + + return ; +}; export default NodeBalancerDetail; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx index 1f980b22484..b8e5292c811 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx @@ -9,8 +9,12 @@ const firewall = firewallFactory.build({ label: 'mock-firewall-1' }); // Set up various mocks for tests +const navigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ + useMatch: vi.fn(() => ({})), + useNavigate: vi.fn(() => navigate), useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }), + useParams: vi.fn(() => ({})), })); vi.mock('@linode/queries', async () => { @@ -21,6 +25,16 @@ vi.mock('@linode/queries', async () => { }; }); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useMatch: queryMocks.useMatch, + useNavigate: queryMocks.useNavigate, + useParams: queryMocks.useParams, + }; +}); + const props = { displayFirewallInfoText: false, nodeBalancerId: 1, @@ -32,6 +46,9 @@ describe('NodeBalancerFirewalls', () => { data: { data: [firewall] }, isLoading: false, }); + queryMocks.useParams.mockReturnValue({ + id: '1', + }); }); afterEach(() => { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx index 05a8cb0e4e6..b49871ade3b 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx @@ -1,5 +1,10 @@ -import { useNodeBalancersFirewallsQuery } from '@linode/queries'; +import { + useAllFirewallDevicesQuery, + useFirewallQuery, + useNodeBalancersFirewallsQuery, +} from '@linode/queries'; import { Box, Button, Drawer, Stack, Typography } from '@linode/ui'; +import { useMatch, useNavigate } from '@tanstack/react-router'; import React from 'react'; import { Link } from 'src/components/Link'; @@ -14,10 +19,11 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { RemoveDeviceDialog } from 'src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog'; import { AddFirewallForm } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm'; +import { useDialogData } from 'src/hooks/useDialogData'; import { NodeBalancerFirewallsRow } from './NodeBalancerFirewallsRow'; -import type { Firewall, FirewallDevice } from '@linode/api-v4'; +import type { Firewall } from '@linode/api-v4'; interface Props { nodeBalancerId: number; @@ -25,7 +31,10 @@ interface Props { export const NodeBalancerFirewalls = (props: Props) => { const { nodeBalancerId } = props; - + const navigate = useNavigate(); + const match = useMatch({ + strict: false, + }); const { data: attachedFirewallData, error, @@ -34,27 +43,35 @@ export const NodeBalancerFirewalls = (props: Props) => { const attachedFirewalls = attachedFirewallData?.data; - const [selectedFirewall, setSelectedFirewall] = React.useState(); - - const [ - deviceToBeRemoved, - setDeviceToBeRemoved, - ] = React.useState(); - - const [ - isRemoveDeviceDialogOpen, - setIsRemoveDeviceDialogOpen, - ] = React.useState(false); + const isUnassignFirewallRoute = + match.routeId === + '/nodebalancers/$id/settings/unassign-firewall/$firewallId'; - const [ - isAddFirewallDrawerOpen, - setIsAddFirewalDrawerOpen, - ] = React.useState(false); - - const handleClickUnassign = (device: FirewallDevice, firewall: Firewall) => { - setDeviceToBeRemoved(device); - setSelectedFirewall(firewall); - setIsRemoveDeviceDialogOpen(true); + const { + data: selectedFirewall, + isFetching: isFetchingSelectedFirewall, + } = useDialogData({ + enabled: isUnassignFirewallRoute, + paramKey: 'firewallId', + queryHook: useFirewallQuery, + redirectToOnNotFound: '/nodebalancers/$id/settings', + }); + + const { data: devices, isFetching: isFetchingDevices } = useDialogData({ + enabled: isUnassignFirewallRoute, + paramKey: 'firewallId', + queryHook: useAllFirewallDevicesQuery, + redirectToOnNotFound: '/nodebalancers/$id/settings', + }); + + const handleClickUnassign = (firewall: Firewall) => { + navigate({ + params: { + firewallId: String(firewall.id), + id: String(nodeBalancerId), + }, + to: '/nodebalancers/$id/settings/unassign-firewall/$firewallId', + }); }; const renderTableContent = () => { @@ -72,10 +89,11 @@ export const NodeBalancerFirewalls = (props: Props) => { return attachedFirewalls.map((attachedFirewall) => ( handleClickUnassign(attachedFirewall)} /> )); }; @@ -94,9 +112,14 @@ export const NodeBalancerFirewalls = (props: Props) => { to your NodeBalancer. Only inbound rules are applied to NodeBalancers.
    + device.entity.type === 'nodebalancer' && + device.entity.id === nodeBalancerId + )} + onClose={() => + navigate({ + params: { id: String(nodeBalancerId) }, + to: '/nodebalancers/$id/settings', + }) + } + open={ + match.routeId === + '/nodebalancers/$id/settings/unassign-firewall/$firewallId' + } firewallId={selectedFirewall?.id ?? -1} firewallLabel={selectedFirewall?.label ?? ''} - onClose={() => setIsRemoveDeviceDialogOpen(false)} + isFetching={isFetchingDevices || isFetchingSelectedFirewall} onService - open={isRemoveDeviceDialogOpen} /> + navigate({ + params: { id: String(nodeBalancerId) }, + to: '/nodebalancers/$id/settings', + }) + } NotFoundComponent={NotFound} - onClose={() => setIsAddFirewalDrawerOpen(false)} - open={isAddFirewallDrawerOpen} + open={match.routeId === '/nodebalancers/$id/settings/add-firewall'} title="Add Firewall" > + navigate({ + params: { id: String(nodeBalancerId) }, + to: '/nodebalancers/$id/settings', + }) + } entityId={nodeBalancerId} entityType="nodebalancer" - onCancel={() => setIsAddFirewalDrawerOpen(false)} />
    diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx index 444e4e66b55..4a9ad264265 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx @@ -1,9 +1,9 @@ +import { useGrants, useProfile } from '@linode/queries'; import * as React from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { noPermissionTooltipText } from 'src/features/Firewalls/FirewallLanding/FirewallActionMenu'; +import { NO_PERMISSIONS_TOOLTIP_TEXT } from 'src/features/Firewalls/FirewallLanding/constants'; import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared'; -import { useGrants, useProfile } from '@linode/queries'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -27,7 +27,7 @@ export const NodeBalancerFirewallsActionMenu = (props: Props) => { const disabledProps = !userCanModifyFirewall ? { disabled: true, - tooltip: noPermissionTooltipText, + tooltip: NO_PERMISSIONS_TOOLTIP_TEXT, } : {}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx index 075ba49e67c..9a86a22b237 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx @@ -25,8 +25,9 @@ vi.mock('@linode/queries', async () => { }); const props = { + devices: [], firewall, - nodeBalancerID: 1, + nodeBalancerId: 1, onClickUnassign: vi.fn(), }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx index 53632915c04..18ad72c3b9f 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx @@ -9,34 +9,23 @@ import { getCountOfRules, getRuleString, } from 'src/features/Firewalls/FirewallLanding/FirewallRow'; -import { useAllFirewallDevicesQuery } from '@linode/queries'; import { NodeBalancerFirewallsActionMenu } from './NodeBalancerFirewallsActionMenu'; import type { Firewall, FirewallDevice } from '@linode/api-v4'; interface Props { + devices: FirewallDevice[] | undefined; firewall: Firewall; - nodeBalancerID: number; - onClickUnassign: ( - device: FirewallDevice | undefined, - firewall: Firewall - ) => void; + nodeBalancerId: number; + onClickUnassign: () => void; } export const NodeBalancerFirewallsRow = (props: Props) => { - const { firewall, nodeBalancerID, onClickUnassign } = props; + const { firewall, onClickUnassign } = props; const { id: firewallID, label, rules, status } = firewall; - const { data: devices } = useAllFirewallDevicesQuery(firewallID); - - const firewallDevice = devices?.find( - (device) => - device.entity.type === 'nodebalancer' && - device.entity.id === nodeBalancerID - ); - const count = getCountOfRules(rules); return ( @@ -54,7 +43,7 @@ export const NodeBalancerFirewallsRow = (props: Props) => { onClickUnassign(firewallDevice, firewall)} + onUnassign={onClickUnassign} /> diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx index 293bf129065..3cbfe38e965 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx @@ -1,6 +1,7 @@ +import { nodeBalancerFactory } from '@linode/utilities'; import * as React from 'react'; -import { firewallFactory, nodeBalancerFactory } from 'src/factories'; +import { firewallFactory } from 'src/factories'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -10,8 +11,11 @@ import { NodeBalancerSettings } from './NodeBalancerSettings'; vi.mock('src/hooks/useIsResourceRestricted'); const queryMocks = vi.hoisted(() => ({ + useMatch: vi.fn(() => ({})), + useNavigate: vi.fn(() => vi.fn()), useNodeBalancerQuery: vi.fn().mockReturnValue({ data: undefined }), useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }), + useParams: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { @@ -23,6 +27,16 @@ vi.mock('@linode/queries', async () => { }; }); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useMatch: queryMocks.useMatch, + useNavigate: queryMocks.useNavigate, + useParams: queryMocks.useParams, + }; +}); + const connectionThrottle = 'Connection Throttle'; describe('NodeBalancerSettings', () => { @@ -33,6 +47,7 @@ describe('NodeBalancerSettings', () => { queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ data: { data: [firewallFactory.build({ label: 'mock-firewall-1' })] }, }); + queryMocks.useParams.mockReturnValue({ id: 1 }); }); afterEach(() => { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx index d05391644df..25a4ad95897 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx @@ -1,3 +1,7 @@ +import { + useNodeBalancerQuery, + useNodebalancerUpdateMutation, +} from '@linode/queries'; import { Accordion, Button, @@ -6,25 +10,26 @@ import { TextField, } from '@linode/ui'; import { useTheme } from '@mui/material'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { useDialogData } from 'src/hooks/useDialogData'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; -import { - useNodeBalancerQuery, - useNodebalancerUpdateMutation, -} from '@linode/queries'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; import { NodeBalancerFirewalls } from './NodeBalancerFirewalls'; export const NodeBalancerSettings = () => { const theme = useTheme(); - const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); - const id = Number(nodeBalancerId); - const { data: nodebalancer } = useNodeBalancerQuery(id); + const navigate = useNavigate(); + const match = useMatch({ + strict: false, + }); + const { id } = useParams({ + strict: false, + }); + const { data: nodebalancer } = useNodeBalancerQuery(Number(id), Boolean(id)); const isNodeBalancerReadOnly = useIsResourceRestricted({ grantLevel: 'read_only', @@ -36,17 +41,13 @@ export const NodeBalancerSettings = () => { error: labelError, isPending: isUpdatingLabel, mutateAsync: updateNodeBalancerLabel, - } = useNodebalancerUpdateMutation(id); + } = useNodebalancerUpdateMutation(Number(id)); const { error: throttleError, isPending: isUpdatingThrottle, mutateAsync: updateNodeBalancerThrottle, - } = useNodebalancerUpdateMutation(id); - - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState( - false - ); + } = useNodebalancerUpdateMutation(Number(id)); const [label, setLabel] = React.useState(nodebalancer?.label); @@ -54,6 +55,16 @@ export const NodeBalancerSettings = () => { nodebalancer?.client_conn_throttle ); + const { + data: selectedNodeBalancer, + isFetching: isFetchingNodeBalancer, + } = useDialogData({ + enabled: !!id, + paramKey: 'id', + queryHook: useNodeBalancerQuery, + redirectToOnNotFound: '/nodebalancers', + }); + React.useEffect(() => { if (label !== nodebalancer?.label) { setLabel(nodebalancer?.label); @@ -97,7 +108,7 @@ export const NodeBalancerSettings = () => {
    - + { setIsDeleteDialogOpen(false)} - open={isDeleteDialogOpen} + isFetching={isFetchingNodeBalancer} + open={match.routeId === '/nodebalancers/$id/settings/delete'} + selectedNodeBalancer={selectedNodeBalancer} />
    ); }; - -export const nodeBalancerSettingsLazyRoute = createLazyRoute( - '/nodebalancers/$nodeBalancerId/settings' -)({ - component: NodeBalancerSettings, -}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx index a15d5ef286e..e5efa06488c 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx @@ -1,18 +1,18 @@ +import { useNodeBalancerQuery } from '@linode/queries'; import Grid from '@mui/material/Grid2'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { useNodeBalancerQuery } from '@linode/queries'; import { SummaryPanel } from './SummaryPanel'; import { TablesPanel } from './TablesPanel'; export const NodeBalancerSummary = () => { - const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); - const id = Number(nodeBalancerId); - const { data: nodebalancer } = useNodeBalancerQuery(id); + const { id } = useParams({ + from: '/nodebalancers/$id/summary', + }); + const { data: nodebalancer } = useNodeBalancerQuery(Number(id), Boolean(id)); return (
    @@ -28,9 +28,3 @@ export const NodeBalancerSummary = () => {
    ); }; - -export const nodeBalancerSummaryLazyRoute = createLazyRoute( - '/nodebalancers/$nodeBalancerId' -)({ - component: NodeBalancerSummary, -}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx index 81ce99abf20..41dc4477c66 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx @@ -1,23 +1,31 @@ -import { waitFor } from '@testing-library/react'; -import * as React from 'react'; - import { - firewallFactory, nodeBalancerConfigFactory, nodeBalancerFactory, -} from 'src/factories'; +} from '@linode/utilities'; +import { waitFor } from '@testing-library/react'; +import * as React from 'react'; + +import { firewallFactory } from 'src/factories'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { SummaryPanel } from './SummaryPanel'; -// Set up various mocks for tests const queryMocks = vi.hoisted(() => ({ useAllNodeBalancerConfigsQuery: vi.fn().mockReturnValue({ data: undefined }), useNodeBalancerQuery: vi.fn().mockReturnValue({ data: undefined }), useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }), + useParams: vi.fn().mockReturnValue({}), })); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { @@ -41,6 +49,7 @@ describe('SummaryPanel', () => { queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ data: { data: [firewallFactory.build({ label: 'mock-firewall-1' })] }, }); + queryMocks.useParams.mockReturnValue({ id: 1 }); }); afterEach(() => { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index 2a3dad1b501..069311161ce 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -8,26 +8,32 @@ import { import { Paper, Typography } from '@linode/ui'; import { convertMegabytesTo } from '@linode/utilities'; import { styled } from '@mui/material/styles'; +import { useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; import { Link } from 'src/components/Link'; import { TagCell } from 'src/components/TagCell/TagCell'; +import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useKubernetesClusterQuery } from 'src/queries/kubernetes'; export const SummaryPanel = () => { - const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); - const id = Number(nodeBalancerId); - const { data: nodebalancer } = useNodeBalancerQuery(id); - const { data: configs } = useAllNodeBalancerConfigsQuery(id); + const { id } = useParams({ + from: '/nodebalancers/$id/summary', + }); + const { data: nodebalancer } = useNodeBalancerQuery(Number(id), Boolean(id)); + const { data: configs } = useAllNodeBalancerConfigsQuery(Number(id)); const { data: regions } = useRegionsQuery(); - const { data: attachedFirewallData } = useNodeBalancersFirewallsQuery(id); + const { data: attachedFirewallData } = useNodeBalancersFirewallsQuery( + Number(id) + ); const linkText = attachedFirewallData?.data[0]?.label; const linkID = attachedFirewallData?.data[0]?.id; const region = regions?.find((r) => r.id === nodebalancer?.region); - const { mutateAsync: updateNodeBalancer } = useNodebalancerUpdateMutation(id); + const { mutateAsync: updateNodeBalancer } = useNodebalancerUpdateMutation( + Number(id) + ); const displayFirewallLink = !!attachedFirewallData?.data?.length; const isNodeBalancerReadOnly = useIsResourceRestricted({ @@ -36,17 +42,20 @@ export const SummaryPanel = () => { id: nodebalancer?.id, }); + const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); + // If we can't get the cluster (status === 'error'), we can assume it's been deleted - const { status: clusterStatus } = useKubernetesClusterQuery( - nodebalancer?.lke_cluster?.id ?? -1, - nodebalancer && Boolean(nodebalancer.lke_cluster), - { + const { status: clusterStatus } = useKubernetesClusterQuery({ + enabled: nodebalancer && Boolean(nodebalancer.lke_cluster), + id: nodebalancer?.lke_cluster?.id ?? -1, + options: { refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, retry: false, - } - ); + }, + isUsingBetaEndpoint, + }); const configPorts = configs?.reduce((acc, config) => { return [...acc, { configId: config.id, port: config.port }]; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.test.tsx index 67878e537d5..c4d1f2d13ec 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.test.tsx @@ -1,17 +1,24 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ +import { nodeBalancerFactory } from '@linode/utilities'; import * as React from 'react'; -import { nodeBalancerFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { TablesPanel } from './TablesPanel'; -// Set up various mocks for tests const queryMocks = vi.hoisted(() => ({ useNodeBalancerQuery: vi.fn().mockReturnValue({ data: undefined }), useNodeBalancerStatsQuery: vi.fn().mockReturnValue({ data: undefined }), + useParams: vi.fn().mockReturnValue({}), })); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { @@ -35,6 +42,7 @@ describe('TablesPanel', () => { queryMocks.useNodeBalancerQuery.mockReturnValue({ data: nodeBalancerFactory.build(), }); + queryMocks.useParams.mockReturnValue({ id: 1 }); }); afterEach(() => { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index ddac1fff05c..38efeaafe54 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -6,8 +6,8 @@ import { import { Box, CircleProgress, ErrorState, Paper, Typography } from '@linode/ui'; import { formatNumber, getMetrics } from '@linode/utilities'; import { styled, useTheme } from '@mui/material/styles'; +import { useParams } from '@tanstack/react-router'; 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'; @@ -30,9 +30,10 @@ export const TablesPanel = () => { const theme = useTheme(); const { data: profile } = useProfile(); const timezone = getUserTimezone(profile?.timezone); - const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); - const id = Number(nodeBalancerId); - const { data: nodebalancer } = useNodeBalancerQuery(id); + const { id } = useParams({ + from: '/nodebalancers/$id/summary', + }); + const { data: nodebalancer } = useNodeBalancerQuery(Number(id), Boolean(id)); const { data: stats, error, isLoading } = useNodeBalancerStatsQuery( nodebalancer?.id ?? -1 diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx index 4a3d494c358..4f737531ed4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx @@ -1,8 +1,8 @@ +import { nodeBalancerFactory } from '@linode/utilities'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { nodeBalancerFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NodeBalancerSelect } from './NodeBalancerSelect'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx index e976126eba0..be1beed0236 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx @@ -1,11 +1,10 @@ +import { useAllNodeBalancersQuery } from '@linode/queries'; import { Autocomplete, CustomPopper } from '@linode/ui'; +import { mapIdsToDevices } from '@linode/utilities'; import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import * as React from 'react'; -import { useAllNodeBalancersQuery } from '@linode/queries'; -import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; - import type { APIError, NodeBalancer } from '@linode/api-v4'; import type { SxProps, Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancers.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancers.tsx deleted file mode 100644 index 66084266b3f..00000000000 --- a/packages/manager/src/features/NodeBalancers/NodeBalancers.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { CircleProgress } from '@linode/ui'; -import * as React from 'react'; -import { Route, Switch } from 'react-router-dom'; - -import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; - -const NodeBalancerDetail = React.lazy(() => - import('./NodeBalancerDetail/NodeBalancerDetail').then((module) => ({ - default: module.NodeBalancerDetail, - })) -); -const NodeBalancersLanding = React.lazy( - () => import('./NodeBalancersLanding/NodeBalancersLanding') -); -const NodeBalancerCreate = React.lazy(() => import('./NodeBalancerCreate')); - -const NodeBalancers = () => { - return ( - }> - - - - - - - - ); -}; - -export default NodeBalancers; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx index e950688d3d4..0de7d906e43 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx @@ -5,6 +5,21 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { NodeBalancerActionMenu } from './NodeBalancerActionMenu'; +const navigate = vi.fn(); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => navigate), + useRouter: vi.fn(() => vi.fn()), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + useRouter: queryMocks.useRouter, + }; +}); + const props = { label: 'nodebalancer-1', nodeBalancerId: 1, @@ -33,6 +48,6 @@ describe('NodeBalancerActionMenu', () => { const deleteButton = getByText('Delete'); await userEvent.click(deleteButton); - expect(props.toggleDialog).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx index f24b602548d..db2c41178bd 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx @@ -1,7 +1,7 @@ import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Hidden } from 'src/components/Hidden'; @@ -13,17 +13,15 @@ import type { Theme } from '@mui/material/styles'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { - label: string; nodeBalancerId: number; - toggleDialog: (nodeBalancerId: number, label: string) => void; } export const NodeBalancerActionMenu = (props: Props) => { + const navigate = useNavigate(); const theme = useTheme(); const matchesMdDown = useMediaQuery(theme.breakpoints.down('lg')); - const history = useHistory(); - const { label, nodeBalancerId, toggleDialog } = props; + const { nodeBalancerId } = props; const isNodeBalancerReadOnly = useIsResourceRestricted({ grantLevel: 'read_only', @@ -34,20 +32,35 @@ export const NodeBalancerActionMenu = (props: Props) => { const actions: Action[] = [ { onClick: () => { - history.push(`/nodebalancers/${nodeBalancerId}/configurations`); + navigate({ + params: { + id: String(nodeBalancerId), + }, + to: `/nodebalancers/$id/configurations`, + }); }, title: 'Configurations', }, { onClick: () => { - history.push(`/nodebalancers/${nodeBalancerId}/settings`); + navigate({ + params: { + id: String(nodeBalancerId), + }, + to: `/nodebalancers/$id/settings`, + }); }, title: 'Settings', }, { disabled: isNodeBalancerReadOnly, onClick: () => { - toggleDialog(nodeBalancerId, label); + navigate({ + params: { + id: String(nodeBalancerId), + }, + to: `/nodebalancers/$id/delete`, + }); }, title: 'Delete', tooltip: isNodeBalancerReadOnly diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx index 21b88f7e9a6..35f1b35eba9 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx @@ -1,13 +1,26 @@ import { breakpoints } from '@linode/ui'; +import { nodeBalancerFactory } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { nodeBalancerFactory } from 'src/factories'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; import { NodeBalancerTableRow } from './NodeBalancerTableRow'; +const navigate = vi.fn(); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => navigate), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + vi.mock('src/hooks/useIsResourceRestricted'); const props = { @@ -47,7 +60,7 @@ describe('NodeBalancerTableRow', () => { const deleteButton = getByText('Delete'); await userEvent.click(deleteButton); - expect(props.onDelete).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalled(); }); it('does not delete the NodeBalancer if the delete button is disabled', async () => { @@ -55,7 +68,9 @@ describe('NodeBalancerTableRow', () => { const { getByText } = renderWithTheme(); const deleteButton = getByText('Delete'); + expect(deleteButton).toBeDisabled(); // Add this assertion + await userEvent.click(deleteButton); - expect(props.onDelete).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx index 35a8a16cdf0..65bd78280e2 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx @@ -14,12 +14,8 @@ import { NodeBalancerActionMenu } from './NodeBalancerActionMenu'; import type { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; -interface Props extends NodeBalancer { - onDelete: () => void; -} - -export const NodeBalancerTableRow = (props: Props) => { - const { id, ipv4, label, onDelete, region, transfer } = props; +export const NodeBalancerTableRow = (props: NodeBalancer) => { + const { id, ipv4, label, region, transfer } = props; const { data: configs } = useAllNodeBalancerConfigsQuery(id); @@ -73,11 +69,7 @@ export const NodeBalancerTableRow = (props: Props) => { - + ); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx index e320fd915ab..0f393a69699 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx @@ -1,14 +1,33 @@ +import { nodeBalancerFactory } from '@linode/utilities'; import { waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; -import { nodeBalancerFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import { NodeBalancersLanding } from './NodeBalancersLanding'; -beforeAll(() => mockMatchMedia()); +const queryMocks = vi.hoisted(() => ({ + useMatch: vi.fn().mockReturnValue({}), + useNavigate: vi.fn(() => vi.fn()), + useParams: vi.fn().mockReturnValue({}), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useMatch: queryMocks.useMatch, + useNavigate: queryMocks.useNavigate, + useParams: queryMocks.useParams, + }; +}); + +beforeAll(() => { + mockMatchMedia(); + queryMocks.useParams.mockReturnValue({ id: 1 }); +}); const loadingTestId = 'circle-progress'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx index 840495a67a1..80f2b6c7bfb 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx @@ -1,7 +1,7 @@ +import { useNodeBalancerQuery, useNodeBalancersQuery } from '@linode/queries'; import { CircleProgress, ErrorState } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Hidden } from 'src/components/Hidden'; @@ -15,26 +15,21 @@ import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useDialogData } from 'src/hooks/useDialogData'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useNodeBalancersQuery } from '@linode/queries'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; import { NodeBalancerTableRow } from './NodeBalancerTableRow'; + const preferenceKey = 'nodebalancers'; export const NodeBalancersLanding = () => { - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState( - false - ); - const [ - selectedNodeBalancerId, - setSelectedNodeBalancerId, - ] = React.useState(-1); - - const history = useHistory(); + const navigate = useNavigate(); + const match = useMatch({ strict: false }); + const params = useParams({ strict: false }); const pagination = usePagination(1, preferenceKey); const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_nodebalancers', @@ -61,14 +56,15 @@ export const NodeBalancersLanding = () => { filter ); - const selectedNodeBalancer = data?.data.find( - (nodebalancer) => nodebalancer.id === selectedNodeBalancerId - ); - - const onDelete = (nodeBalancerId: number) => { - setSelectedNodeBalancerId(nodeBalancerId); - setIsDeleteDialogOpen(true); - }; + const { + data: selectedNodeBalancer, + isFetching: isFetchingNodeBalancer, + } = useDialogData({ + enabled: !!params.id, + paramKey: 'id', + queryHook: useNodeBalancerQuery, + redirectToOnNotFound: '/nodebalancers', + }); if (error) { return ( @@ -90,6 +86,9 @@ export const NodeBalancersLanding = () => { <> { disabledCreateButton={isRestricted} docsLink="https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-nodebalancers" entity="NodeBalancer" - onButtonClick={() => history.push('/nodebalancers/create')} + onButtonClick={() => navigate({ to: '/nodebalancers/create' })} title="NodeBalancers" /> @@ -137,11 +136,7 @@ export const NodeBalancersLanding = () => { {data?.data.map((nodebalancer) => ( - onDelete(nodebalancer.id)} - {...nodebalancer} - /> + ))}
    @@ -155,17 +150,12 @@ export const NodeBalancersLanding = () => { /> setIsDeleteDialogOpen(false)} - open={isDeleteDialogOpen} + isFetching={isFetchingNodeBalancer} + open={match.routeId === '/nodebalancers/$id/delete'} + selectedNodeBalancer={selectedNodeBalancer} /> ); }; -export const nodeBalancersLandingLazyRoute = createLazyRoute('/nodebalancers')({ - component: NodeBalancersLanding, -}); - export default NodeBalancersLanding; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx index b3277aceb8b..b04b3f64eaf 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx @@ -6,6 +6,18 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + vi.mock('src/hooks/useRestrictedGlobalGrantCheck'); // Note: An integration test confirming the helper text and enabled Create NodeBalancer button already exists, so we're just checking for a disabled create button here diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx index 805981e2b7e..1f22b671b48 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx @@ -1,5 +1,5 @@ +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import NetworkIcon from 'src/assets/icons/entityIcons/networking.svg'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -16,8 +16,7 @@ import { } from './NodeBalancersLandingEmptyStateData'; export const NodeBalancerLandingEmptyState = () => { - const { push } = useHistory(); - + const navigate = useNavigate(); const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_nodebalancers', }); @@ -36,7 +35,9 @@ export const NodeBalancerLandingEmptyState = () => { category: linkAnalyticsEvent.category, label: 'Create NodeBalancer', }); - push('/nodebalancers/create'); + navigate({ + to: '/nodebalancers/create', + }); }, tooltipText: getRestrictedResourceText({ action: 'create', diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx index 384e504365c..ffe922d5ba9 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx @@ -1,8 +1,10 @@ +import { useIsGeckoEnabled } from '@linode/shared'; import { sortByString } from '@linode/utilities'; import * as React from 'react'; import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; +import { useFlags } from 'src/hooks/useFlags'; import { useIsObjectStorageGen2Enabled } from '../../hooks/useIsObjectStorageGen2Enabled'; import { WHITELISTED_REGIONS } from '../../utilities'; @@ -25,6 +27,11 @@ const sortRegionOptions = (a: Region, b: Region) => { export const AccessKeyRegions = (props: Props) => { const { disabled, error, onChange, required, selectedRegion } = props; + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const { allRegionsError, availableStorageRegions, @@ -47,6 +54,7 @@ export const AccessKeyRegions = (props: Props) => { disabled={disabled} errorText={errorText} isClearable={false} + isGeckoLAEnabled={isGeckoLAEnabled} label="Regions" onChange={onChange} regions={availableStorageRegions ?? []} diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx index 63a1a81ef19..f093cacf825 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx @@ -1,8 +1,9 @@ +import { regionFactory } from '@linode/utilities'; import '@testing-library/jest-dom'; import { waitFor } from '@testing-library/react'; import React from 'react'; -import { objectStorageKeyFactory, regionFactory } from 'src/factories'; +import { objectStorageKeyFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx index 89bb66640a1..7ad193597b8 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx @@ -1,8 +1,8 @@ +import { regionFactory } from '@linode/utilities'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { regionFactory } from 'src/factories/regions'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { HostNamesDrawer } from './HostNamesDrawer'; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesList.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesList.test.tsx index 115cc71f937..a6facc18c09 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesList.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesList.test.tsx @@ -1,9 +1,9 @@ +import { regionFactory } from '@linode/utilities'; import { screen } from '@testing-library/react'; import React from 'react'; -import { 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 { renderWithTheme } from 'src/utilities/testHelpers'; import { HostNamesList } from './HostNamesList'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx index 938b81d78fe..97e62f9bf99 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx @@ -1,4 +1,8 @@ -import { readableBytes, truncateMiddle } from '@linode/utilities'; +import { + readableBytes, + regionFactory, + truncateMiddle, +} from '@linode/utilities'; import { screen, waitFor } from '@testing-library/react'; import React from 'react'; import { vi } from 'vitest'; @@ -7,7 +11,6 @@ import { objectStorageBucketFactory, objectStorageBucketFactoryGen2, profileFactory, - regionFactory, } from 'src/factories'; import { formatDate } from 'src/utilities/formatDate'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.test.tsx index a6eeaca8f4f..de0ea605363 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.test.tsx @@ -1,7 +1,7 @@ +import { regionFactory } from '@linode/utilities'; import { screen } from '@testing-library/react'; import React from 'react'; -import { regionFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { BucketRegions } from './BucketRegions'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx index 36f85d91bf5..0b801690d70 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx @@ -1,7 +1,10 @@ +import { useIsGeckoEnabled } from '@linode/shared'; import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; +import { useFlags } from 'src/hooks/useFlags'; + import { useIsObjectStorageGen2Enabled } from '../hooks/useIsObjectStorageGen2Enabled'; import { WHITELISTED_REGIONS } from '../utilities'; @@ -24,6 +27,12 @@ export const BucketRegions = (props: Props) => { const { isObjectStorageGen2Enabled } = useIsObjectStorageGen2Enabled(); + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + // Error could be: 1. General Regions error, 2. Field error, 3. Nothing const errorText = error || allRegionsError?.[0]?.reason; @@ -36,6 +45,7 @@ export const BucketRegions = (props: Props) => { disableClearable disabled={disabled} errorText={errorText} + isGeckoLAEnabled={isGeckoLAEnabled} label="Region" onBlur={onBlur} onChange={(e, region) => onChange(region.id)} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index e3215ae4c62..45ef9827ed3 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -1,7 +1,9 @@ import { useRegionsQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useFlags } from 'src/hooks/useFlags'; import { useObjectStorageClusters } from 'src/queries/object-storage/queries'; import type { Region } from '@linode/api-v4/lib/regions'; @@ -28,6 +30,12 @@ export const ClusterSelect: React.FC = (props) => { const { data: clusters, error: clustersError } = useObjectStorageClusters(); const { data: regions } = useRegionsQuery(); + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + const regionOptions = clusters?.reduce((acc, cluster) => { const region = regions?.find((r) => r.id === cluster.region); if (region) { @@ -50,6 +58,7 @@ export const ClusterSelect: React.FC = (props) => { disableClearable disabled={disabled} errorText={errorText} + isGeckoLAEnabled={isGeckoLAEnabled} label="Region" onBlur={onBlur} onChange={(e, region) => onChange(region.id)} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx index f01236e1113..4a73de1f4fd 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -5,7 +6,6 @@ import * as React from 'react'; import { accountSettingsFactory, objectStorageClusterFactory, - regionFactory, } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.test.tsx index afaa30e96ad..f9c399301bb 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.test.tsx @@ -1,11 +1,8 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { - linodeFactory, - placementGroupFactory, - regionFactory, -} from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsAssignLinodesDrawer } from './PlacementGroupsAssignLinodesDrawer'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx index 3aa90c75ad2..049c9b512b9 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx @@ -7,6 +7,7 @@ import { useAllPlacementGroupsQuery, useAssignLinodesToPlacementGroup, } from '@linode/queries'; +import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, Box, @@ -24,7 +25,6 @@ import { DescriptionList } from 'src/components/DescriptionList/DescriptionList' import { NotFound } from 'src/components/NotFound'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import { LinodeSelect } from '../Linodes/LinodeSelect/LinodeSelect'; import { getLinodesFromAllPlacementGroups, hasPlacementGroupReachedCapacity, diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index 81dcae30e01..d069edc5990 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -3,6 +3,7 @@ import { useCreatePlacementGroup, useRegionsQuery, } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { ActionsPanel, Divider, @@ -31,6 +32,7 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { NotFound } from 'src/components/NotFound'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useFlags } from 'src/hooks/useFlags'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; @@ -42,15 +44,15 @@ import { hasRegionReachedPlacementGroupCapacity, } from './utils'; -import type { LinodeCreateType } from '../Linodes/LinodeCreate/types'; import type { PlacementGroupsCreateDrawerProps } from './types'; import type { CreatePlacementGroupPayload, PlacementGroup, Region, } from '@linode/api-v4'; +import type { DisableItemOption } from '@linode/ui'; +import type { LinodeCreateType } from '@linode/utilities'; import type { FormikHelpers } from 'formik'; -import type { DisableItemOption } from 'src/components/ListItemOption'; export const PlacementGroupsCreateDrawer = ( props: PlacementGroupsCreateDrawerProps @@ -62,6 +64,11 @@ export const PlacementGroupsCreateDrawer = ( open, selectedRegionId, } = props; + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const { data: regions } = useRegionsQuery(); const { data: allPlacementGroupsInRegion } = useAllPlacementGroupsQuery({ enabled: Boolean(selectedRegionId), @@ -266,6 +273,7 @@ export const PlacementGroupsCreateDrawer = ( disableClearable disabledRegions={disabledRegions} helperText={values.region && pgRegionLimitHelperText} + isGeckoLAEnabled={isGeckoLAEnabled} onChange={(e, region) => handleRegionSelect(region.id)} regions={regions ?? []} tooltipText="Only regions that support placement groups are listed." diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx index b28a18d1284..e5b10f4cd75 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.test.tsx @@ -1,7 +1,8 @@ +import { linodeFactory } from '@linode/utilities'; import { userEvent } from '@testing-library/user-event'; import * as React from 'react'; -import { linodeFactory, placementGroupFactory } from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsDeleteModal } from './PlacementGroupsDeleteModal'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx index ea98bd86674..734e673b6a2 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx @@ -1,10 +1,7 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import * as React from 'react'; -import { - linodeFactory, - placementGroupFactory, - regionFactory, -} from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { PlacementGroupsDetail } from './PlacementGroupsDetail'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx index 56a41adce9e..8bdfa94325e 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTable.test.tsx @@ -1,6 +1,6 @@ +import { linodeFactory } from '@linode/utilities'; import * as React from 'react'; -import { linodeFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsLinodesTable } from './PlacementGroupsLinodesTable'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx index befbd550601..f55395c1058 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.test.tsx @@ -1,8 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import * as React from 'react'; -import { linodeFactory } from 'src/factories'; -import { wrapWithTableBody } from 'src/utilities/testHelpers'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { wrapWithTableBody, renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsLinodesTableRow } from './PlacementGroupsLinodesTableRow'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsSummary/PlacementGroupsSummary.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsSummary/PlacementGroupsSummary.test.tsx index 27922913f5c..16b1f00d0bf 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsSummary/PlacementGroupsSummary.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsSummary/PlacementGroupsSummary.test.tsx @@ -1,6 +1,7 @@ +import { regionFactory } from '@linode/utilities'; import * as React from 'react'; -import { placementGroupFactory, regionFactory } from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsSummary } from './PlacementGroupsSummary'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx index eff250d88e4..4f8bf2fbefe 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx @@ -1,6 +1,7 @@ +import { regionFactory } from '@linode/utilities'; import * as React from 'react'; -import { placementGroupFactory, regionFactory } from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsDetailPanel } from './PlacementGroupsDetailPanel'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx index 11ce5ae6be1..f0f4b1afccd 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx @@ -1,7 +1,8 @@ +import { regionFactory } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { placementGroupFactory, regionFactory } from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsEditDrawer } from './PlacementGroupsEditDrawer'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx index 7b5fc4f1a8b..ae1c8bc754c 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx @@ -1,10 +1,7 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import * as React from 'react'; -import { - linodeFactory, - placementGroupFactory, - regionFactory, -} from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; import { renderWithTheme, resizeScreenSize, diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx index 14c5ab66a7c..ec54d37749f 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.test.tsx @@ -1,6 +1,6 @@ +import { linodeFactory } from '@linode/utilities'; import * as React from 'react'; -import { linodeFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsUnassignModal } from './PlacementGroupsUnassignModal'; diff --git a/packages/manager/src/features/PlacementGroups/utils.test.ts b/packages/manager/src/features/PlacementGroups/utils.test.ts index c50f7c351ec..c83ba1a4698 100644 --- a/packages/manager/src/features/PlacementGroups/utils.test.ts +++ b/packages/manager/src/features/PlacementGroups/utils.test.ts @@ -1,10 +1,7 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; import { renderHook } from '@testing-library/react'; -import { - linodeFactory, - placementGroupFactory, - regionFactory, -} from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; import { getLinodesFromAllPlacementGroups, diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index 225f9b6813a..82da40d92ae 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -118,6 +118,10 @@ export const CreateAPITokenDrawer = (props: Props) => { globalGrantType: 'child_account_access', }); + // Visually hide the "Child Account Access" permission even though it's still part of the base perms. + const hideChildAccountAccessScope = + profile?.user_type !== 'parent' || isChildAccountAccessRestricted; + const form = useFormik<{ expiry: string; label: string; @@ -128,7 +132,10 @@ export const CreateAPITokenDrawer = (props: Props) => { const { token } = await createPersonalAccessToken({ expiry: values.expiry, label: values.label, - scopes: permTuplesToScopeString(values.scopes), + scopes: permTuplesToScopeString( + values.scopes, + hideChildAccountAccessScope ? ['child_account'] : [] + ), }); onClose(); showSecret(token ?? 'Secret not available'); @@ -186,6 +193,19 @@ export const CreateAPITokenDrawer = (props: Props) => { invalidAccessLevels: [levelMap.read_only], name: 'vpc', }, + ...(hideChildAccountAccessScope + ? [ + { + defaultAccessLevel: levelMap.hidden, + invalidAccessLevels: [ + levelMap.read_only, + levelMap.read_write, + levelMap.none, + ], + name: 'child_account', + }, + ] + : []), ]; const indexOfColumnWhereAllAreSelected = allScopesAreTheSame( @@ -202,10 +222,6 @@ export const CreateAPITokenDrawer = (props: Props) => { // Filter permissions for all users except parent user accounts. const allPermissions = form.values.scopes; - // Visually hide the "Child Account Access" permission even though it's still part of the base perms. - const hideChildAccountAccessScope = - profile?.user_type !== 'parent' || isChildAccountAccessRestricted; - return ( { if (scopeTups.length !== perms.length) { return false; } - return scopeTups.reduce( - (acc: boolean, [key, value]: Permission) => - value === levelMap.read_write && acc, - true + const excludeSet = new Set(exclude); + return scopeTups.every( + ([key, value]) => value === levelMap.read_write || excludeSet.has(key) ); }; -export const permTuplesToScopeString = (scopeTups: Permission[]): string => { - if (allMaxPerm(scopeTups, basePerms)) { +export const permTuplesToScopeString = ( + scopeTups: Permission[], + exclude: PermissionKey[] +): string => { + if (allMaxPerm(scopeTups, basePerms, exclude)) { return '*'; } const joinedTups = scopeTups.reduce((acc, [key, value]) => { diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx index bedacf7a6d6..8fcb9118f2c 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx @@ -1,3 +1,4 @@ +import { useProfile, useSMSOptOutMutation } from '@linode/queries'; import { ActionsPanel, Box, @@ -12,7 +13,6 @@ import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Link } from 'src/components/Link'; -import { useSMSOptOutMutation, useProfile } from '@linode/queries'; import { getFormattedNumber } from './PhoneVerification/helpers'; diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index 2ebbc1e6539..4b7637ff80b 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -1,3 +1,4 @@ +import { useStackScriptsInfiniteQuery } from '@linode/queries'; import { useInfiniteVolumesQuery } from '@linode/queries'; import { useFirewallsInfiniteQuery } from '@linode/queries'; import { useInfiniteNodebalancersQuery } from '@linode/queries'; @@ -9,7 +10,6 @@ import { useDatabasesInfiniteQuery } from 'src/queries/databases/databases'; import { useDomainsInfiniteQuery } from 'src/queries/domains'; import { useImagesInfiniteQuery } from 'src/queries/images'; import { useKubernetesClustersInfiniteQuery } from 'src/queries/kubernetes'; -import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts'; import { databaseToSearchableItem, domainToSearchableItem, diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 974ec34e878..dc6e6b8d7a6 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -1,14 +1,15 @@ -import { useAllVolumesQuery } from '@linode/queries'; +import { useAllLinodesQuery } from '@linode/queries'; import { useAllFirewallsQuery } from '@linode/queries'; +import { useAllVolumesQuery } from '@linode/queries'; import { useAllNodeBalancersQuery } from '@linode/queries'; -import { useAllLinodesQuery } from '@linode/queries'; +import { useAllAccountStackScriptsQuery } from '@linode/queries'; +import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { useAllDatabasesQuery } from 'src/queries/databases/databases'; import { useAllDomainsQuery } from 'src/queries/domains'; import { useAllImagesQuery } from 'src/queries/images'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; -import { useAllAccountStackScriptsQuery } from 'src/queries/stackscripts'; import { bucketToSearchableItem, databaseToSearchableItem, @@ -41,11 +42,12 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { error: domainsError, isLoading: domainsLoading, } = useAllDomainsQuery(enabled); + const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { data: clusters, error: lkeClustersError, isLoading: lkeClustersLoading, - } = useAllKubernetesClustersQuery(enabled); + } = useAllKubernetesClustersQuery({ enabled, isUsingBetaEndpoint }); const { data: volumes, error: volumesError, diff --git a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx index 9bf7be06fea..c6657f96d97 100644 --- a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx @@ -1,4 +1,9 @@ import { yupResolver } from '@hookform/resolvers/yup'; +import { + useCreateStackScriptMutation, + useGrants, + useProfile, +} from '@linode/queries'; import { Box, Button, Notice, Paper, Stack } from '@linode/ui'; import { stackScriptSchema } from '@linode/validation'; import { useNavigate } from '@tanstack/react-router'; @@ -9,8 +14,6 @@ import { FormProvider, useForm } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useGrants, useProfile } from '@linode/queries'; -import { useCreateStackScriptMutation } from 'src/queries/stackscripts'; import { StackScriptForm } from '../StackScriptForm/StackScriptForm'; diff --git a/packages/manager/src/features/StackScripts/StackScriptEdit.tsx b/packages/manager/src/features/StackScripts/StackScriptEdit.tsx index 1428dc80d23..d6cb66a41e5 100644 --- a/packages/manager/src/features/StackScripts/StackScriptEdit.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptEdit.tsx @@ -1,4 +1,8 @@ import { yupResolver } from '@hookform/resolvers/yup'; +import { + useStackScriptQuery, + useUpdateStackScriptMutation, +} from '@linode/queries'; import { useGrants, useProfile } from '@linode/queries'; import { Button, @@ -19,10 +23,6 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { NotFound } from 'src/components/NotFound'; -import { - useStackScriptQuery, - useUpdateStackScriptMutation, -} from 'src/queries/stackscripts'; import { getRestrictedResourceText } from '../Account/utils'; import { StackScriptForm } from './StackScriptForm/StackScriptForm'; diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptDeleteDialog.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptDeleteDialog.tsx index c76ee21d371..f3c581bdbfa 100644 --- a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptDeleteDialog.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptDeleteDialog.tsx @@ -1,9 +1,9 @@ +import { useDeleteStackScriptMutation } from '@linode/queries'; import { Button, Stack, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useDeleteStackScriptMutation } from 'src/queries/stackscripts'; import type { StackScript } from '@linode/api-v4'; diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx index 9c5335bf03d..f9b29d6191a 100644 --- a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx @@ -1,3 +1,7 @@ +import { + useStackScriptQuery, + useStackScriptsInfiniteQuery, +} from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { CircleProgress, ErrorState, Stack, TooltipIcon } from '@linode/ui'; import { @@ -25,8 +29,6 @@ import { } from 'src/features/Linodes/LinodeCreate/Tabs/StackScripts/utilities'; import { useDialogData } from 'src/hooks/useDialogData'; import { useOrderV2 } from 'src/hooks/useOrderV2'; -import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts'; -import { useStackScriptQuery } from 'src/queries/stackscripts'; import { StackScriptSearchHelperText } from '../Partials/StackScriptSearchHelperText'; import { StackScriptsEmptyLandingState } from '../StackScriptBase/StackScriptsEmptyLandingPage'; diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptMakePublicDialog.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptMakePublicDialog.tsx index c5f9830fda2..fb002e1f344 100644 --- a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptMakePublicDialog.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptMakePublicDialog.tsx @@ -1,9 +1,9 @@ +import { useUpdateStackScriptMutation } from '@linode/queries'; import { Button, Stack, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useUpdateStackScriptMutation } from 'src/queries/stackscripts'; import type { StackScript } from '@linode/api-v4'; diff --git a/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx b/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx index 31380b89d24..2a7c6fa9497 100644 --- a/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx @@ -1,3 +1,7 @@ +import { + useStackScriptQuery, + useUpdateStackScriptMutation, +} from '@linode/queries'; import { useGrants, useProfile } from '@linode/queries'; import { CircleProgress, ErrorState, Paper } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; @@ -9,10 +13,6 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { NotFound } from 'src/components/NotFound'; import { StackScript } from 'src/components/StackScript/StackScript'; -import { - useStackScriptQuery, - useUpdateStackScriptMutation, -} from 'src/queries/stackscripts'; import { getRestrictedResourceText } from '../Account/utils'; import { diff --git a/packages/manager/src/features/StackScripts/stackScriptUtils.ts b/packages/manager/src/features/StackScripts/stackScriptUtils.ts index 1c76348e6f0..318554b03ab 100644 --- a/packages/manager/src/features/StackScripts/stackScriptUtils.ts +++ b/packages/manager/src/features/StackScripts/stackScriptUtils.ts @@ -1,28 +1,4 @@ -import { getStackScripts } from '@linode/api-v4'; - -import type { - Grant, - Params, - StackScript, - StackScriptPayload, -} from '@linode/api-v4'; - -const oneClickFilter = [ - { - '+and': [ - { '+or': [{ username: 'linode-stackscripts' }, { username: 'linode' }] }, - { - label: { - '+contains': 'One-Click', - }, - }, - ], - '+order_by': 'ordinal', - }, -]; - -export const getOneClickApps = (params?: Params) => - getStackScripts(params, oneClickFilter); +import type { Grant, StackScript, StackScriptPayload } from '@linode/api-v4'; export const getStackScriptUrl = ( username: string, diff --git a/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx b/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx index e3da110d994..f8b941921e1 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx @@ -1,9 +1,9 @@ +import { useSupportTicketCloseMutation } from '@linode/queries'; import { ActionsPanel, Typography } from '@linode/ui'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useSupportTicketCloseMutation } from 'src/queries/support'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx index a977c0b73e2..1582191ebbd 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx @@ -1,6 +1,11 @@ +import { + useInfiniteSupportTicketRepliesQuery, + useProfile, + useSupportTicketQuery, +} from '@linode/queries'; import { CircleProgress, ErrorState, Stack } from '@linode/ui'; -import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Grid2'; +import { styled } from '@mui/material/styles'; import { createLazyRoute } from '@tanstack/react-router'; import { isEmpty } from 'ramda'; import * as React from 'react'; @@ -9,11 +14,6 @@ import { Waypoint } from 'react-waypoint'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { useProfile } from '@linode/queries'; -import { - useInfiniteSupportTicketRepliesQuery, - useSupportTicketQuery, -} from 'src/queries/support'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; import { ExpandableTicketPanel } from '../ExpandableTicketPanel'; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyContainer.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyContainer.tsx index 2c11ef71073..ec28e6d064f 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyContainer.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyContainer.tsx @@ -1,4 +1,5 @@ import { uploadAttachment } from '@linode/api-v4'; +import { useSupportTicketReplyMutation } from '@linode/queries'; import { Accordion, Notice } from '@linode/ui'; import Grid from '@mui/material/Grid2'; import { lensPath, set } from 'ramda'; @@ -6,7 +7,6 @@ import * as React from 'react'; import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; -import { useSupportTicketReplyMutation } from 'src/queries/support'; import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { storage } from 'src/utilities/storage'; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index de5fa30859a..03041bf5042 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -1,5 +1,6 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { uploadAttachment } from '@linode/api-v4/lib/support'; +import { useCreateSupportTicketMutation } from '@linode/queries'; import { Accordion, ActionsPanel, @@ -17,7 +18,6 @@ import { Controller, FormProvider, useForm } from 'react-hook-form'; import { useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; -import { useCreateSupportTicketMutation } from 'src/queries/support'; import { sendSupportTicketExitEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { storage, supportTicketStorageDefaults } from 'src/utilities/storage'; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index d126f94a19e..6b2ed629a27 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -9,6 +9,7 @@ import { Autocomplete, FormHelperText, TextField } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; +import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { useAllDatabasesQuery } from 'src/queries/databases/databases'; import { useAllDomainsQuery } from 'src/queries/domains'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; @@ -70,11 +71,15 @@ export const SupportTicketProductSelectionFields = (props: Props) => { isLoading: nodebalancersLoading, } = useAllNodeBalancersQuery(entityType === 'nodebalancer_id'); + const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { data: clusters, error: clustersError, isLoading: clustersLoading, - } = useAllKubernetesClustersQuery(entityType === 'lkecluster_id'); + } = useAllKubernetesClustersQuery({ + enabled: entityType === 'lkecluster_id', + isUsingBetaEndpoint, + }); const { data: linodes, diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx index 0f42f3bcb09..e0e8332821b 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx @@ -12,7 +12,7 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { SupportTicketDialog } from './SupportTicketDialog'; -import TicketList from './TicketList'; +import { TicketList } from './TicketList'; import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; import type { BaseQueryParams } from '@linode/utilities'; diff --git a/packages/manager/src/features/Support/SupportTickets/TicketList.test.tsx b/packages/manager/src/features/Support/SupportTickets/TicketList.test.tsx index 1a0c82886b3..be2d7404c3b 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketList.test.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketList.test.tsx @@ -3,10 +3,12 @@ import * as React from 'react'; import { supportTicketFactory } 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 { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; -import { Props, TicketList } from './TicketList'; +import { TicketList } from './TicketList'; + +import type { Props } from './TicketList'; beforeAll(() => mockMatchMedia()); diff --git a/packages/manager/src/features/Support/SupportTickets/TicketList.tsx b/packages/manager/src/features/Support/SupportTickets/TicketList.tsx index fddf8f1804a..fbbe76c282c 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketList.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketList.tsx @@ -1,3 +1,4 @@ +import { useSupportTicketsQuery } from '@linode/queries'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -13,7 +14,6 @@ import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading' import { TableSortCell } from 'src/components/TableSortCell'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useSupportTicketsQuery } from 'src/queries/support'; import { TicketRow } from './TicketRow'; import { getStatusFilter, useTicketSeverityCapability } from './ticketUtils'; @@ -187,5 +187,3 @@ export const TicketList = (props: Props) => { ); }; - -export default TicketList; diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.styles.ts b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.styles.ts index e793fdbd1e1..353b13b29da 100644 --- a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.styles.ts +++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.styles.ts @@ -6,7 +6,7 @@ import Add from 'src/assets/icons/add.svg'; export const StyledHeading = styled('h3', { label: 'StyledHeading', - shouldForwardProp: omittedProps(['paddingTop']), + shouldForwardProp: omittedProps(['paddingTop', 'marginTop']), })<{ marginTop?: boolean }>(({ theme, ...props }) => ({ '& svg': { height: 20, diff --git a/packages/manager/src/features/TopMenu/CreateMenu/ProductFamilyGroup.tsx b/packages/manager/src/features/TopMenu/CreateMenu/ProductFamilyGroup.tsx index 704ac350474..e34b5130a35 100644 --- a/packages/manager/src/features/TopMenu/CreateMenu/ProductFamilyGroup.tsx +++ b/packages/manager/src/features/TopMenu/CreateMenu/ProductFamilyGroup.tsx @@ -1,5 +1,4 @@ -import { Stack } from '@linode/ui'; -import { Typography } from '@linode/ui'; +import { Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 8433189c58c..ec272fbef40 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -208,7 +208,9 @@ export const SearchBar = () => { ...params.inputProps, sx: { '&::placeholder': { - color: theme.tokens.component.GlobalHeader.Search.Text.Placeholder, + color: + theme.tokens.component.GlobalHeader.Search.Text + .Placeholder, }, }, }, @@ -220,9 +222,13 @@ export const SearchBar = () => { sx={{ '> svg': { '&:hover': { - color: theme.tokens.component.GlobalHeader.Search.Icon.Hover, + color: + theme.tokens.component.GlobalHeader.Search + .Icon.Hover, }, - color: theme.tokens.component.GlobalHeader.Search.Icon.Default, + color: + theme.tokens.component.GlobalHeader.Search.Icon + .Default, }, padding: 0, [theme.breakpoints.up('sm')]: { @@ -245,18 +251,32 @@ export const SearchBar = () => { ), sx: { '&:active, &:focus, &.Mui-focused, &.Mui-focused:hover': { - backgroundColor: theme.tokens.component.GlobalHeader.Search.Background, - borderColor: theme.tokens.component.GlobalHeader.Search.Border.Active, - color: theme.tokens.component.GlobalHeader.Search.Text.Filled, + backgroundColor: + theme.tokens.component.GlobalHeader.Search.Background, + borderColor: + theme.tokens.component.GlobalHeader.Search.Border + .Active, + color: + theme.tokens.component.GlobalHeader.Search.Text + .Filled, }, '&:hover': { - backgroundColor: theme.tokens.component.GlobalHeader.Search.Background, - borderColor: theme.tokens.component.GlobalHeader.Search.Border.Hover, - color: theme.tokens.component.GlobalHeader.Search.Text.Filled, + backgroundColor: + theme.tokens.component.GlobalHeader.Search.Background, + borderColor: + theme.tokens.component.GlobalHeader.Search.Border + .Hover, + color: + theme.tokens.component.GlobalHeader.Search.Text + .Filled, }, - backgroundColor: theme.tokens.component.GlobalHeader.Search.Background, - borderColor: theme.tokens.component.GlobalHeader.Search.Border.Default, - color: theme.tokens.component.GlobalHeader.Search.Text.Filled, + backgroundColor: + theme.tokens.component.GlobalHeader.Search.Background, + borderColor: + theme.tokens.component.GlobalHeader.Search.Border + .Default, + color: + theme.tokens.component.GlobalHeader.Search.Text.Filled, maxWidth: '100%', }, }, diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 1d6269981b0..67b5b1c47d8 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -1,6 +1,8 @@ +import { useAccount, useProfile } from '@linode/queries'; import { Button, ChevronDownIcon, + ChevronUpIcon, Stack, Tooltip, Typography, @@ -12,11 +14,9 @@ import { useTheme } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import ChevronUp from 'src/assets/icons/chevron-up.svg'; import { Avatar } from 'src/components/Avatar/Avatar'; import { AvatarForProxy } from 'src/components/AvatarForProxy'; import { SwitchAccountDrawer } from 'src/features/Account/SwitchAccountDrawer'; -import { useAccount, useProfile } from '@linode/queries'; import { getStorage, setStorage } from 'src/utilities/storage'; import { UserMenuPopover } from './UserMenuPopover'; @@ -81,7 +81,7 @@ export const UserMenu = React.memo(() => { return undefined; } return open ? ( - + ) : ( { return ( {link.display} @@ -158,6 +161,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { slotProps={{ paper: { sx: (theme) => ({ + backgroundColor: theme.tokens.alias.Background.Normal, paddingX: theme.tokens.spacing.S24, paddingY: theme.tokens.spacing.S16, }), @@ -179,11 +183,19 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { > theme.tokens.spacing.S8}> {canSwitchBetweenParentOrProxyAccount && ( - Current account: + ({ + color: theme.tokens.alias.Content.Text.Primary.Default, + font: theme.tokens.alias.Typography.Label.Semibold.S, + })} + > + Current account: + )} ({ - font: theme.tokens.alias.Typography.Heading.M, + color: theme.tokens.alias.Content.Text.Primary.Default, + font: theme.tokens.alias.Typography.Label.Bold.L, })} > {canSwitchBetweenParentOrProxyAccount && companyNameOrEmail @@ -205,10 +217,10 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { My Profile - + {profileLinks.slice(0, 4).map(renderLink)} - + {profileLinks.slice(4).map(renderLink)} @@ -224,10 +236,13 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { {accountLinks.map((menuLink) => menuLink.hide ? null : ( {menuLink.display} @@ -243,6 +258,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { }; const Heading = styled(Typography)(({ theme }) => ({ + color: theme.tokens.alias.Content.Text.Primary.Default, font: theme.tokens.alias.Typography.Heading.Overline, letterSpacing: theme.tokens.alias.Typography.Heading.OverlineLetterSpacing, textTransform: theme.tokens.alias.Typography.Heading.OverlineTextCase, diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx index 9f3c15074ec..64a6b3a6816 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx @@ -15,7 +15,7 @@ import { } from './VPCCreateForm.styles'; import type { CreateVPCPayload } from '@linode/api-v4'; -import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types'; +import type { LinodeCreateType } from '@linode/utilities'; import type { LinodeCreateQueryParams } from 'src/features/Linodes/types'; interface Props { diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index ff986f11b1c..41701390745 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -1,3 +1,4 @@ +import { useIsGeckoEnabled } from '@linode/shared'; import { TextField } from '@linode/ui'; import { getQueryParamsFromQueryString } from '@linode/utilities'; import * as React from 'react'; @@ -6,6 +7,7 @@ import { useLocation } from 'react-router-dom'; import { Link } from 'src/components/Link'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useFlags } from 'src/hooks/useFlags'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { VPC_CREATE_FORM_VPC_HELPER_TEXT } from '../../constants'; @@ -13,7 +15,7 @@ import { StyledBodyTypography } from './VPCCreateForm.styles'; import type { Region } from '@linode/api-v4'; import type { CreateVPCPayload } from '@linode/api-v4'; -import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types'; +import type { LinodeCreateType } from '@linode/utilities'; import type { LinodeCreateQueryParams } from 'src/features/Linodes/types'; interface Props { @@ -25,6 +27,11 @@ interface Props { export const VPCTopSectionContent = (props: Props) => { const { disabled, isDrawer, regions } = props; const location = useLocation(); + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); const queryParams = getQueryParamsFromQueryString( location.search @@ -59,6 +66,7 @@ export const VPCTopSectionContent = (props: Props) => { currentCapability="VPCs" disabled={isDrawer ? true : disabled} errorText={fieldState.error?.message} + isGeckoLAEnabled={isGeckoLAEnabled} onBlur={field.onBlur} onChange={(_, region) => field.onChange(region?.id ?? '')} regions={regions} diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx index fbef0006bfa..f7ae9f77def 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -1,7 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import { fireEvent, waitFor } from '@testing-library/react'; import * as React from 'react'; -import { linodeFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 40896631c32..9ec5d22230e 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -5,6 +5,7 @@ import { useGrants, useProfile, } from '@linode/queries'; +import { LinodeSelect } from '@linode/shared'; import { Autocomplete, Box, @@ -18,6 +19,7 @@ import { TooltipIcon, Typography, } from '@linode/ui'; +import { useFormattedDate } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -26,12 +28,10 @@ import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; import { Link } from 'src/components/Link'; import { NotFound } from 'src/components/NotFound'; import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP, VPC_MULTIPLE_CONFIGURATIONS_LEARN_MORE_LINK, } from 'src/features/VPCs/constants'; -import { useFormattedDate } from 'src/hooks/useFormattedDate'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; import { getErrorMap } from 'src/utilities/errorUtils'; 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 83b39cda728..256b17e5d2c 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx @@ -13,7 +13,7 @@ import { Stack, TextField, } from '@linode/ui'; -import { createSubnetSchema } from '@linode/validation'; +import { createSubnetSchemaIPv4 } from '@linode/validation'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -25,7 +25,7 @@ import { getRecommendedSubnetIPv4, } from 'src/utilities/subnets'; -import type { CreateSubnetPayload } from '@linode/api-v4'; +import type { CreateSubnetPayload, Subnet } from '@linode/api-v4'; interface Props { onClose: () => void; @@ -44,7 +44,7 @@ export const SubnetCreateDrawer = (props: Props) => { const recommendedIPv4 = getRecommendedSubnetIPv4( DEFAULT_SUBNET_IPV4_VALUE, - vpc?.subnets?.map((subnet) => subnet.ipv4 ?? '') ?? [] + vpc?.subnets?.map((subnet: Subnet) => subnet.ipv4 ?? '') ?? [] ); const { @@ -66,7 +66,7 @@ export const SubnetCreateDrawer = (props: Props) => { label: '', }, mode: 'onBlur', - resolver: yupResolver(createSubnetSchema), + resolver: yupResolver(createSubnetSchemaIPv4), }); const ipv4 = watch('ipv4'); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx index ff12dab22a9..b5d9afe83b7 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx @@ -1,6 +1,7 @@ import { linodeConfigInterfaceFactory, linodeConfigInterfaceFactoryWithVPC, + linodeFactory, } from '@linode/utilities'; import { fireEvent, @@ -15,7 +16,6 @@ import { subnetFactory, } from 'src/factories'; import { linodeConfigFactory } from 'src/factories/linodeConfigs'; -import { linodeFactory } from 'src/factories/linodes'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index 6102cdee233..8a7eb0cedc6 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -13,6 +13,7 @@ import { Stack, Typography, } from '@linode/ui'; +import { useFormattedDate } from '@linode/utilities'; import { useQueryClient } from '@tanstack/react-query'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -21,7 +22,6 @@ import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; import { NotFound } from 'src/components/NotFound'; import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; import { SUBNET_UNASSIGN_LINODES_WARNING } from 'src/features/VPCs/constants'; -import { useFormattedDate } from 'src/hooks/useFormattedDate'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index f94cfc1e712..497c31d7846 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -1,17 +1,11 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { - useGrants, - useProfile, - useRegionsQuery, - useUpdateVPCMutation, -} from '@linode/queries'; +import { useGrants, useProfile, useUpdateVPCMutation } from '@linode/queries'; import { ActionsPanel, Drawer, Notice, TextField } from '@linode/ui'; import { updateVPCSchema } from '@linode/validation'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { NotFound } from 'src/components/NotFound'; -import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import type { UpdateVPCPayload, VPC } from '@linode/api-v4'; @@ -21,8 +15,6 @@ interface Props { vpc?: VPC; } -const REGION_HELPER_TEXT = 'Region cannot be changed during beta.'; - export const VPCEditDrawer = (props: Props) => { const { onClose, open, vpc } = props; @@ -75,8 +67,6 @@ export const VPCEditDrawer = (props: Props) => { } }; - const { data: regionsData, error: regionsError } = useRegionsQuery(); - return ( { control={control} name="description" /> - {regionsData && ( - null} - regions={regionsData} - value={vpc?.region} - /> - )} ({ })); export const VolumeCreate = () => { + const flags = useFlags(); const theme = useTheme(); const navigate = useNavigate(); const { classes } = useStyles(); @@ -136,6 +137,10 @@ export const VolumeCreate = () => { const { data: grants } = useGrants(); const { data: regions } = useRegionsQuery(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const { mutateAsync: createVolume } = useCreateVolumeMutation(); @@ -410,6 +415,7 @@ export const VolumeCreate = () => { currentCapability="Block Storage" disabled={doesNotHavePermission} errorText={touched.region ? errors.region : undefined} + isGeckoLAEnabled={isGeckoLAEnabled} label="Region" onBlur={handleBlur} regions={regions ?? []} diff --git a/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.test.tsx b/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.test.tsx index 43f74766a0b..0542b51e9b9 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.test.tsx @@ -1,7 +1,6 @@ -import { formatPlanTypes } from '@linode/utilities'; +import { formatPlanTypes, regionFactory } from '@linode/utilities'; import React from 'react'; -import { regionFactory } from 'src/factories/regions'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlansAvailabilityNotice } from './PlansAvailabilityNotice'; diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index de5053e9a65..1777e2180ed 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -1,4 +1,5 @@ import { useRegionAvailabilityQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Notice } from '@linode/ui'; import { getQueryParamsFromQueryString, @@ -10,7 +11,6 @@ import { useLocation } from 'react-router-dom'; import { getIsDistributedRegion, isDistributedRegionSupported, - useIsGeckoEnabled, } from 'src/components/RegionSelect/RegionSelect.utils'; import { TabbedPanel } from 'src/components/TabbedPanel/TabbedPanel'; import { useFlags } from 'src/hooks/useFlags'; @@ -88,7 +88,10 @@ export const PlansPanel = (props: PlansPanelProps) => { } = props; const flags = useFlags(); - const { isGeckoLAEnabled } = useIsGeckoEnabled(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); const location = useLocation(); const params = getQueryParamsFromQueryString( location.search diff --git a/packages/manager/src/features/components/PlansPanel/constants.ts b/packages/manager/src/features/components/PlansPanel/constants.ts index ebd4b1ee140..6b52c75c2dc 100644 --- a/packages/manager/src/features/components/PlansPanel/constants.ts +++ b/packages/manager/src/features/components/PlansPanel/constants.ts @@ -29,9 +29,8 @@ export const GPU_COMPUTE_INSTANCES_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/gpu-compute-instances'; export const TRANSFER_COSTS_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/network-transfer-usage-and-costs'; -// TODO: accelerated plans - update to GA link (when GA launches) export const ACCELERATED_COMPUTE_INSTANCES_LINK = - 'https://techdocs.akamai.com/cloud-computing/docs/accelerated-compute-instances-beta'; + 'https://techdocs.akamai.com/cloud-computing/docs/accelerated-compute-instances'; export const DEDICATED_512_GB_PLAN: ExtendedType = { accelerated_devices: 0, diff --git a/packages/manager/src/features/components/PlansPanel/utils.test.ts b/packages/manager/src/features/components/PlansPanel/utils.test.ts index b1988601ab2..e17ff6990ab 100644 --- a/packages/manager/src/features/components/PlansPanel/utils.test.ts +++ b/packages/manager/src/features/components/PlansPanel/utils.test.ts @@ -1,7 +1,7 @@ +import { regionAvailabilityFactory } from '@linode/utilities'; import { renderHook } from '@testing-library/react'; import { extendedTypes } from 'src/__data__/ExtendedType'; -import { regionAvailabilityFactory } from 'src/factories'; import { planSelectionTypeFactory, typeFactory } from 'src/factories/types'; import { PLAN_IS_CURRENTLY_UNAVAILABLE_COPY } from './constants'; diff --git a/packages/manager/src/hooks/useCreateVPC.ts b/packages/manager/src/hooks/useCreateVPC.ts index 3bb7cfda10a..d2eff39607b 100644 --- a/packages/manager/src/hooks/useCreateVPC.ts +++ b/packages/manager/src/hooks/useCreateVPC.ts @@ -19,7 +19,7 @@ import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEvent import { DEFAULT_SUBNET_IPV4_VALUE } from 'src/utilities/subnets'; import type { CreateVPCPayload, VPC } from '@linode/api-v4'; -import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types'; +import type { LinodeCreateType } from '@linode/utilities'; // Custom hook to consolidate shared logic between VPCCreate.tsx and VPCCreateDrawer.tsx export interface UseCreateVPCInputs { diff --git a/packages/manager/src/hooks/useDefaultFirewallChipInformation.ts b/packages/manager/src/hooks/useDefaultFirewallChipInformation.ts new file mode 100644 index 00000000000..77e5e9c3ce5 --- /dev/null +++ b/packages/manager/src/hooks/useDefaultFirewallChipInformation.ts @@ -0,0 +1,42 @@ +import { useFirewallSettingsQuery } from '@linode/queries'; + +import { + getDefaultFirewallDescription, + getFirewallDefaultEntities, +} from 'src/features/Firewalls/components/FirewallSelectOption.utils'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; + +/** + * Hook to obtain the information regarding Default Firewalls which can be used for + * for Default Firewall chips. + * + * Determines if the given firewall (via ID) is a default firewall + * Determines the tooltip and chip text to be used for the chip + */ +export const useDefaultFirewallChipInformation = ( + firewallId: null | number | undefined, + hideDefaultChips?: boolean +) => { + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + + const { data: firewallSettings } = useFirewallSettingsQuery({ + enabled: isLinodeInterfacesEnabled && !hideDefaultChips, + }); + + const tooltipText = + firewallId && + firewallSettings && + getDefaultFirewallDescription(firewallId, firewallSettings); + const defaultNumEntities = + firewallSettings && firewallId + ? getFirewallDefaultEntities(firewallId, firewallSettings).length + : 0; + + const isDefault = !!tooltipText; + + return { + defaultNumEntities, + isDefault, + tooltipText, + }; +}; diff --git a/packages/manager/src/hooks/usePendo.ts b/packages/manager/src/hooks/usePendo.ts index b16624dcbae..83166b48cd5 100644 --- a/packages/manager/src/hooks/usePendo.ts +++ b/packages/manager/src/hooks/usePendo.ts @@ -1,6 +1,5 @@ import { useAccount, useProfile } from '@linode/queries'; import { loadScript } from '@linode/utilities'; // `loadScript` from `useScript` hook -import { sha256 } from 'js-sha256'; import React from 'react'; import { APP_ROOT, PENDO_API_KEY } from 'src/constants'; @@ -19,17 +18,17 @@ declare global { /** * This function prevents address ID collisions leading to muddled data between environments. Account and visitor IDs must be unique per API-key. * See: https://support.pendo.io/hc/en-us/articles/360031862352-Pendo-in-multiple-environments-for-development-and-testing - * @returns Unique SHA256 hash of ID and the environment; else, undefined if missing values to hash. + * @returns Unique ID for the environment; else, undefined if missing values. */ -const hashUniquePendoId = (id: string | undefined) => { - const pendoEnv = - APP_ROOT === 'https://cloud.linode.com' ? 'production' : 'non-production'; +const getUniquePendoId = (id: string | undefined) => { + const isProdEnv = APP_ROOT === 'https://cloud.linode.com'; if (!id || !APP_ROOT) { return; } - return sha256(id + pendoEnv); + // Append "-nonprod" to all IDs when in lower environments. + return `${id}${!isProdEnv ? '-nonprod' : ''}`; }; /** @@ -69,8 +68,8 @@ export const usePendo = () => { const { data: account } = useAccount(); const { data: profile } = useProfile(); - const accountId = hashUniquePendoId(account?.euuid); - const visitorId = hashUniquePendoId(profile?.uid.toString()); + const accountId = getUniquePendoId(account?.euuid); + const visitorId = getUniquePendoId(profile?.uid.toString()); const optanonCookie = getCookie('OptanonConsent'); // Since OptanonConsent cookie always has a .linode.com domain, only check for consent in dev/staging/prod envs. diff --git a/packages/manager/src/hooks/useTabs.ts b/packages/manager/src/hooks/useTabs.ts index 52d5e5f0b3b..c97821a3e04 100644 --- a/packages/manager/src/hooks/useTabs.ts +++ b/packages/manager/src/hooks/useTabs.ts @@ -1,4 +1,4 @@ -import { useMatchRoute } from '@tanstack/react-router'; +import { useMatchRoute, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import type { LinkProps } from '@tanstack/react-router'; @@ -35,6 +35,7 @@ export interface Tab { * since Reach Tabs maintains its own index state. */ export function useTabs(tabs: T[]) { + const navigate = useNavigate(); const matchRoute = useMatchRoute(); // Filter out hidden tabs @@ -55,10 +56,15 @@ export function useTabs(tabs: T[]) { return index === -1 ? 0 : index; }, [visibleTabs, matchRoute]); - // Simple handler to satisfy Reach Tabs props - const handleTabChange = React.useCallback(() => { - // No-op - navigation is handled by Tanstack Router `Link` - }, []); + const handleTabChange = React.useCallback( + (index: number) => { + const tab = visibleTabs[index]; + if (tab) { + navigate({ to: tab.to }); + } + }, + [visibleTabs, navigate] + ); return { handleTabChange, diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts index 57acef660bb..b620cfda332 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts @@ -1,22 +1,19 @@ import { configFactory, + linodeBackupFactory, + linodeFactory, + linodeIPFactory, linodeInterfaceFactoryPublic, linodeInterfaceFactoryVPC, linodeInterfaceFactoryVlan, linodeInterfaceSettingsFactory, + linodeStatsFactory, + linodeTransferFactory, } from '@linode/utilities'; import { DateTime } from 'luxon'; import { http } from 'msw'; -import { - firewallDeviceFactory, - linodeBackupFactory, - linodeDiskFactory, - linodeFactory, - linodeIPFactory, - linodeStatsFactory, - linodeTransferFactory, -} from 'src/factories'; +import { firewallDeviceFactory, linodeDiskFactory } from 'src/factories'; import { queueEvents } from 'src/mocks/utilities/events'; import { makeNotFoundResponse, diff --git a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts index ca8167f1afe..2d9c2cad3d1 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts @@ -1,7 +1,6 @@ -import { pickRandom } from '@linode/utilities'; +import { pickRandom, regions } from '@linode/utilities'; import { http } from 'msw'; -import { regions } from 'src/__data__/regionsData'; import { objectStorageEndpointsFactory } from 'src/factories/objectStorage'; import { quotaFactory, quotaUsageFactory } from 'src/factories/quotas'; import { diff --git a/packages/manager/src/mocks/presets/crud/seeds/linodes.ts b/packages/manager/src/mocks/presets/crud/seeds/linodes.ts index 72cf4a8268e..05b3df90357 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/linodes.ts @@ -1,7 +1,6 @@ -import { configFactory } from '@linode/utilities'; +import { linodeFactory, configFactory } from '@linode/utilities'; import { getSeedsCountMap } from 'src/dev-tools/utils'; -import { linodeFactory } from 'src/factories'; import { mswDB } from 'src/mocks/indexedDB'; import { seedWithUniqueIds } from 'src/mocks/presets/crud/seeds/utils'; diff --git a/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts b/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts index 8aa91a808f9..0c78d6470e3 100644 --- a/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts +++ b/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts @@ -1,6 +1,6 @@ +import { regions } from '@linode/utilities'; import { http } from 'msw'; -import { regions } from 'src/__data__/regionsData'; import { makePaginatedResponse } from 'src/mocks/utilities/response'; import type { MockPresetExtra } from 'src/mocks/types'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 5cf51a3c4e4..fdac266145c 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -6,16 +6,29 @@ * * New handlers should be added to the CRUD baseline preset instead (ex: src/mocks/presets/crud/handlers/linodes.ts) which support a much more dynamic data mocking. */ -import { pickRandom } from '@linode/utilities'; +import { + accountAvailabilityFactory, + dedicatedTypeFactory, + linodeFactory, + linodeIPFactory, + linodeStatsFactory, + linodeTransferFactory, + linodeTypeFactory, + nodeBalancerConfigFactory, + nodeBalancerConfigNodeFactory, + nodeBalancerFactory, + pickRandom, + proDedicatedTypeFactory, + regionAvailabilityFactory, + regions, +} from '@linode/utilities'; import { DateTime } from 'luxon'; import { HttpResponse, http } from 'msw'; -import { regions } from 'src/__data__/regionsData'; import { MOCK_THEME_STORAGE_KEY } from 'src/dev-tools/ThemeSelector'; import { VLANFactory, // abuseTicketNotificationFactory, - accountAvailabilityFactory, accountBetaFactory, accountFactory, accountMaintenanceFactory, @@ -30,11 +43,11 @@ import { creditPaymentResponseFactory, dashboardFactory, databaseBackupFactory, + databaseEngineConfigFactory, databaseEngineFactory, databaseFactory, databaseInstanceFactory, databaseTypeFactory, - dedicatedTypeFactory, domainFactory, domainRecordFactory, entityTransferFactory, @@ -51,11 +64,6 @@ import { kubernetesVersionFactory, linodeConfigFactory, linodeDiskFactory, - linodeFactory, - linodeIPFactory, - linodeStatsFactory, - linodeTransferFactory, - linodeTypeFactory, lkeEnterpriseTypeFactory, lkeHighAvailabilityTypeFactory, lkeStandardAvailabilityTypeFactory, @@ -69,9 +77,6 @@ import { managedSSHPubKeyFactory, managedStatsFactory, monitorFactory, - nodeBalancerConfigFactory, - nodeBalancerConfigNodeFactory, - nodeBalancerFactory, nodeBalancerTypeFactory, nodePoolFactory, notificationChannelFactory, @@ -87,10 +92,8 @@ import { placementGroupFactory, possibleMySQLReplicationTypes, possiblePostgresReplicationTypes, - proDedicatedTypeFactory, profileFactory, promoFactory, - regionAvailabilityFactory, securityQuestionsFactory, serviceTypesFactory, stackScriptFactory, @@ -113,8 +116,8 @@ import { getStorage } from 'src/utilities/storage'; const getRandomWholeNumber = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1) + min); +import { accountEntityFactory } from 'src/factories/accountEntities'; import { accountPermissionsFactory } from 'src/factories/accountPermissions'; -import { accountResourcesFactory } from 'src/factories/accountResources'; import { userPermissionsFactory } from 'src/factories/userPermissions'; import type { @@ -369,6 +372,9 @@ const databases = [ http.post('*/databases/:engine/instances/:databaseId/resume', () => { return HttpResponse.json({}); }), + http.get('*/databases/:engine/config', () => { + return HttpResponse.json(databaseEngineConfigFactory.build()); + }), ]; const vpc = [ @@ -429,14 +435,27 @@ const iam = [ http.get('*/iam/role-permissions', () => { return HttpResponse.json(accountPermissionsFactory.build()); }), - http.get('*/iam/role-permissions/users/:username', () => { + http.get('*/iam/users/:username/role-permissions', () => { return HttpResponse.json(userPermissionsFactory.build()); }), ]; -const resources = [ - http.get('*/v4*/resources', () => { - return HttpResponse.json(accountResourcesFactory.build()); +const entities = [ + http.get('*/v4*/entities', () => { + const entity1 = accountEntityFactory.buildList(10, { + type: 'linode', + }); + const entity2 = accountEntityFactory.build({ + type: 'image', + }); + const entity3 = accountEntityFactory.build({ + id: 1, + label: 'firewall-1', + type: 'firewall', + }); + const entities = [...entity1, entity2, entity3]; + + return HttpResponse.json(makeResourcePage(entities)); }), ]; @@ -2915,5 +2934,5 @@ export const handlers = [ ...databases, ...vpc, ...iam, - ...resources, + ...entities, ]; diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 4b5c1050b63..9a63ace5b05 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -126,7 +126,7 @@ export interface MockState { firewalls: Firewall[]; ipAddresses: IPAddress[]; linodeConfigs: [number, Config][]; - linodeInterfaces: [number, LinodeInterface][], + linodeInterfaces: [number, LinodeInterface][]; linodes: Linode[]; notificationQueue: Notification[]; placementGroups: PlacementGroup[]; diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 4a1d5dd38fc..c7d9df20150 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -4,6 +4,7 @@ import { deleteEntityFromAlert, editAlertDefinition, } from '@linode/api-v4/lib/cloudpulse'; +import { queryPresets } from '@linode/queries'; import { keepPreviousData, useMutation, @@ -11,7 +12,6 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { queryPresets } from '@linode/queries'; import { queryFactory } from './queries'; import type { @@ -61,6 +61,7 @@ export const useAlertDefinitionQuery = ( ) => { return useQuery({ ...queryFactory.alerts._ctx.alertByServiceTypeAndId(serviceType, alertId), + refetchInterval: 120000, }); }; @@ -78,8 +79,30 @@ export const useEditAlertDefinition = () => { return useMutation({ mutationFn: ({ alertId, serviceType, ...data }) => editAlertDefinition(data, serviceType, alertId), - onSuccess() { - queryClient.invalidateQueries(queryFactory.alerts); + + onSuccess(data) { + const allAlertsQueryKey = queryFactory.alerts._ctx.all().queryKey; + queryClient.cancelQueries({ queryKey: allAlertsQueryKey }); + queryClient.setQueryData(allAlertsQueryKey, (oldData) => { + return ( + oldData?.map((alert) => { + return alert.id === data.id ? data : alert; + }) ?? [data] + ); + }); + + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId( + data.service_type, + String(data.id) + ).queryKey, + }); + + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.alertsByServiceType( + data.service_type + ).queryKey, + }); }, }); }; diff --git a/packages/manager/src/queries/databases/databases.ts b/packages/manager/src/queries/databases/databases.ts index fab0a6c11d0..5c5ce04ee20 100644 --- a/packages/manager/src/queries/databases/databases.ts +++ b/packages/manager/src/queries/databases/databases.ts @@ -3,6 +3,7 @@ import { deleteDatabase, getDatabaseBackups, getDatabaseCredentials, + getDatabaseEngineConfig, getDatabases, getEngineDatabase, legacyRestoreWithBackup, @@ -36,6 +37,7 @@ import type { DatabaseBackup, DatabaseCredentials, DatabaseEngine, + DatabaseEngineConfig, DatabaseFork, DatabaseInstance, DatabaseType, @@ -47,6 +49,10 @@ import type { } from '@linode/api-v4'; export const databaseQueries = createQueryKeys('databases', { + configs: (engine: Engine) => ({ + queryFn: () => getDatabaseEngineConfig(engine), + queryKey: ['configs', engine], + }), database: (engine: Engine, id: number) => ({ contextQueries: { backups: { @@ -265,6 +271,15 @@ export const useDatabaseTypesQuery = ( enabled, }); +export const useDatabaseEngineConfig = ( + engine: Engine, + enabled: boolean = true +) => + useQuery({ + ...databaseQueries.configs(engine), + enabled, + }); + export const useDatabaseCredentialsQuery = ( engine: Engine, id: number, diff --git a/packages/manager/src/queries/resources/resources.ts b/packages/manager/src/queries/entities/entities.ts similarity index 50% rename from packages/manager/src/queries/resources/resources.ts rename to packages/manager/src/queries/entities/entities.ts index b2015d1ee45..c7c78af3a05 100644 --- a/packages/manager/src/queries/resources/resources.ts +++ b/packages/manager/src/queries/entities/entities.ts @@ -1,15 +1,15 @@ import { queryPresets, useProfile } from '@linode/queries'; import { useQuery } from '@tanstack/react-query'; -import { resourcesQueries } from './queries'; +import { entitiesQueries } from './queries'; -import type { APIError, IamAccountResource } from '@linode/api-v4'; +import type { APIError, AccountEntity, ResourcePage } from '@linode/api-v4'; -export const useAccountResources = () => { +export const useAccountEntities = () => { const { data: profile } = useProfile(); - return useQuery({ - ...resourcesQueries.resources, + return useQuery, APIError[]>({ + ...entitiesQueries.entities, ...queryPresets.oneTimeFetch, ...queryPresets.noRetry, enabled: !profile?.restricted, diff --git a/packages/manager/src/queries/entities/queries.ts b/packages/manager/src/queries/entities/queries.ts new file mode 100644 index 00000000000..871e963045b --- /dev/null +++ b/packages/manager/src/queries/entities/queries.ts @@ -0,0 +1,9 @@ +import { getAccountEntities } from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +export const entitiesQueries = createQueryKeys('entities', { + entities: { + queryFn: getAccountEntities, + queryKey: null, + }, +}); diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 07f36e4442b..5293bdf09ae 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -36,11 +36,6 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { - useAPLAvailability, - useIsLkeEnterpriseEnabled, -} from 'src/features/Kubernetes/kubeUtils'; - import type { CreateKubeClusterPayload, CreateNodePoolData, @@ -68,11 +63,11 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { queryFn: () => getKubernetesClusterControlPlaneACL(id), queryKey: [id], }, - cluster: (useBetaEndpoint: boolean = false) => ({ - queryFn: useBetaEndpoint + cluster: (isUsingBetaEndpoint: boolean = false) => ({ + queryFn: isUsingBetaEndpoint ? () => getKubernetesClusterBeta(id) : () => getKubernetesCluster(id), - queryKey: [useBetaEndpoint ? 'v4beta' : 'v4'], + queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], }), dashboard: { queryFn: () => getKubernetesClusterDashboard(id), @@ -99,8 +94,8 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { return decodedKubeConfig; } catch (error) { const err = error as { - response?: { status?: number }; reason?: string; + response?: { status?: number }; }; const serviceUnavailableStatus = 503; if ( @@ -144,12 +139,12 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }), lists: { contextQueries: { - all: (useBetaEndpoint: boolean = false) => ({ + all: (isUsingBetaEndpoint: boolean = false) => ({ queryFn: () => - useBetaEndpoint + isUsingBetaEndpoint ? getAllKubernetesClustersBeta() : getAllKubernetesClusters(), - queryKey: [useBetaEndpoint ? 'v4beta' : 'v4'], + queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], }), infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => @@ -159,13 +154,13 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { paginated: ( params: Params, filter: Filter, - useBetaEndpoint: boolean = false + isUsingBetaEndpoint: boolean = false ) => ({ queryFn: () => - useBetaEndpoint + isUsingBetaEndpoint ? getKubernetesClustersBeta(params, filter) : getKubernetesClusters(params, filter), - queryKey: [params, filter, useBetaEndpoint ? 'v4beta' : 'v4'], + queryKey: [params, filter, isUsingBetaEndpoint ? 'v4beta' : 'v4'], }), }, queryKey: null, @@ -174,11 +169,11 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { queryFn: () => getAllKubernetesTieredVersionsBeta(tier), queryKey: [tier], }), - types: (useBetaEndpoint: boolean = false) => ({ - queryFn: useBetaEndpoint + types: (isUsingBetaEndpoint: boolean = false) => ({ + queryFn: isUsingBetaEndpoint ? getAllKubernetesTypesBeta : () => getAllKubernetesTypes(), - queryKey: [useBetaEndpoint ? 'v4beta' : 'v4'], + queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], }), versions: { queryFn: () => getAllKubernetesVersions(), @@ -186,18 +181,15 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }, }); -export const useKubernetesClusterQuery = ( - id: number, +export const useKubernetesClusterQuery = ({ enabled = true, - options = {} -) => { - const { isLoading: isAPLAvailabilityLoading, showAPL } = useAPLAvailability(); - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); - const useBetaEndpoint = showAPL || isLkeEnterpriseLAFeatureEnabled; - + id = -1, + options = {}, + isUsingBetaEndpoint = false, +}) => { return useQuery({ - ...kubernetesQueries.cluster(id)._ctx.cluster(useBetaEndpoint), - enabled: enabled && !isAPLAvailabilityLoading, + ...kubernetesQueries.cluster(id)._ctx.cluster(isUsingBetaEndpoint), + enabled, ...options, }); }; @@ -220,16 +212,25 @@ export const useKubernetesClustersInfiniteQuery = ( }); }; -export const useKubernetesClustersQuery = ( - params: Params, - filter: Filter, - enabled = true -) => { - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); - const useBetaEndpoint = isLkeEnterpriseLAFeatureEnabled; +interface KubernetesClustersQueryOptions { + enabled: boolean; + filter: Filter; + params: Params; + isUsingBetaEndpoint: boolean; +} +export const useKubernetesClustersQuery = ({ + enabled = true, + filter, + params, + isUsingBetaEndpoint = false, +}: KubernetesClustersQueryOptions) => { return useQuery, APIError[]>({ - ...kubernetesQueries.lists._ctx.paginated(params, filter, useBetaEndpoint), + ...kubernetesQueries.lists._ctx.paginated( + params, + filter, + isUsingBetaEndpoint + ), enabled, placeholderData: keepPreviousData, }); @@ -477,12 +478,12 @@ export const useKubernetesTieredVersionsQuery = ( * Avoiding fetching all Kubernetes Clusters if possible. * Before you use this, consider implementing infinite scroll instead. */ -export const useAllKubernetesClustersQuery = (enabled = false) => { - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); - const useBetaEndpoint = isLkeEnterpriseLAFeatureEnabled; - +export const useAllKubernetesClustersQuery = ({ + enabled = false, + isUsingBetaEndpoint = false, +}) => { return useQuery({ - ...kubernetesQueries.lists._ctx.all(useBetaEndpoint), + ...kubernetesQueries.lists._ctx.all(isUsingBetaEndpoint), enabled, }); }; @@ -555,8 +556,8 @@ const getAllKubernetesTypesBeta = () => (results) => results.data ); -export const useKubernetesTypesQuery = (useBetaEndpoint?: boolean) => +export const useKubernetesTypesQuery = (isUsingBetaEndpoint?: boolean) => useQuery({ ...queryPresets.oneTimeFetch, - ...kubernetesQueries.types(useBetaEndpoint), + ...kubernetesQueries.types(isUsingBetaEndpoint), }); diff --git a/packages/manager/src/queries/resources/queries.ts b/packages/manager/src/queries/resources/queries.ts deleted file mode 100644 index 074ea03fe3a..00000000000 --- a/packages/manager/src/queries/resources/queries.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getAccountResources } from '@linode/api-v4'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; - -export const resourcesQueries = createQueryKeys('resources', { - resources: { - queryFn: getAccountResources, - queryKey: null, - }, -}); diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index bc02b907703..17b2e261369 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -1,184 +1,7 @@ -import { - createStackScript, - deleteStackScript, - getStackScript, - getStackScripts, - updateStackScript, -} from '@linode/api-v4'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { - keepPreviousData, - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; +import { stackscriptQueries } from '@linode/queries'; -import { getOneClickApps } from 'src/features/StackScripts/stackScriptUtils'; -import { getAll } from '@linode/utilities'; - -import { queryPresets } from '@linode/queries'; - -import type { - APIError, - Filter, - Params, - ResourcePage, - StackScript, - StackScriptPayload, -} from '@linode/api-v4'; -import type { UseMutationOptions } from '@tanstack/react-query'; import type { EventHandlerData } from '@linode/queries'; -export const getAllOCAsRequest = (passedParams: Params = {}) => - getAll((params) => - getOneClickApps({ ...params, ...passedParams }) - )().then((data) => data.data); - -export const getAllAccountStackScripts = () => - getAll((params) => - getStackScripts(params, { mine: true }) - )().then((data) => data.data); - -export const stackscriptQueries = createQueryKeys('stackscripts', { - all: { - queryFn: () => getAllAccountStackScripts(), - queryKey: null, - }, - infinite: (filter: Filter = {}) => ({ - queryFn: ({ pageParam }) => - getStackScripts({ page: pageParam as number, page_size: 25 }, filter), - queryKey: [filter], - }), - marketplace: { - queryFn: () => getAllOCAsRequest(), - queryKey: null, - }, - stackscript: (id: number) => ({ - queryFn: () => getStackScript(id), - queryKey: [id], - }), -}); - -export const useMarketplaceAppsQuery = (enabled: boolean) => { - return useQuery({ - ...stackscriptQueries.marketplace, - enabled, - ...queryPresets.oneTimeFetch, - }); -}; - -export const useStackScriptQuery = (id: number, enabled = true) => - useQuery({ - ...stackscriptQueries.stackscript(id), - enabled, - }); - -/** - * Don't use this! It only exists so users can search for their StackScripts - * in the legacy main search. - */ -export const useAllAccountStackScriptsQuery = (enabled: boolean) => - useQuery({ - ...stackscriptQueries.all, - enabled, - }); - -export const useCreateStackScriptMutation = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: createStackScript, - onSuccess(stackscript) { - queryClient.setQueryData( - stackscriptQueries.stackscript(stackscript.id).queryKey, - stackscript - ); - queryClient.invalidateQueries({ - queryKey: stackscriptQueries.infinite._def, - }); - queryClient.invalidateQueries({ - queryKey: stackscriptQueries.all.queryKey, - }); - }, - }); -}; - -export const useStackScriptsInfiniteQuery = ( - filter: Filter = {}, - enabled = true -) => - useInfiniteQuery, APIError[]>({ - ...stackscriptQueries.infinite(filter), - enabled, - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - initialPageParam: 1, - placeholderData: keepPreviousData, - retry: false, - }); - -export const useUpdateStackScriptMutation = ( - id: number, - options?: UseMutationOptions< - StackScript, - APIError[], - Partial - > -) => { - const queryClient = useQueryClient(); - - return useMutation>({ - mutationFn: (data) => updateStackScript(id, data), - ...options, - onSuccess(stackscript, vars, ctx) { - queryClient.invalidateQueries({ - queryKey: stackscriptQueries.infinite._def, - }); - queryClient.invalidateQueries({ - queryKey: stackscriptQueries.all.queryKey, - }); - queryClient.setQueryData( - stackscriptQueries.stackscript(id).queryKey, - stackscript - ); - if (options?.onSuccess) { - options.onSuccess(stackscript, vars, ctx); - } - }, - }); -}; - -export const useDeleteStackScriptMutation = ( - id: number, - options: UseMutationOptions<{}, APIError[]> -) => { - const queryClient = useQueryClient(); - - return useMutation<{}, APIError[]>({ - mutationFn: () => deleteStackScript(id), - ...options, - onSuccess(...params) { - queryClient.invalidateQueries({ - queryKey: stackscriptQueries.infinite._def, - }); - queryClient.invalidateQueries({ - queryKey: stackscriptQueries.all.queryKey, - }); - queryClient.removeQueries({ - queryKey: stackscriptQueries.stackscript(id).queryKey, - }); - if (options.onSuccess) { - options.onSuccess(...params); - } - }, - }); -}; - export const stackScriptEventHandler = ({ event, invalidateQueries, diff --git a/packages/manager/src/queries/support.ts b/packages/manager/src/queries/support.ts index 08b9af4bb58..b1ed05d14fb 100644 --- a/packages/manager/src/queries/support.ts +++ b/packages/manager/src/queries/support.ts @@ -1,118 +1,7 @@ -import { - closeSupportTicket, - createReply, - createSupportTicket, - getTicket, - getTicketReplies, - getTickets, -} from '@linode/api-v4/lib/support'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { - keepPreviousData, - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; +import { supportQueries } from '@linode/queries'; -import type { - ReplyRequest, - SupportReply, - SupportTicket, - TicketRequest, -} from '@linode/api-v4/lib/support'; -import type { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; import type { EventHandlerData } from '@linode/queries'; -const supportQueries = createQueryKeys('support', { - ticket: (id: number) => ({ - contextQueries: { - replies: { - queryFn: ({ pageParam }) => - getTicketReplies(id, { page: pageParam as number, 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[]>({ - ...supportQueries.tickets(params, filter), - placeholderData: keepPreviousData, - }); - -export const useSupportTicketQuery = (id: number) => - 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[]>({ - ...supportQueries.ticket(id)._ctx.replies, - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - initialPageParam: 1, - }); - -export const useSupportTicketReplyMutation = () => { - const queryClient = useQueryClient(); - 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[]>({ - mutationFn: () => closeSupportTicket(id), - onSuccess() { - queryClient.invalidateQueries({ - queryKey: supportQueries.tickets._def, - }); - queryClient.invalidateQueries({ - queryKey: supportQueries.ticket(id).queryKey, - }); - }, - }); -}; - export const supportTicketEventHandler = ({ event, invalidateQueries, diff --git a/packages/manager/src/routes/firewalls/index.ts b/packages/manager/src/routes/firewalls/index.ts index 66436a0eb23..9e3674c4e22 100644 --- a/packages/manager/src/routes/firewalls/index.ts +++ b/packages/manager/src/routes/firewalls/index.ts @@ -4,7 +4,7 @@ import { rootRoute } from '../root'; import { FirewallsRoute } from './FirewallsRoute'; import type { TableSearchParams } from '../types'; -import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types'; +import type { LinodeCreateType } from '@linode/utilities'; export interface FirewallsSearchParams extends TableSearchParams { type?: LinodeCreateType; diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 186b87554f1..5fd7ada5b49 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -92,6 +92,7 @@ export const migrationRouteTree = migrationRootRoute.addChildren([ firewallsRouteTree, imagesRouteTree, longviewRouteTree, + nodeBalancersRouteTree, placementGroupsRouteTree, stackScriptsRouteTree, volumesRouteTree, diff --git a/packages/manager/src/routes/nodeBalancers/index.ts b/packages/manager/src/routes/nodeBalancers/index.ts index 7dae79cedcd..855fcf1481a 100644 --- a/packages/manager/src/routes/nodeBalancers/index.ts +++ b/packages/manager/src/routes/nodeBalancers/index.ts @@ -1,4 +1,4 @@ -import { createRoute, lazyRouteComponent } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { NodeBalancersRoute } from './NodeBalancersRoute'; @@ -13,60 +13,89 @@ const nodeBalancersIndexRoute = createRoute({ getParentRoute: () => nodeBalancersRoute, path: '/', }).lazy(() => - import( - 'src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding' - ).then((m) => m.nodeBalancersLandingLazyRoute) + import('./nodeBalancersLazyRoutes').then( + (m) => m.nodeBalancersLandingLazyRoute + ) ); const nodeBalancersCreateRoute = createRoute({ getParentRoute: () => nodeBalancersRoute, path: 'create', }).lazy(() => - import('src/features/NodeBalancers/NodeBalancerCreate').then( - (m) => m.nodeBalancerCreateLazyRoute - ) + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerCreateLazyRoute) ); const nodeBalancerDetailRoute = createRoute({ + beforeLoad: async ({ params }) => { + throw redirect({ + params: { + id: params.id, + }, + to: '/nodebalancers/$id/summary', + }); + }, getParentRoute: () => nodeBalancersRoute, - parseParams: (params) => ({ - nodeBalancerId: Number(params.nodeBalancerId), - }), - path: '$nodeBalancerId', + path: '$id', }).lazy(() => - import( - 'src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail' - ).then((m) => m.nodeBalancerDetailLazyRoute) + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerDetailLazyRoute) ); const nodeBalancerDetailSummaryRoute = createRoute({ - getParentRoute: () => nodeBalancerDetailRoute, - path: 'summary', + getParentRoute: () => nodeBalancersRoute, + path: '$id/summary', }).lazy(() => - import( - 'src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary' - ).then((m) => m.nodeBalancerSummaryLazyRoute) + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerDetailLazyRoute) ); -// TODO TanStack Router - figure proper way of lazy loading class components const nodeBalancerDetailConfigurationsRoute = createRoute({ - component: lazyRouteComponent( - () => - import( - 'src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations' - ) - ), - getParentRoute: () => nodeBalancerDetailRoute, - path: 'configurations', -}); + getParentRoute: () => nodeBalancersRoute, + path: '$id/configurations', +}).lazy(() => + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerDetailLazyRoute) +); + +const nodeBalancerDetailConfigurationRoute = createRoute({ + getParentRoute: () => nodeBalancersRoute, + path: '$id/configurations/$configId', +}).lazy(() => + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerDetailLazyRoute) +); const nodeBalancerDetailSettingsRoute = createRoute({ - getParentRoute: () => nodeBalancerDetailRoute, - path: 'settings', + getParentRoute: () => nodeBalancersRoute, + path: '$id/settings', }).lazy(() => - import( - 'src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings' - ).then((m) => m.nodeBalancerSettingsLazyRoute) + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerDetailLazyRoute) +); + +const nodeBalancerDetailSettingsDeleteRoute = createRoute({ + getParentRoute: () => nodeBalancersRoute, + path: '$id/settings/delete', +}).lazy(() => + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerDetailLazyRoute) +); + +const nodeBalancerDetailSettingsAddFirewallRoute = createRoute({ + getParentRoute: () => nodeBalancersRoute, + path: '$id/settings/add-firewall', +}).lazy(() => + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerDetailLazyRoute) +); + +const nodeBalancerDetailSettingsUnassignFirewallRoute = createRoute({ + getParentRoute: () => nodeBalancersRoute, + path: '$id/settings/unassign-firewall/$firewallId', +}).lazy(() => + import('./nodeBalancersLazyRoutes').then((m) => m.nodeBalancerDetailLazyRoute) +); + +const nodeBalancerDeleteRoute = createRoute({ + getParentRoute: () => nodeBalancersRoute, + path: '$id/delete', +}).lazy(() => + import('./nodeBalancersLazyRoutes').then( + (m) => m.nodeBalancersLandingLazyRoute + ) ); export const nodeBalancersRouteTree = nodeBalancersRoute.addChildren([ @@ -74,7 +103,14 @@ export const nodeBalancersRouteTree = nodeBalancersRoute.addChildren([ nodeBalancersCreateRoute, nodeBalancerDetailRoute.addChildren([ nodeBalancerDetailSummaryRoute, - nodeBalancerDetailConfigurationsRoute, - nodeBalancerDetailSettingsRoute, + nodeBalancerDetailConfigurationsRoute.addChildren([ + nodeBalancerDetailConfigurationRoute, + ]), + nodeBalancerDetailSettingsRoute.addChildren([ + nodeBalancerDetailSettingsDeleteRoute, + nodeBalancerDetailSettingsAddFirewallRoute, + nodeBalancerDetailSettingsUnassignFirewallRoute, + ]), ]), + nodeBalancerDeleteRoute, ]); diff --git a/packages/manager/src/routes/nodeBalancers/nodeBalancersLazyRoutes.ts b/packages/manager/src/routes/nodeBalancers/nodeBalancersLazyRoutes.ts new file mode 100644 index 00000000000..6951896a185 --- /dev/null +++ b/packages/manager/src/routes/nodeBalancers/nodeBalancersLazyRoutes.ts @@ -0,0 +1,21 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import NodeBalancerCreate from 'src/features/NodeBalancers/NodeBalancerCreate'; +import { NodeBalancerDetail } from 'src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail'; +import { NodeBalancersLanding } from 'src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding'; + +export const nodeBalancersLandingLazyRoute = createLazyRoute('/nodebalancers')({ + component: NodeBalancersLanding, +}); + +export const nodeBalancerDetailLazyRoute = createLazyRoute( + '/nodebalancers/$id' +)({ + component: NodeBalancerDetail, +}); + +export const nodeBalancerCreateLazyRoute = createLazyRoute( + '/nodebalancers/create' +)({ + component: NodeBalancerCreate, +}); diff --git a/packages/manager/src/utilities/analytics/formEventAnalytics.ts b/packages/manager/src/utilities/analytics/formEventAnalytics.ts index 084fb0fc51b..46086eae603 100644 --- a/packages/manager/src/utilities/analytics/formEventAnalytics.ts +++ b/packages/manager/src/utilities/analytics/formEventAnalytics.ts @@ -7,7 +7,7 @@ import type { FormStepEvent, LinodeCreateFormEventOptions, } from './types'; -import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types'; +import type { LinodeCreateType } from '@linode/utilities'; /** * Form Events diff --git a/packages/manager/src/utilities/analytics/types.ts b/packages/manager/src/utilities/analytics/types.ts index 3ccf69f4de8..c4d635f122e 100644 --- a/packages/manager/src/utilities/analytics/types.ts +++ b/packages/manager/src/utilities/analytics/types.ts @@ -1,4 +1,4 @@ -import type { LinodeCreateType } from 'src/features/Linodes/LinodeCreate/types'; +import type { LinodeCreateType } from '@linode/utilities'; // Define a custom type for the _satellite object declare global { diff --git a/packages/manager/src/utilities/codesnippets/generate-cURL.test.ts b/packages/manager/src/utilities/codesnippets/generate-cURL.test.ts index 010b3836cc7..c0f6ef8c882 100644 --- a/packages/manager/src/utilities/codesnippets/generate-cURL.test.ts +++ b/packages/manager/src/utilities/codesnippets/generate-cURL.test.ts @@ -1,4 +1,4 @@ -import { createLinodeRequestFactory } from 'src/factories/linodes'; +import { createLinodeRequestFactory } from '@linode/utilities'; import { generateCurlCommand } from './generate-cURL'; diff --git a/packages/manager/src/utilities/codesnippets/generate-cli.test.ts b/packages/manager/src/utilities/codesnippets/generate-cli.test.ts index b6df3e3760e..5b73e92717a 100644 --- a/packages/manager/src/utilities/codesnippets/generate-cli.test.ts +++ b/packages/manager/src/utilities/codesnippets/generate-cli.test.ts @@ -1,4 +1,4 @@ -import { createLinodeRequestFactory } from 'src/factories/linodes'; +import { createLinodeRequestFactory } from '@linode/utilities'; import { generateCLICommand } from './generate-cli'; diff --git a/packages/manager/src/utilities/creditCard.test.ts b/packages/manager/src/utilities/creditCard.test.ts index 5787fab5623..ef4f41196bc 100644 --- a/packages/manager/src/utilities/creditCard.test.ts +++ b/packages/manager/src/utilities/creditCard.test.ts @@ -1,6 +1,5 @@ import { CreditCardSchema } from '@linode/validation'; import { Settings } from 'luxon'; -import { take, takeLast } from 'ramda'; import { formatExpiry, @@ -9,7 +8,7 @@ import { } from './creditCard'; const currentYear = new Date().getFullYear(); -const currentYearFirstTwoDigits = take(2, String(currentYear)); +const currentYearFirstTwoDigits = String(currentYear).slice(0, 2); describe('isCreditCardExpired', () => { describe('given today is 01/01/2019', () => { @@ -89,7 +88,7 @@ describe('credit card expiry date parsing and validation', () => { data: { card_number: '1111111111111111', cvv: '123', - expiry: `09/${takeLast(2, String(currentYear + 19))}`, + expiry: `09/${String(currentYear + 19).slice(-2)}`, }, result: true, }, @@ -97,7 +96,7 @@ describe('credit card expiry date parsing and validation', () => { data: { card_number: '1111111111111111', cvv: '123', - expiry: `09/${takeLast(2, String(currentYear + 1))}`, + expiry: `09/${String(currentYear + 1).slice(-2)}`, }, result: true, }, @@ -108,8 +107,8 @@ describe('credit card expiry date parsing and validation', () => { // We also use currentYear to make sure this test does not fail in many // years down the road. cvv: '123', - // Using takeLast to simulate a user entering the year in a 2 digit format. - expiry: `09/${takeLast(2, String(currentYear + 21))}`, + // Using slice() to simulate a user entering the year in a 2 digit format. + expiry: `09/${String(currentYear + 21).slice(-2)}`, }, result: 'Expiry too far in the future.', }, diff --git a/packages/manager/src/utilities/creditCard.ts b/packages/manager/src/utilities/creditCard.ts index 921c68cae5c..729512996ac 100644 --- a/packages/manager/src/utilities/creditCard.ts +++ b/packages/manager/src/utilities/creditCard.ts @@ -1,5 +1,4 @@ import { DateTime } from 'luxon'; -import { take, takeLast } from 'ramda'; /** * Credit cards generally are valid through the expiry month (inclusive). * @@ -41,7 +40,7 @@ export const isCreditCardExpired = (expDate: string) => { export const formatExpiry = (expiry: string): string => { const expiryData = expiry.split('/'); return expiryData[1].length > 2 - ? `${expiryData[0]}/${takeLast(2, expiryData[1])}` + ? `${expiryData[0]}/${expiryData[1].slice(-2)}` : expiry; }; @@ -56,5 +55,5 @@ export const parseExpiryYear = ( return expiryYear; } - return take(2, String(new Date().getFullYear())) + expiryYear; + return String(new Date().getFullYear()).slice(0, 2) + expiryYear; }; diff --git a/packages/manager/src/utilities/formikErrorUtils.ts b/packages/manager/src/utilities/formikErrorUtils.ts index 2ae6cbcb776..04bd35ac562 100644 --- a/packages/manager/src/utilities/formikErrorUtils.ts +++ b/packages/manager/src/utilities/formikErrorUtils.ts @@ -1,7 +1,5 @@ -import { reverse } from 'ramda'; - import { getAPIErrorOrDefault } from './errorUtils'; -import { isNilOrEmpty } from './isNilOrEmpty'; +import { isNilOrEmpty } from '@linode/utilities'; import type { APIError } from '@linode/api-v4/lib/types'; import type { FormikErrors } from 'formik'; @@ -87,11 +85,13 @@ export const handleFieldErrors = ( callback: (error: unknown) => void, fieldErrors: APIError[] = [] ) => { - const mappedFieldErrors = reverse(fieldErrors).reduce( - (result, { field, reason }) => - field ? { ...result, [field]: reason } : result, - {} - ); + const mappedFieldErrors = [...fieldErrors] + .reverse() + .reduce( + (result, { field, reason }) => + field ? { ...result, [field]: reason } : result, + {} + ); if (!isNilOrEmpty(mappedFieldErrors)) { return callback(mappedFieldErrors); diff --git a/packages/manager/src/utilities/getSelectedOptionFromGroupedOptions.test.ts b/packages/manager/src/utilities/getSelectedOptionFromGroupedOptions.test.ts deleted file mode 100644 index a08e01ba19d..00000000000 --- a/packages/manager/src/utilities/getSelectedOptionFromGroupedOptions.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getSelectedOptionFromGroupedOptions } from './getSelectedOptionFromGroupedOptions'; - -const option1 = { - label: 'Option 1', - value: 'Option 1', -}; - -const option2 = { - label: 'Option 2', - value: 'Option 2', -}; - -const option3 = { - label: 'Volumes Option 1', - value: 'Volumes Option 1', -}; - -const option4 = { - label: 'Volumes Option 2', - value: 'Volumes Option 2', -}; - -const fakeDeviceList = [ - { - label: 'Disks', - options: [option1, option2], - value: 'disks', - }, - { - label: 'Volumes', - options: [option3, option4], - value: 'volumes', - }, -]; - -describe('DeviceSelection', () => { - describe('getSelectedOptionFromGroupedOptions helper method', () => { - it('should retrieve an Item from a set of grouped options', () => { - expect( - getSelectedOptionFromGroupedOptions(option3.value, fakeDeviceList) - ).toBe(option3); - expect( - getSelectedOptionFromGroupedOptions(option2.value, fakeDeviceList) - ).toBe(option2); - }); - - it("should return null if the option isn't found", () => { - expect( - getSelectedOptionFromGroupedOptions('not a real value', fakeDeviceList) - ).toBeNull(); - }); - }); -}); diff --git a/packages/manager/src/utilities/getSelectedOptionFromGroupedOptions.ts b/packages/manager/src/utilities/getSelectedOptionFromGroupedOptions.ts deleted file mode 100644 index 4d48704df21..00000000000 --- a/packages/manager/src/utilities/getSelectedOptionFromGroupedOptions.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { flatten } from 'ramda'; - -import type { SelectOption } from '@linode/ui'; - -type OptionGroup = { - label: string; - options: SelectOption[]; -}; - -export const getSelectedOptionFromGroupedOptions = ( - selectedValue: string, - options: OptionGroup[] -) => { - if (!selectedValue) { - return null; - } - // Ramda's flatten doesn't seem able to handle the typing issues here, but this returns an array of Item. - const optionsList = (flatten( - options.map((group) => group.options) - ) as unknown) as SelectOption[]; - return optionsList.find((option) => option.value === selectedValue) || null; -}; diff --git a/packages/manager/src/utilities/isNilOrEmpty.ts b/packages/manager/src/utilities/isNilOrEmpty.ts deleted file mode 100644 index a2189a87b0e..00000000000 --- a/packages/manager/src/utilities/isNilOrEmpty.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { isEmpty, isNil } from 'ramda'; - -export const isNilOrEmpty = (v: any) => isNil(v) || isEmpty(v); diff --git a/packages/manager/src/utilities/linodes.test.ts b/packages/manager/src/utilities/linodes.test.ts index 76620116f05..b90ee0ed7ca 100644 --- a/packages/manager/src/utilities/linodes.test.ts +++ b/packages/manager/src/utilities/linodes.test.ts @@ -1,6 +1,7 @@ +import { linodeFactory } from '@linode/utilities'; import { renderHook } from '@testing-library/react'; -import { accountMaintenanceFactory, linodeFactory } from 'src/factories'; +import { accountMaintenanceFactory } from 'src/factories'; import { addMaintenanceToLinodes, diff --git a/packages/manager/src/utilities/mergeDeepRight.test.ts b/packages/manager/src/utilities/mergeDeepRight.test.ts new file mode 100644 index 00000000000..9c02cf51499 --- /dev/null +++ b/packages/manager/src/utilities/mergeDeepRight.test.ts @@ -0,0 +1,49 @@ +import { mergeDeepRight } from './mergeDeepRight'; + +describe('mergeDeepRight function', () => { + it('should be able to merge simple objects', () => { + const obj1 = { + errors: { id: 25 }, + }; + + const obj2 = { + errors: { reason: 'error 2' }, + }; + + expect(mergeDeepRight(obj1, obj2)).toStrictEqual({ + errors: { id: 25, reason: 'error 2' }, + }); + }); + it('should be able to pick the second object value for a non object values', () => { + const obj1 = { + data: { id: 25 }, + errors: [{ reason: 'error 1' }, { reason: 'error 2' }], + }; + + const obj2 = { + data: { region: 'us-east' }, + errors: [{ reason: 'error 3' }, { reason: 'error 4' }], + }; + + expect(mergeDeepRight(obj1, obj2)).toStrictEqual({ + data: { id: 25, region: 'us-east' }, + errors: [{ reason: 'error 3' }, { reason: 'error 4' }], + }); + }); + it('should be able to merge deeply nested objects', () => { + const obj1 = { + data: { address: { ipv4: '10.0.0.24', ipv6: '' }, id: 25 }, + errors: [{ reason: 'error 1' }], + }; + + const obj2 = { + data: { address: { ipv4: '192.168.0.2' }, id: 28 }, + errors: [{ reason: 'error 2' }, { reason: 'error 3' }], + }; + + expect(mergeDeepRight(obj1, obj2)).toStrictEqual({ + data: { address: { ipv4: '192.168.0.2', ipv6: '' }, id: 28 }, + errors: [{ reason: 'error 2' }, { reason: 'error 3' }], + }); + }); +}); diff --git a/packages/manager/src/utilities/mergeDeepRight.ts b/packages/manager/src/utilities/mergeDeepRight.ts new file mode 100644 index 00000000000..f4c6d397938 --- /dev/null +++ b/packages/manager/src/utilities/mergeDeepRight.ts @@ -0,0 +1,39 @@ +/** + * Creates a new object with the own properties of the first object merged + * with the own properties of the second object. If a key exists in both objects: and both values are objects, + * the two values will be recursively merged otherwise the value from the second object will be used. + */ +export const mergeDeepRight = < + T extends Record, + U extends Record +>( + obj1: T, + obj2: U +): T & U => { + if (!isObject(obj2)) { + if (!obj2) { + return obj1 as T & U; + } else { + return obj2 as T & U; + } + } + if (!isObject(obj1)) { + if (!obj1) { + return obj2 as T & U; + } else { + return obj1 as T & U; + } + } + + return Object.keys({ ...obj1, ...obj2 }).reduce((acc: any, key: string) => { + const val1 = obj1[key]; + const val2 = obj2[key]; + + acc[key] = mergeDeepRight(val1, val2); + return acc; + }, {} as T & U); +}; + +// using a custom function to check for an object since typescript classifies arrays, dates and maps as type 'object' +const isObject = (obj: object) => + Object.prototype.toString.call(obj) === '[object Object]'; diff --git a/packages/manager/src/utilities/pricing/backups.test.tsx b/packages/manager/src/utilities/pricing/backups.test.tsx index a3ef5db57af..14c5fde1fca 100644 --- a/packages/manager/src/utilities/pricing/backups.test.tsx +++ b/packages/manager/src/utilities/pricing/backups.test.tsx @@ -1,4 +1,4 @@ -import { linodeFactory, linodeTypeFactory } from 'src/factories'; +import { linodeFactory, linodeTypeFactory } from '@linode/utilities'; import { getLinodeBackupPrice, getTotalBackupsPrice } from './backups'; diff --git a/packages/manager/src/utilities/pricing/kubernetes.test.tsx b/packages/manager/src/utilities/pricing/kubernetes.test.tsx index 6a76f0329bb..bb7799cdcd1 100644 --- a/packages/manager/src/utilities/pricing/kubernetes.test.tsx +++ b/packages/manager/src/utilities/pricing/kubernetes.test.tsx @@ -1,4 +1,6 @@ -import { linodeTypeFactory, nodePoolFactory } from 'src/factories'; +import { linodeTypeFactory } from '@linode/utilities'; + +import { nodePoolFactory } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; import { getKubernetesMonthlyPrice, getTotalClusterPrice } from './kubernetes'; diff --git a/packages/manager/src/utilities/pricing/linodes.test.ts b/packages/manager/src/utilities/pricing/linodes.test.ts index 52f324cf89e..3f82c5ce58d 100644 --- a/packages/manager/src/utilities/pricing/linodes.test.ts +++ b/packages/manager/src/utilities/pricing/linodes.test.ts @@ -1,4 +1,4 @@ -import { linodeTypeFactory } from 'src/factories'; +import { linodeTypeFactory } from '@linode/utilities'; import { getLinodeBackupPrice } from './backups'; import { diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index deca3e62169..862628256ee 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -12,7 +12,6 @@ import mediaQuery from 'css-mediaquery'; import { Formik } from 'formik'; import { LDProvider } from 'launchdarkly-react-client-sdk'; import { SnackbarProvider } from 'notistack'; -import { mergeDeepRight } from 'ramda'; import * as React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { Provider } from 'react-redux'; @@ -26,6 +25,8 @@ import { setupInterceptors } from 'src/request'; import { migrationRouteTree } from 'src/routes'; import { defaultState, storeFactory } from 'src/store'; +import { mergeDeepRight } from './mergeDeepRight'; + import type { QueryClient } from '@tanstack/react-query'; // TODO: Tanstack Router - replace AnyRouter once migration is complete. import type { AnyRootRoute, AnyRouter } from '@tanstack/react-router'; @@ -78,7 +79,6 @@ interface Options { routePath?: string; theme?: 'dark' | 'light'; } - /** * preference state is necessary for all tests using the * renderWithTheme() helper function, since the whole app is wrapped with diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index a37201ab595..445e4096a03 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,16 @@ +## [2025-04-08] - v0.2.0 + + +### Added: + +- `tags/` directory and migrated relevant query keys and hooks ([#11897](https://github.com/linode/manager/pull/11897)) +- `support/` directory and migrated relevant query keys and hooks ([#11904](https://github.com/linode/manager/pull/11904)) +- `stackscripts/` directory and migrated relevant query keys and hooks ([#11949](https://github.com/linode/manager/pull/11949)) + +### Upcoming Features: + +- Add Firewall Settings query ([#11828](https://github.com/linode/manager/pull/11828)) + ## [2025-03-25] - v0.1.0 diff --git a/packages/queries/package.json b/packages/queries/package.json index bc51645bfe6..adae314e4bc 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.1.0", + "version": "0.2.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", @@ -35,7 +35,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@linode/eslint-plugin-cloud-manager": "^0.0.7", + "@linode/eslint-plugin-cloud-manager": "^0.0.10", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", diff --git a/packages/queries/src/firewalls/firewalls.ts b/packages/queries/src/firewalls/firewalls.ts index 54ac1a0826c..fea962ac76f 100644 --- a/packages/queries/src/firewalls/firewalls.ts +++ b/packages/queries/src/firewalls/firewalls.ts @@ -5,11 +5,13 @@ import { deleteFirewallDevice, getFirewall, getFirewallDevices, + getFirewallSettings, getFirewalls, getTemplate, getTemplates, updateFirewall, updateFirewallRules, + updateFirewallSettings, } from '@linode/api-v4/lib/firewalls'; import { getAll } from '@linode/utilities'; import { createQueryKeys } from '@lukemorales/query-key-factory'; @@ -34,12 +36,15 @@ import type { FirewallDevice, FirewallDevicePayload, FirewallRules, + FirewallSettings, FirewallTemplate, FirewallTemplateSlug, Params, ResourcePage, UpdateFirewallRules, + UpdateFirewallSettings, } from '@linode/api-v4'; +import type { UseQueryOptions } from '@tanstack/react-query'; const getAllFirewallDevices = ( id: number, @@ -91,6 +96,10 @@ export const firewallQueries = createQueryKeys('firewalls', { }, queryKey: null, }, + settings: { + queryFn: getFirewallSettings, + queryKey: null, + }, template: (slug: FirewallTemplateSlug) => ({ queryFn: () => getTemplate(slug), queryKey: [slug], @@ -101,10 +110,14 @@ export const firewallQueries = createQueryKeys('firewalls', { }, }); -export const useAllFirewallDevicesQuery = (id: number) => - useQuery( - firewallQueries.firewall(id)._ctx.devices - ); +export const useAllFirewallDevicesQuery = ( + id: number, + enabled: boolean = true +) => + useQuery({ + ...firewallQueries.firewall(id)._ctx.devices, + enabled, + }); export const useFirewallsInfiniteQuery = (filter: Filter, enabled: boolean) => { return useInfiniteQuery, APIError[]>({ @@ -273,14 +286,26 @@ export const useFirewallsQuery = (params?: Params, filter?: Filter) => { }); }; +export const useFirewallSettingsQuery = ( + options?: Partial> +) => { + return useQuery({ + ...firewallQueries.settings, + ...options, + }); +}; + export const useFirewallTemplatesQuery = () => { return useQuery({ ...firewallQueries.templates, }); }; -export const useFirewallQuery = (id: number) => - useQuery(firewallQueries.firewall(id)); +export const useFirewallQuery = (id: number, enabled: boolean = true) => + useQuery({ + ...firewallQueries.firewall(id), + enabled, + }); export const useAllFirewallsQuery = (enabled: boolean = true) => { return useQuery({ @@ -289,6 +314,19 @@ export const useAllFirewallsQuery = (enabled: boolean = true) => { }); }; +export const useMutateFirewallSettings = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => updateFirewallSettings(data), + onSuccess(firewallSettings) { + queryClient.setQueryData( + firewallQueries.settings.queryKey, + firewallSettings + ); + }, + }); +}; + export const useMutateFirewall = (id: number) => { const queryClient = useQueryClient(); return useMutation>({ diff --git a/packages/queries/src/index.ts b/packages/queries/src/index.ts index 6002c8b1230..76a0024e659 100644 --- a/packages/queries/src/index.ts +++ b/packages/queries/src/index.ts @@ -8,6 +8,9 @@ export * from './nodebalancers'; export * from './placementGroups'; export * from './profile'; export * from './regions'; +export * from './stackscripts'; +export * from './tags'; +export * from './support'; export * from './vlans'; export * from './vpcs'; export * from './volumes'; diff --git a/packages/queries/src/linodes/interfaces.ts b/packages/queries/src/linodes/interfaces.ts index 8c87f98b45b..56120fd33f0 100644 --- a/packages/queries/src/linodes/interfaces.ts +++ b/packages/queries/src/linodes/interfaces.ts @@ -27,13 +27,14 @@ export const useLinodeInterfacesQuery = (linodeId: number) => { export const useLinodeInterfaceQuery = ( linodeId: number, - interfaceId: number + interfaceId: number | undefined, + enabled: boolean = true ) => { return useQuery({ ...linodeQueries .linode(linodeId) - ._ctx.interfaces._ctx.interface(interfaceId), - enabled: Boolean(interfaceId), + ._ctx.interfaces._ctx.interface(interfaceId ?? -1), + enabled: enabled && interfaceId !== undefined, }); }; diff --git a/packages/queries/src/linodes/linodes.ts b/packages/queries/src/linodes/linodes.ts index 5f3309d9dd6..3b254020c5f 100644 --- a/packages/queries/src/linodes/linodes.ts +++ b/packages/queries/src/linodes/linodes.ts @@ -335,6 +335,20 @@ export const useCreateLinodeMutation = () => { queryKey: vpcQueries.vpc(vpcId).queryKey, }); } + } else { + // invalidate firewall queries if a new Linode interface is assigned to a firewall + if (variables.interfaces?.some((iface) => iface.firewall_id)) { + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + } + for (const iface of variables.interfaces ?? []) { + if (iface.firewall_id) { + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(iface.firewall_id).queryKey, + }); + } + } } // If the Linode is assigned to a placement group on creation, diff --git a/packages/queries/src/stackscripts/index.ts b/packages/queries/src/stackscripts/index.ts new file mode 100644 index 00000000000..2f4baf117ed --- /dev/null +++ b/packages/queries/src/stackscripts/index.ts @@ -0,0 +1,3 @@ +export * from './requests'; +export * from './stackscripts'; +export * from './keys'; diff --git a/packages/queries/src/stackscripts/keys.ts b/packages/queries/src/stackscripts/keys.ts new file mode 100644 index 00000000000..ddc0b10f47a --- /dev/null +++ b/packages/queries/src/stackscripts/keys.ts @@ -0,0 +1,26 @@ +import { getStackScript, getStackScripts } from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import { getAllAccountStackScripts, getAllOCAsRequest } from './requests'; + +import type { Filter } from '@linode/api-v4'; + +export const stackscriptQueries = createQueryKeys('stackscripts', { + all: { + queryFn: () => getAllAccountStackScripts(), + queryKey: null, + }, + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getStackScripts({ page: pageParam as number, page_size: 25 }, filter), + queryKey: [filter], + }), + marketplace: { + queryFn: () => getAllOCAsRequest(), + queryKey: null, + }, + stackscript: (id: number) => ({ + queryFn: () => getStackScript(id), + queryKey: [id], + }), +}); diff --git a/packages/queries/src/stackscripts/requests.ts b/packages/queries/src/stackscripts/requests.ts new file mode 100644 index 00000000000..e2e1476f521 --- /dev/null +++ b/packages/queries/src/stackscripts/requests.ts @@ -0,0 +1,31 @@ +import { getStackScripts } from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; + +import type { Params, StackScript } from '@linode/api-v4'; + +const oneClickFilter = [ + { + '+and': [ + { '+or': [{ username: 'linode-stackscripts' }, { username: 'linode' }] }, + { + label: { + '+contains': 'One-Click', + }, + }, + ], + '+order_by': 'ordinal', + }, +]; + +export const getOneClickApps = (params?: Params) => + getStackScripts(params, oneClickFilter); + +export const getAllOCAsRequest = (passedParams: Params = {}) => + getAll((params) => + getOneClickApps({ ...params, ...passedParams }) + )().then((data) => data.data); + +export const getAllAccountStackScripts = () => + getAll((params) => + getStackScripts(params, { mine: true }) + )().then((data) => data.data); diff --git a/packages/queries/src/stackscripts/stackscripts.ts b/packages/queries/src/stackscripts/stackscripts.ts new file mode 100644 index 00000000000..ea4229c0adc --- /dev/null +++ b/packages/queries/src/stackscripts/stackscripts.ts @@ -0,0 +1,143 @@ +import { + createStackScript, + deleteStackScript, + updateStackScript, +} from '@linode/api-v4'; +import { queryPresets } from '@linode/queries'; +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import { stackscriptQueries } from './keys'; + +import type { + APIError, + Filter, + ResourcePage, + StackScript, + StackScriptPayload, +} from '@linode/api-v4'; +import type { UseMutationOptions } from '@tanstack/react-query'; + +export const useMarketplaceAppsQuery = (enabled: boolean) => { + return useQuery({ + ...stackscriptQueries.marketplace, + enabled, + ...queryPresets.oneTimeFetch, + }); +}; + +export const useStackScriptQuery = (id: number, enabled = true) => + useQuery({ + ...stackscriptQueries.stackscript(id), + enabled, + }); + +/** + * Don't use this! It only exists so users can search for their StackScripts + * in the legacy main search. + */ +export const useAllAccountStackScriptsQuery = (enabled: boolean) => + useQuery({ + ...stackscriptQueries.all, + enabled, + }); + +export const useCreateStackScriptMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createStackScript, + onSuccess(stackscript) { + queryClient.setQueryData( + stackscriptQueries.stackscript(stackscript.id).queryKey, + stackscript + ); + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.infinite._def, + }); + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.all.queryKey, + }); + }, + }); +}; + +export const useStackScriptsInfiniteQuery = ( + filter: Filter = {}, + enabled = true +) => + useInfiniteQuery, APIError[]>({ + ...stackscriptQueries.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + placeholderData: keepPreviousData, + retry: false, + }); + +export const useUpdateStackScriptMutation = ( + id: number, + options?: UseMutationOptions< + StackScript, + APIError[], + Partial + > +) => { + const queryClient = useQueryClient(); + + return useMutation>({ + mutationFn: (data) => updateStackScript(id, data), + ...options, + onSuccess(stackscript, vars, ctx) { + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.infinite._def, + }); + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.all.queryKey, + }); + queryClient.setQueryData( + stackscriptQueries.stackscript(id).queryKey, + stackscript + ); + if (options?.onSuccess) { + options.onSuccess(stackscript, vars, ctx); + } + }, + }); +}; + +export const useDeleteStackScriptMutation = ( + id: number, + options: UseMutationOptions<{}, APIError[]> +) => { + const queryClient = useQueryClient(); + + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteStackScript(id), + ...options, + onSuccess(...params) { + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.infinite._def, + }); + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.all.queryKey, + }); + queryClient.removeQueries({ + queryKey: stackscriptQueries.stackscript(id).queryKey, + }); + if (options.onSuccess) { + options.onSuccess(...params); + } + }, + }); +}; diff --git a/packages/queries/src/support/index.ts b/packages/queries/src/support/index.ts new file mode 100644 index 00000000000..5f9360ade81 --- /dev/null +++ b/packages/queries/src/support/index.ts @@ -0,0 +1 @@ +export * from './support'; diff --git a/packages/queries/src/support/support.ts b/packages/queries/src/support/support.ts new file mode 100644 index 00000000000..123e0e3a414 --- /dev/null +++ b/packages/queries/src/support/support.ts @@ -0,0 +1,113 @@ +import { + closeSupportTicket, + createReply, + createSupportTicket, + getTicket, + getTicketReplies, + getTickets, +} from '@linode/api-v4/lib/support'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import type { + ReplyRequest, + SupportReply, + SupportTicket, + TicketRequest, +} from '@linode/api-v4'; +import type { + APIError, + Filter, + Params, + ResourcePage, +} from '@linode/api-v4/lib/types'; + +export const supportQueries = createQueryKeys('support', { + ticket: (id: number) => ({ + contextQueries: { + replies: { + queryFn: ({ pageParam }) => + getTicketReplies(id, { page: pageParam as number, 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[]>({ + ...supportQueries.tickets(params, filter), + placeholderData: keepPreviousData, + }); + +export const useSupportTicketQuery = (id: number) => + 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[]>({ + ...supportQueries.ticket(id)._ctx.replies, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + }); + +export const useSupportTicketReplyMutation = () => { + const queryClient = useQueryClient(); + 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[]>({ + mutationFn: () => closeSupportTicket(id), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: supportQueries.tickets._def, + }); + queryClient.invalidateQueries({ + queryKey: supportQueries.ticket(id).queryKey, + }); + }, + }); +}; diff --git a/packages/queries/src/tags/index.ts b/packages/queries/src/tags/index.ts new file mode 100644 index 00000000000..6e2133be61e --- /dev/null +++ b/packages/queries/src/tags/index.ts @@ -0,0 +1 @@ +export * from './tags'; diff --git a/packages/manager/src/queries/tags.ts b/packages/queries/src/tags/tags.ts similarity index 95% rename from packages/manager/src/queries/tags.ts rename to packages/queries/src/tags/tags.ts index ac0dc241c8f..c8deeed0094 100644 --- a/packages/manager/src/queries/tags.ts +++ b/packages/queries/src/tags/tags.ts @@ -1,9 +1,10 @@ import { getTags } from '@linode/api-v4'; -import { queryPresets } from '@linode/queries'; import { getAll } from '@linode/utilities'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useQuery } from '@tanstack/react-query'; +import { queryPresets } from '../base'; + import type { APIError, Filter, Params, Tag } from '@linode/api-v4'; import type { QueryClient } from '@tanstack/react-query'; diff --git a/packages/search/package.json b/packages/search/package.json index 772990be27c..bb948e209c1 100644 --- a/packages/search/package.json +++ b/packages/search/package.json @@ -16,6 +16,6 @@ "@linode/api-v4": "workspace:*" }, "peerDependencies": { - "vite": "^5 || ^6" + "vite": "^6.2.4" } } diff --git a/packages/shared/.changeset/README.md b/packages/shared/.changeset/README.md new file mode 100644 index 00000000000..b9a412689fb --- /dev/null +++ b/packages/shared/.changeset/README.md @@ -0,0 +1,18 @@ +# Changesets + +This directory gets auto-populated when running `pnpm changeset`. +You can however add your changesets manually as well, knowing that the [TYPE] is limited to the following options `Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, `Tests`, `Upcoming Features` and follow this format: + +```md +--- +'@linode/[PACKAGE]': [TYPE] +--- + +My PR Description ([#`PR number`](`PR link`)) +``` + +You must commit them to the repo so they can be picked up for the changelog generation. + +This directory get wiped out when running `pnpm generate-changelog`. + +See `changeset.mjs` for implementation details. diff --git a/packages/shared/.eslintrc.json b/packages/shared/.eslintrc.json new file mode 100644 index 00000000000..b8f8c62e03a --- /dev/null +++ b/packages/shared/.eslintrc.json @@ -0,0 +1,5 @@ +{ + // Temporarily extending ESLint config from linode/manager as the base config. + // @todo: modularization - Replace the path with the base shared ESLint config once available. + "extends": ["../manager/.eslintrc.cjs"] +} diff --git a/packages/shared/.prettierrc b/packages/shared/.prettierrc new file mode 100644 index 00000000000..c563e850dad --- /dev/null +++ b/packages/shared/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 80, + "singleQuote": true +} diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md new file mode 100644 index 00000000000..ca9075134dd --- /dev/null +++ b/packages/shared/CHANGELOG.md @@ -0,0 +1,8 @@ +## [2025-04-08] - v0.1.0 + + +### Added: + +- New `shared` package with `LinodeSelect` as the first component ([#11844](https://github.com/linode/manager/pull/11844)) +- Move `useIsGeckoEnabled` hook out of `RegionSelect` to `@linode/shared` package ([#11918](https://github.com/linode/manager/pull/11918)) + diff --git a/packages/shared/README.md b/packages/shared/README.md new file mode 100644 index 00000000000..33cd2c58897 --- /dev/null +++ b/packages/shared/README.md @@ -0,0 +1,15 @@ +# Shared Feature Component Library + +`@linode/shared` contains definitions for React-based feature components and hooks that are used frequently across Akamai Connected Cloud Manager. + +In contrast to the other libraries, [`@linode/ui`](../ui/) and [`@linode/utilities`](../utilities/) in this repository, components and hooks in this package make use of [`@linode/api-v4`](../api-v4/), [`@linode/queries`](../queries/) and other dependencies to implement common, opinionated and complex components to enable a seamless experience for users as they navigate between features of the app. + +## Components + +All components defined in this library must conform to the [CDS 2.0 design system](https://github.com/linode/design-language-system) and be built using base components from [`@linode/ui`](../ui/). + +Interfaces must be documented using the [TSDoc](https://tsdoc.org/) comment standard. This repository also includes support for Storybook stories and Vitest unit tests, which may be included as necessary to improve component quality and reliability. + +## Hooks + +The hooks defined in this library are intended to provide functionality that is too complex or not "pure" enough to be placed in `@linode/utilities`. These hooks are used to implement feature-specific logic and are designed for use within React components. \ No newline at end of file diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000000..b0875312b6d --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,72 @@ +{ + "name": "@linode/shared", + "version": "0.1.0", + "description": "Linode shared feature component library", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "author": "Linode", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/linode/manager/tree/develop/packages/shared" + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "start": "tsc -w --preserveWatchOutput", + "lint": "eslint . --quiet --ext .js,.ts,.tsx", + "typecheck": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "test:debug": "node --inspect-brk scripts/test.js --runInBand", + "precommit": "lint-staged" + }, + "lint-staged": { + "*.{ts,tsx,js}": [ + "prettier --write", + "eslint --ext .js,.ts,.tsx --quiet" + ] + }, + "dependencies": { + "@linode/api-v4": "workspace:*", + "@linode/queries": "workspace:*", + "@linode/ui": "workspace:*", + "@linode/utilities": "workspace:*", + "@mui/material": "^6.4.5", + "@tanstack/react-query": "5.51.24", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@linode/eslint-plugin-cloud-manager": "^0.0.10", + "@storybook/addon-actions": "^8.6.7", + "@storybook/react": "^8.6.7", + "@testing-library/dom": "^10.1.0", + "@testing-library/jest-dom": "~6.4.2", + "@testing-library/react": "~16.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^7.1.0", + "eslint-config-prettier": "~8.1.0", + "eslint-plugin-cypress": "^2.11.3", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-perfectionist": "^1.4.0", + "eslint-plugin-prettier": "~3.3.1", + "eslint-plugin-ramda": "^2.5.1", + "eslint-plugin-react": "^7.19.0", + "eslint-plugin-react-hooks": "^3.0.0", + "eslint-plugin-react-refresh": "^0.4.13", + "eslint-plugin-scanjs-rules": "^0.2.1", + "eslint-plugin-sonarjs": "^0.5.0", + "eslint-plugin-testing-library": "^3.1.2", + "eslint-plugin-xss": "^0.1.10", + "lint-staged": "^15.2.9", + "prettier": "~2.2.1", + "vite-plugin-svgr": "^3.2.0" + } +} diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.stories.tsx b/packages/shared/src/components/LinodeSelect/LinodeSelect.stories.tsx similarity index 96% rename from packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.stories.tsx rename to packages/shared/src/components/LinodeSelect/LinodeSelect.stories.tsx index d7cbd9de212..5b0aa4c78fb 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.stories.tsx +++ b/packages/shared/src/components/LinodeSelect/LinodeSelect.stories.tsx @@ -1,4 +1,3 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; import { action } from '@storybook/addon-actions'; import React from 'react'; @@ -8,6 +7,7 @@ import type { LinodeMultiSelectProps, LinodeSingleSelectProps, } from './LinodeSelect'; +import type { Linode } from '@linode/api-v4/lib/linodes'; import type { Meta, StoryObj } from '@storybook/react'; const linodes = [ diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx b/packages/shared/src/components/LinodeSelect/LinodeSelect.test.tsx similarity index 86% rename from packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx rename to packages/shared/src/components/LinodeSelect/LinodeSelect.test.tsx index e0af79aab29..269395039fa 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx +++ b/packages/shared/src/components/LinodeSelect/LinodeSelect.test.tsx @@ -1,10 +1,10 @@ -import { screen, waitFor } from '@testing-library/react'; +import { linodeFactory } from '@linode/utilities'; +import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { describe, expect, test, vi } from 'vitest'; -import { linodeFactory } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - +import { QueryClientWrapper, renderWithWrappers } from '../../utilities/wrap'; import { LinodeSelect } from './LinodeSelect'; import type { Linode } from '@linode/api-v4'; @@ -17,14 +17,15 @@ describe('LinodeSelect', () => { const options: Linode[] = []; // Assuming no options are available const onSelectionChange = vi.fn(); - renderWithTheme( + const screen = renderWithWrappers( + />, + [QueryClientWrapper()] ); const input = screen.getByTestId(TEXTFIELD_ID); @@ -43,13 +44,14 @@ describe('LinodeSelect', () => { const option: Linode[] = []; // Assuming no options are available const onSelectionChange = vi.fn(); - renderWithTheme( + const screen = renderWithWrappers( + />, + [QueryClientWrapper()] ); // Open the dropdown @@ -68,14 +70,15 @@ describe('LinodeSelect', () => { const option: Linode[] = []; // Assuming no options are available const onSelectionChange = vi.fn(); - renderWithTheme( + const screen = renderWithWrappers( + />, + [QueryClientWrapper()] ); const input = screen.getByTestId(TEXTFIELD_ID); @@ -93,14 +96,15 @@ describe('LinodeSelect', () => { const option = linodeFactory.build({ id: 1, label: 'Linode 1' }); const onSelectionChange = vi.fn(); - renderWithTheme( + const screen = renderWithWrappers( + />, + [QueryClientWrapper()] ); const input = screen.getByTestId(TEXTFIELD_ID); @@ -117,14 +121,15 @@ describe('LinodeSelect', () => { const option = linodeFactory.build({ id: 1, label: 'Linode 1' }); const onSelectionChange = vi.fn(); - renderWithTheme( + const screen = renderWithWrappers( + />, + [QueryClientWrapper()] ); const input = screen.getByTestId(TEXTFIELD_ID); diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/shared/src/components/LinodeSelect/LinodeSelect.tsx similarity index 96% rename from packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx rename to packages/shared/src/components/LinodeSelect/LinodeSelect.tsx index 6903748ed41..f497d095217 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/shared/src/components/LinodeSelect/LinodeSelect.tsx @@ -1,9 +1,7 @@ -import { Autocomplete, CustomPopper } from '@linode/ui'; -import React from 'react'; - -import Close from 'src/assets/icons/close.svg'; import { useAllLinodesQuery } from '@linode/queries'; -import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; +import { Autocomplete, CloseIcon, CustomPopper } from '@linode/ui'; +import { mapIdsToDevices } from '@linode/utilities'; +import React from 'react'; import type { APIError, Filter, Linode } from '@linode/api-v4'; import type { SxProps, Theme } from '@mui/material/styles'; @@ -142,7 +140,6 @@ export const LinodeSelect = ( : linodes?.find(value) ?? null : mapIdsToDevices(value, linodes) } - ChipProps={{ deleteIcon: }} PopperComponent={CustomPopper} clearOnBlur={false} data-testid="add-linode-autocomplete" @@ -162,6 +159,7 @@ export const LinodeSelect = ( onBlur={onBlur} onInputChange={(_, value) => setInputValue(value)} options={options || (linodes ?? [])} + slotProps={{ chip: { deleteIcon: } }} sx={sx} /> ); diff --git a/packages/shared/src/components/LinodeSelect/index.ts b/packages/shared/src/components/LinodeSelect/index.ts new file mode 100644 index 00000000000..c9e407ede3d --- /dev/null +++ b/packages/shared/src/components/LinodeSelect/index.ts @@ -0,0 +1 @@ +export * from './LinodeSelect'; diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts new file mode 100644 index 00000000000..c9e407ede3d --- /dev/null +++ b/packages/shared/src/components/index.ts @@ -0,0 +1 @@ +export * from './LinodeSelect'; diff --git a/packages/shared/src/env.d.ts b/packages/shared/src/env.d.ts new file mode 100644 index 00000000000..f36c722baed --- /dev/null +++ b/packages/shared/src/env.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const src: ComponentClass; + export default src; +} diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts new file mode 100644 index 00000000000..5329361f3a1 --- /dev/null +++ b/packages/shared/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useIsGeckoEnabled'; diff --git a/packages/shared/src/hooks/useIsGeckoEnabled.ts b/packages/shared/src/hooks/useIsGeckoEnabled.ts new file mode 100644 index 00000000000..763edcd9964 --- /dev/null +++ b/packages/shared/src/hooks/useIsGeckoEnabled.ts @@ -0,0 +1,23 @@ +import { useRegionsQuery } from '@linode/queries'; + +import type { Region } from '@linode/api-v4'; + +export const useIsGeckoEnabled = ( + isGecko2EnabledFlag: boolean | undefined, + isGecko2LAFlag: boolean | undefined +) => { + const { data: regions } = useRegionsQuery(); + + const isGeckoLA = isGecko2EnabledFlag && isGecko2LAFlag; + const isGeckoBeta = isGecko2EnabledFlag && !isGecko2LAFlag; + + const hasDistributedRegionCapability = regions?.some((region: Region) => + region.capabilities.includes('Distributed Plans') + ); + const isGeckoLAEnabled = Boolean(hasDistributedRegionCapability && isGeckoLA); + const isGeckoBetaEnabled = Boolean( + hasDistributedRegionCapability && isGeckoBeta + ); + + return { isGeckoBetaEnabled, isGeckoLAEnabled }; +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 00000000000..497509f7e12 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './components'; + +export * from './hooks'; diff --git a/packages/shared/src/utilities/wrap.tsx b/packages/shared/src/utilities/wrap.tsx new file mode 100644 index 00000000000..4893cdafa9f --- /dev/null +++ b/packages/shared/src/utilities/wrap.tsx @@ -0,0 +1,29 @@ +/* eslint-disable react-refresh/only-export-components */ +import { queryClientFactory } from '@linode/queries'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import React from 'react'; + +import type { RenderResult } from '@testing-library/react'; + +type Wrapper = (ui: React.ReactNode) => React.ReactNode; + +export const wrap = ( + ui: React.ReactNode, + wrappers: Wrapper[] +): React.ReactNode => wrappers.reduce((prev, wrapper) => wrapper(prev), ui); + +export const renderWithWrappers = ( + ui: React.ReactNode, + wrappers: Wrapper[] +): RenderResult => { + const renderResult = render(wrap(ui, wrappers)); + return { + ...renderResult, + rerender: (ui) => renderResult.rerender(wrap(ui, wrappers)), + }; +}; + +export const QueryClientWrapper = (queryClient = queryClientFactory()) => ( + ui: React.ReactNode +) => {ui}; diff --git a/packages/shared/testSetup.ts b/packages/shared/testSetup.ts new file mode 100644 index 00000000000..141cd45f9a4 --- /dev/null +++ b/packages/shared/testSetup.ts @@ -0,0 +1,9 @@ +import * as matchers from '@testing-library/jest-dom/matchers'; +import { cleanup } from '@testing-library/react'; +import { afterEach, expect } from 'vitest'; + +expect.extend(matchers); + +afterEach(() => { + cleanup(); +}); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000000..160560a852f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react", + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "noEmit": true, + "allowUnreachableCode": false, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "types": ["@testing-library/jest-dom"] + }, + "include": ["src"] +} diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 00000000000..ba5b20959d2 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,11 @@ +import svgr from 'vite-plugin-svgr'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [svgr({ exportAsDefault: true })], + + test: { + environment: 'jsdom', + setupFiles: './testSetup.ts', + }, +}); diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 76f53396f26..0246c62b847 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,3 +1,20 @@ +## [2025-04-08] - v0.9.0 + + +### Added: + +- Move `ListItemOption` from `manager` to `ui` package ([#11790](https://github.com/linode/manager/pull/11790)) +- A new `NewFeatureChip` component and updated BetaChip styles ([#11965](https://github.com/linode/manager/pull/11965)) +- Chevron Up Icon ([#11946](https://github.com/linode/manager/pull/11946)) + +### Changed: + +- Add `Checkbox` design tokens and update styles to match Akamai Design System ([#11871](https://github.com/linode/manager/pull/11871)) + +### Fixed: + +- BetaChip `color` prop ([#11872](https://github.com/linode/manager/pull/11872)) + ## [2025-03-25] - v0.8.0 diff --git a/packages/ui/package.json b/packages/ui/package.json index 48e7ceedb8b..2397152b70b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.8.0", + "version": "0.9.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", @@ -45,7 +45,7 @@ ] }, "devDependencies": { - "@linode/eslint-plugin-cloud-manager": "^0.0.7", + "@linode/eslint-plugin-cloud-manager": "^0.0.10", "@storybook/addon-actions": "^8.6.7", "@storybook/preview-api": "^8.6.7", "@storybook/react": "^8.6.7", diff --git a/packages/ui/src/assets/icons/chevron-up.svg b/packages/ui/src/assets/icons/chevron-up.svg new file mode 100644 index 00000000000..77421075bf1 --- /dev/null +++ b/packages/ui/src/assets/icons/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/index.ts b/packages/ui/src/assets/icons/index.ts index 7554d0d7358..fe3a598fa29 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -4,6 +4,8 @@ export { default as CheckboxIcon } from './checkbox.svg'; export { default as CheckboxCheckedIcon } from './checkboxChecked.svg'; export { default as CheckboxIndeterminateIcon } from './checkboxIndeterminate.svg'; export { default as ChevronDownIcon } from './chevron-down.svg'; +export { default as ChevronUpIcon } from './chevron-up.svg'; +export { default as CloseIcon } from './close.svg'; export { default as InfoIcon } from './info.svg'; export { default as PendingIcon } from './pending.svg'; export { default as PlusSignIcon } from './plusSign.svg'; diff --git a/packages/ui/src/assets/icons/radio.svg b/packages/ui/src/assets/icons/radio.svg index 22bc08e92d2..ee0ce1fcfcb 100644 --- a/packages/ui/src/assets/icons/radio.svg +++ b/packages/ui/src/assets/icons/radio.svg @@ -1,6 +1,3 @@ - - - - - + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/radioRadioed.svg b/packages/ui/src/assets/icons/radioRadioed.svg index 76031bbcb4a..0b99e9ec03d 100644 --- a/packages/ui/src/assets/icons/radioRadioed.svg +++ b/packages/ui/src/assets/icons/radioRadioed.svg @@ -1,16 +1,3 @@ - - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/packages/manager/src/components/BetaChip/BetaChip.stories.tsx b/packages/ui/src/components/BetaChip/BetaChip.stories.tsx similarity index 92% rename from packages/manager/src/components/BetaChip/BetaChip.stories.tsx rename to packages/ui/src/components/BetaChip/BetaChip.stories.tsx index 3cc3a4ea934..047795c144b 100644 --- a/packages/manager/src/components/BetaChip/BetaChip.stories.tsx +++ b/packages/ui/src/components/BetaChip/BetaChip.stories.tsx @@ -10,7 +10,7 @@ export const Default: StoryObj = { }; const meta: Meta = { - args: { color: 'default' }, + args: {}, component: BetaChip, title: 'Foundations/Chip/BetaChip', }; diff --git a/packages/ui/src/components/BetaChip/BetaChip.test.tsx b/packages/ui/src/components/BetaChip/BetaChip.test.tsx index ebd06322c14..c30e2b1b68d 100644 --- a/packages/ui/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/ui/src/components/BetaChip/BetaChip.test.tsx @@ -11,21 +11,12 @@ describe('BetaChip', () => { const { getByTestId } = renderWithTheme(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: rgba(0, 0, 0, 0.08)'); - }); - - it('renders with primary color', () => { - const { getByTestId } = renderWithTheme(); - const betaChip = getByTestId('betaChip'); - expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)'); + expect(betaChip).toHaveStyle('background-color: rgb(105, 105, 112)'); }); it('triggers an onClick callback', () => { const onClickMock = vi.fn(); - const { getByTestId } = renderWithTheme( - - ); + const { getByTestId } = renderWithTheme(); const betaChip = getByTestId('betaChip'); fireEvent.click(betaChip); expect(onClickMock).toHaveBeenCalledTimes(1); diff --git a/packages/ui/src/components/BetaChip/BetaChip.tsx b/packages/ui/src/components/BetaChip/BetaChip.tsx index 33b67e12711..9c7bc131a37 100644 --- a/packages/ui/src/components/BetaChip/BetaChip.tsx +++ b/packages/ui/src/components/BetaChip/BetaChip.tsx @@ -1,66 +1,54 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { Global } from '@linode/design-language-system'; import { Chip } from '../Chip'; import type { ChipProps } from '@mui/material'; -export interface BetaChipProps - extends Omit< - ChipProps, - | 'avatar' - | 'clickable' - | 'deleteIcon' - | 'disabled' - | 'icon' - | 'label' - | 'onDelete' - | 'outlineColor' - | 'size' - | 'variant' - > { - /** - * The color of the chip. - * default renders a gray chip, primary renders a blue chip. - */ - color?: 'default' | 'primary'; -} +export type BetaChipProps = Omit< + ChipProps, + | 'avatar' + | 'clickable' + | 'deleteIcon' + | 'disabled' + | 'icon' + | 'label' + | 'onDelete' + | 'outlineColor' + | 'size' + | 'variant' +>; /** * ## Usage * - * Beta chips label features that are not yet part of Cloud Manager's core supported functionality.
    + * BetaChip is used when a feature is available to a limited number of users as part of a beta rollout.
    * **Example:** A beta chip may appear in the [primary navigation](https://github.com/linode/manager/pull/8104#issuecomment-1309334374), * breadcrumbs, [banners](/docs/components-notifications-dismissible-banners--beta-banners), tabs, and/or plain text to designate beta functionality.
    * **Visual style:** bold, capitalized text; reduced height, letter spacing, and font size; solid color background. * */ export const BetaChip = (props: BetaChipProps) => { - const { color } = props; - - return ( - - ); + return ; }; const StyledBetaChip = styled(Chip, { label: 'StyledBetaChip', + shouldForwardProp: (prop) => prop !== 'color', })(({ theme }) => ({ '& .MuiChip-label': { padding: 0, }, - background: 'lch(77.7 28.7 275 / 0.12)', - color: theme.tokens.color.Ultramarine[50], - font: theme.font.bold, - fontSize: '0.625rem', + background: Global.Color.Neutrals[70], + color: Global.Color.Neutrals.White, + + fontWeight: theme.tokens.font.FontWeight.Extrabold, + fontSize: '11px', + lineHeight: '12px', height: 16, - letterSpacing: '.25px', - marginLeft: theme.spacing(), - padding: theme.spacing(0.5), - textTransform: 'uppercase', + letterSpacing: '.22px', + marginLeft: theme.spacingFunction(8), + padding: theme.spacingFunction(4), + textTransform: theme.tokens.font.Textcase.Uppercase, })); diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx index 1cb40fce6b4..1c63c838d3b 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx @@ -1,14 +1,24 @@ import React from 'react'; +import { Box } from '../Box'; import { Checkbox } from './Checkbox'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; const meta: Meta = { component: Checkbox, + decorators: [ + (Story: StoryFn) => ( + ({ margin: theme.tokens.spacing.S16 })}> + + + ), + ], title: 'Foundations/Checkbox', }; +export default meta; + type Story = StoryObj; export const Default: Story = { @@ -27,43 +37,81 @@ export const Default: Story = { args: { checked: false, }, - render: (args) => , +}; + +export const Unchecked: Story = { + args: { + checked: false, + }, }; export const Checked: Story = { args: { checked: true, }, - render: (args) => , }; -export const Unchecked: Story = { +export const Indeterminate: Story = { args: { - checked: false, + indeterminate: true, + }, +}; + +export const UncheckedDisabled: Story = { + args: { + disabled: true, + }, +}; + +export const CheckedDisabled: Story = { + args: { + checked: true, + disabled: true, }, - render: (args) => , }; -export const Label: Story = { +export const IndeterminateDisabled: Story = { + args: { + indeterminate: true, + disabled: true, + }, +}; + +export const UncheckedReadOnly: Story = { + args: { + readOnly: true, + }, +}; + +export const CheckedReadOnly: Story = { + args: { + readOnly: true, + checked: true, + }, +}; + +export const IndeterminateReadOnly: Story = { + args: { + readOnly: true, + indeterminate: true, + }, +}; + +export const WithLabel: Story = { args: { text: 'This Checkbox has a label', }, - render: (args) => , }; -export const Tooltip: Story = { +export const WithTooltip: Story = { args: { toolTipText: 'This is the tooltip!', }, - render: (args) => , }; -export const LabelAndTooltip: Story = { +export const WithLabelAndTooltip: Story = { args: { text: 'This Checkbox has a tooltip', toolTipText: 'This is the tooltip!', }, - render: (args) => , }; - -export default meta; diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index cfe056661ec..1470e11affe 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -1,5 +1,3 @@ -// @todo: modularization - Import from 'ui' package once FormControlLabel is migrated. -import { FormControlLabel } from '@mui/material'; import _Checkbox from '@mui/material/Checkbox'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -10,6 +8,7 @@ import { CheckboxIndeterminateIcon, } from '../../assets/icons'; import { TooltipIcon } from '../TooltipIcon'; +import { FormControlLabel } from '../FormControlLabel'; import type { CheckboxProps } from '@mui/material/Checkbox'; import type { SxProps, Theme } from '@mui/material/styles'; @@ -80,25 +79,42 @@ const StyledCheckbox = styled(_Checkbox)(({ theme, ...props }) => ({ '& .defaultFill': { transition: theme.transitions.create(['fill']), }, - '&:hover': { - color: theme.palette.primary.main, - }, - color: theme.tokens.color.Neutrals[40], + padding: theme.tokens.spacing.S8, transition: theme.transitions.create(['color']), - ...(props.checked && { - color: theme.palette.primary.main, - }), - ...(props.disabled && { - '& .defaultFill': { - fill: `${theme.bg.main}`, - opacity: 0.5, - }, - color: `${theme.tokens.color.Neutrals[40]} !important`, - fill: `${theme.bg.main} !important`, + // Unchecked & Readonly + ...(props.readOnly && { + color: theme.tokens.component.Checkbox.Empty.ReadOnly.Border, pointerEvents: 'none', }), + // Checked & Readonly + ...(props.checked && + props.readOnly && { + svg: { + '#Check': { + fill: theme.tokens.component.Checkbox.Checked.ReadOnly.Icon, + }, + border: `1px solid ${theme.tokens.component.Checkbox.Checked.ReadOnly.Border}`, + }, + color: `${theme.tokens.component.Checkbox.Checked.ReadOnly.Background} !important`, + pointerEvents: 'none', + }), + // Indeterminate & Readonly + ...(props.indeterminate && + props.readOnly && { + svg: { + 'g rect:nth-of-type(2)': { + fill: theme.tokens.component.Checkbox.Indeterminated.ReadOnly.Icon, + }, + border: `1px solid ${theme.tokens.component.Checkbox.Indeterminated.ReadOnly.Border}`, + }, + color: `${theme.tokens.component.Checkbox.Checked.ReadOnly.Background} !important`, + pointerEvents: 'none', + }), })); -const StyledFormControlLabel = styled(FormControlLabel)(() => ({ +const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({ + '& .MuiFormControlLabel-label': { + paddingTop: theme.tokens.spacing.S2, + }, marginRight: 0, })); diff --git a/packages/ui/src/components/Chip/Chip.tsx b/packages/ui/src/components/Chip/Chip.tsx index 18a6b36f144..8f5a56d1f2b 100644 --- a/packages/ui/src/components/Chip/Chip.tsx +++ b/packages/ui/src/components/Chip/Chip.tsx @@ -10,6 +10,8 @@ export interface ChipProps extends _ChipProps { component?: React.ElementType; } -export const Chip = (props: ChipProps) => { - return <_Chip {...props} />; -}; +export const Chip = React.forwardRef( + (props: ChipProps, ref) => { + return <_Chip ref={ref} {...props} />; + } +); diff --git a/packages/ui/src/components/EditableText/EditableText.tsx b/packages/ui/src/components/EditableText/EditableText.tsx index a19323bc83f..541bab797c9 100644 --- a/packages/ui/src/components/EditableText/EditableText.tsx +++ b/packages/ui/src/components/EditableText/EditableText.tsx @@ -13,7 +13,7 @@ import type { TextFieldProps } from '../TextField'; import type { Theme } from '@mui/material/styles'; import type { PropsWithChildren } from 'react'; -const useStyles = makeStyles()( +const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ button: { '&[aria-label="Save"]': { @@ -99,6 +99,11 @@ const useStyles = makeStyles()( textDecoration: 'underline !important', }, }, + breadcrumbText: { + color: theme.tokens.component.Breadcrumb.Normal.Text.Default, + fontSize: '1rem !important', + paddingLeft: 0, + }, }) ); @@ -135,6 +140,10 @@ interface BaseProps extends Omit { * Optional suffix to append to the text when it is not in editing mode */ textSuffix?: string; + /** + * Whether this EditableText is used as a breadcrumb + */ + isBreadcrumb?: boolean; } interface PropsWithoutLink extends BaseProps { @@ -163,7 +172,7 @@ interface PropsWithLink extends BaseProps { export type EditableTextProps = PropsWithLink | PropsWithoutLink; export const EditableText = (props: EditableTextProps) => { - const { classes } = useStyles(); + const { classes, cx } = useStyles(); const [isEditing, setIsEditing] = React.useState(Boolean(props.errorText)); const [text, setText] = React.useState(props.text); @@ -173,6 +182,7 @@ export const EditableText = (props: EditableTextProps) => { disabledBreadcrumbEditButton, errorText, handleAnalyticsEvent, + isBreadcrumb, labelLink, onCancel, onEdit, @@ -237,7 +247,7 @@ export const EditableText = (props: EditableTextProps) => { }; const labelText = ( @@ -245,7 +255,7 @@ export const EditableText = (props: EditableTextProps) => { return !isEditing && !errorText ? (
    {!!labelLink ? ( @@ -258,7 +268,7 @@ export const EditableText = (props: EditableTextProps) => { {/** pencil icon */}
    ) : ( -
    +
    { +export interface ListItemOptionProps { children?: React.ReactNode; disabledOptions?: DisableItemOption; item: T & { id: number | string }; @@ -34,7 +38,7 @@ export const ListItemOption = ({ maxHeight, props, selected, -}: ListItemProps) => { +}: ListItemOptionProps) => { const { className, onClick, ...rest } = props; const isItemOptionDisabled = Boolean(disabledOptions); const itemOptionDisabledReason = disabledOptions?.reason; diff --git a/packages/ui/src/components/ListItemOption/index.ts b/packages/ui/src/components/ListItemOption/index.ts new file mode 100644 index 00000000000..03d3422cd55 --- /dev/null +++ b/packages/ui/src/components/ListItemOption/index.ts @@ -0,0 +1 @@ +export * from './ListItemOption'; diff --git a/packages/ui/src/components/NewFeatureChip/NewFeatureChip.stories.tsx b/packages/ui/src/components/NewFeatureChip/NewFeatureChip.stories.tsx new file mode 100644 index 00000000000..10c6e89a73e --- /dev/null +++ b/packages/ui/src/components/NewFeatureChip/NewFeatureChip.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { NewFeatureChip } from './NewFeatureChip'; + +import type { NewFeatureChipProps } from './NewFeatureChip'; +import type { Meta, StoryObj } from '@storybook/react'; + +export const Default: StoryObj = { + render: (args) => , +}; + +const meta: Meta = { + args: { color: 'default' }, + component: NewFeatureChip, + title: 'Foundations/Chip/NewFeatureChip', +}; +export default meta; diff --git a/packages/ui/src/components/NewFeatureChip/NewFeatureChip.test.tsx b/packages/ui/src/components/NewFeatureChip/NewFeatureChip.test.tsx new file mode 100644 index 00000000000..abfe861604b --- /dev/null +++ b/packages/ui/src/components/NewFeatureChip/NewFeatureChip.test.tsx @@ -0,0 +1,26 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithTheme } from '../../utilities/testHelpers'; +import { NewFeatureChip } from './NewFeatureChip'; + +describe('NewFeatureChip', () => { + it('renders with default color', () => { + const { getByTestId } = renderWithTheme(); + const newFeatureChip = getByTestId('newFeatureChip'); + expect(newFeatureChip).toBeInTheDocument(); + expect(newFeatureChip).toHaveStyle('background-color: rgb(114, 89, 214)'); + }); + + it('triggers an onClick callback', () => { + const onClickMock = vi.fn(); + const { getByTestId } = renderWithTheme( + + ); + const newFeatureChip = getByTestId('newFeatureChip'); + fireEvent.click(newFeatureChip); + expect(onClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/NewFeatureChip/NewFeatureChip.tsx b/packages/ui/src/components/NewFeatureChip/NewFeatureChip.tsx new file mode 100644 index 00000000000..5b3da06eef9 --- /dev/null +++ b/packages/ui/src/components/NewFeatureChip/NewFeatureChip.tsx @@ -0,0 +1,57 @@ +import { styled } from '@mui/material/styles'; +import { Global } from '@linode/design-language-system'; +import * as React from 'react'; + +import { Chip } from '../Chip'; + +import type { ChipProps } from '@mui/material'; + +export type NewFeatureChipProps = Omit< + ChipProps, + | 'avatar' + | 'clickable' + | 'deleteIcon' + | 'disabled' + | 'icon' + | 'label' + | 'onDelete' + | 'outlineColor' + | 'size' + | 'variant' +>; + +/** + * ## Usage + * + * The NewFeatureChip is displayed to all users after the feature has been fully rolled out.
    + * **Example:** A NewFeatureChip chip may appear in the primary navigation, + * breadcrumbs, banners, tabs, and/or plain text to designate new functionality and improve visibility for all the users.
    + * **Visual style:** bold, capitalized text; reduced height, letter spacing, and font size; solid color background. + * + */ +export const NewFeatureChip = (props: NewFeatureChipProps) => { + return ( + + ); +}; + +const StyledNewFeatureChip = styled(Chip, { + label: 'StyledNewFeatureChip', + shouldForwardProp: (prop) => prop !== 'color', +})(({ theme }) => ({ + '& .MuiChip-label': { + padding: 0, + }, + background: Global.Color.Violet[70], + color: Global.Color.Neutrals.White, + + font: theme.font.bold, + fontSize: '11px', + fontWeight: theme.tokens.font.FontWeight.Extrabold, + lineHeight: '12px', + height: 16, + letterSpacing: '.22px', + marginLeft: theme.spacingFunction(8), + padding: theme.spacingFunction(4), + textTransform: theme.tokens.font.Textcase.Uppercase, +})); diff --git a/packages/ui/src/components/NewFeatureChip/index.ts b/packages/ui/src/components/NewFeatureChip/index.ts new file mode 100644 index 00000000000..d7c6ceaeaca --- /dev/null +++ b/packages/ui/src/components/NewFeatureChip/index.ts @@ -0,0 +1 @@ +export * from './NewFeatureChip'; diff --git a/packages/ui/src/components/Radio/Radio.test.tsx b/packages/ui/src/components/Radio/Radio.test.tsx index 27f7aebecf1..6e7af47fe5f 100644 --- a/packages/ui/src/components/Radio/Radio.test.tsx +++ b/packages/ui/src/components/Radio/Radio.test.tsx @@ -12,10 +12,13 @@ describe('Radio', () => { const radio = screen.getByRole('radio'); expect(radio).toBeInTheDocument(); - const notFilled = screen.container.querySelector('[id="Oval-2"]'); + + const notFilled = screen.container.querySelector('#radio-inner'); expect(notFilled).not.toBeInTheDocument(); + fireEvent.click(radio); - const filled = screen.container.querySelector('[id="Oval-2"]'); + + const filled = screen.container.querySelector('#radio-inner'); expect(filled).toBeInTheDocument(); }); diff --git a/packages/ui/src/components/Radio/Radio.tsx b/packages/ui/src/components/Radio/Radio.tsx index 7beaab125f9..77f331c49c0 100644 --- a/packages/ui/src/components/Radio/Radio.tsx +++ b/packages/ui/src/components/Radio/Radio.tsx @@ -31,14 +31,14 @@ export const Radio = (props: RadioProps) => { } icon={ } data-qa-radio={props.checked || false} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 7860eb3b358..375e7648336 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -24,6 +24,7 @@ export * from './InputAdornment'; export * from './InputLabel'; export * from './List'; export * from './ListItem'; +export * from './ListItemOption'; export * from './Notice'; export * from './Paper'; export * from './Radio'; @@ -33,6 +34,7 @@ export * from './Stack'; export * from './SvgIcon'; export * from './TextField'; export * from './Toggle'; +export * from './NewFeatureChip'; export * from './Tooltip'; export * from './TooltipIcon'; export * from './Typography'; diff --git a/packages/ui/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts index 843623a573d..9059eb3773c 100644 --- a/packages/ui/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -385,6 +385,29 @@ export const darkTheme: ThemeOptions = { }, }, }, + MuiCheckbox: { + styleOverrides: { + root: { + // Unchecked & Disabled + '&.Mui-disabled': { + '& svg': { + backgroundColor: Component.Checkbox.Empty.Disabled.Background, + }, + color: Component.Checkbox.Empty.Disabled.Border, + pointerEvents: 'none', + }, + // Checked & Disabled + '&.Mui-checked.Mui-disabled': { + color: Component.Checkbox.Checked.Disabled.Background, + }, + // Indeterminate & Disabled + '&.MuiCheckbox-indeterminate.Mui-disabled': { + color: Component.Checkbox.Indeterminated.Disabled.Background, + }, + color: Component.Checkbox.Empty.Default.Border, + }, + }, + }, MuiChip: { defaultProps: { // In dark mode, we decided our Chips will be our primary color by default. @@ -757,6 +780,7 @@ export const darkTheme: ThemeOptions = { '&:hover': { color: theme.palette.primary.main, }, + padding: '10px 10px', }), }, }, diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index c21153eab78..d1f6f56d698 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -590,7 +590,37 @@ export const lightTheme: ThemeOptions = { MuiCheckbox: { styleOverrides: { root: { - color: Color.Neutrals[40], + '&:active': { + color: `${Component.Checkbox.Empty.Active.Border} !important`, + }, + '&:hover': { + color: `${Component.Checkbox.Empty.Hover.Border} !important`, + }, + // Checked + '&.Mui-checked': { + color: Component.Checkbox.Checked.Default.Background, + }, + // Indeterminate + '&.MuiCheckbox-indeterminate': { + color: Component.Checkbox.Indeterminated.Default.Background, + }, + // Unchecked & Disabled + '&.Mui-disabled': { + '& svg': { + backgroundColor: Component.Checkbox.Empty.Disabled.Background, + }, + color: Component.Checkbox.Empty.Disabled.Border, + pointerEvents: 'none', + }, + // Checked & Disabled + '&.Mui-checked.Mui-disabled': { + color: Component.Checkbox.Checked.Disabled.Background, + }, + // Indeterminate & Disabled + '&.MuiCheckbox-indeterminate.Mui-disabled': { + color: Component.Checkbox.Indeterminated.Disabled.Background, + }, + color: Component.Checkbox.Empty.Default.Border, }, }, }, @@ -1124,25 +1154,35 @@ export const lightTheme: ThemeOptions = { color: primaryColors.main, }, root: ({ theme }) => ({ - '& $checked': { - color: primaryColors.main, + '&:active': { + color: theme.tokens.component.RadioButton.Active.Active.Border, + }, + '&.Mui-checked': { + color: theme.tokens.component.RadioButton.Active.Default.Border, + '&:active': { + color: theme.tokens.component.RadioButton.Active.Active.Border, + }, }, '& .defaultFill': { fill: theme.color.white, transition: theme.transitions.create(['fill']), }, + '& svg circle': { + fill: Color.Neutrals.White, + }, '&.Mui-disabled': { '& .defaultFill': { fill: Color.Neutrals[5], }, - color: `${Color.Neutrals[40]} !important`, - fill: `${Color.Neutrals[5]} !important`, - pointerEvents: 'none', - }, - '&.MuiRadio-root': { - '.MuiSvgIcon-fontSizeMedium': { - fontSize: '20px', + '&:not(.Mui-checked) svg circle': { + fill: Color.Neutrals[20], }, + '&:not(.Mui-checked)': { + color: + theme.tokens.component.RadioButton.Inactive.Disabled.Border, + }, + color: theme.tokens.component.RadioButton.Active.Disabled.Border, + pointerEvents: 'none', }, '&.MuiRadio-sizeSmall': { '.MuiSvgIcon-fontSizeSmall': { @@ -1153,10 +1193,10 @@ export const lightTheme: ThemeOptions = { '& .defaultFill': { fill: theme.color.white, }, - color: theme.palette.primary.main, - fill: theme.color.white, + color: theme.tokens.component.RadioButton.Active.Hover.Border, + fill: theme.tokens.component.RadioButton.Active.Hover.Background, }, - color: Color.Neutrals[40], + color: theme.tokens.alias.Action.Neutral, padding: '10px 10px', transition: theme.transitions.create(['color']), }), @@ -1208,7 +1248,7 @@ export const lightTheme: ThemeOptions = { MuiSvgIcon: { styleOverrides: { root: { - fontSize: 24, + fontSize: 20, }, }, }, diff --git a/packages/utilities/.prettierrc b/packages/utilities/.prettierrc index c563e850dad..7d06e0e8fdf 100644 --- a/packages/utilities/.prettierrc +++ b/packages/utilities/.prettierrc @@ -1,4 +1,4 @@ { "printWidth": 80, "singleQuote": true -} +} \ No newline at end of file diff --git a/packages/utilities/CHANGELOG.md b/packages/utilities/CHANGELOG.md index f7d5d36aaba..46208b09409 100644 --- a/packages/utilities/CHANGELOG.md +++ b/packages/utilities/CHANGELOG.md @@ -1,3 +1,19 @@ +## [2025-04-08] - v0.2.0 + + +### Added: + +- Move `regionsData` from `manager` to `utilities` package ([#11790](https://github.com/linode/manager/pull/11790)) +- Move `LinodeCreateType` to `utilities` package ([#11790](https://github.com/linode/manager/pull/11790)) +- Move `doesRegionSupportFeature` from `manager` to `utilities` package ([#11891](https://github.com/linode/manager/pull/11891)) +- Add `luxon` dependency and move related utils from `manager` to `utilities` package ([#11905](https://github.com/linode/manager/pull/11905)) +- Migrate ramda dependent utils to @linode/utilities package ([#11913](https://github.com/linode/manager/pull/11913)) +- Move `useFormattedDate` from `manager` to `utilities` package ([#11931](https://github.com/linode/manager/pull/11931)) + +### Removed: + +- Unused utils with security vulnerabilities ([#11899](https://github.com/linode/manager/pull/11899)) + ## [2025-03-25] - v0.1.0 diff --git a/packages/utilities/package.json b/packages/utilities/package.json index 0d2d513b8d9..4822500cfcb 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -1,8 +1,8 @@ { "name": "@linode/utilities", - "version": "0.1.0", + "version": "0.2.0", "description": "Linode Utility functions library", - "main": "src/index.js", + "main": "src/index.ts", "module": "src/index.ts", "types": "src/index.ts", "author": "Linode", @@ -32,15 +32,18 @@ }, "dependencies": { "@linode/api-v4": "workspace:*", - "@types/ramda": "0.25.16", + "luxon": "3.4.4", + "ramda": "~0.25.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { - "@linode/eslint-plugin-cloud-manager": "^0.0.7", + "@linode/eslint-plugin-cloud-manager": "^0.0.10", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", + "@types/luxon": "3.4.2", + "@types/ramda": "0.25.16", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -50,7 +53,6 @@ "eslint-plugin-prettier": "~3.3.1", "eslint-plugin-sonarjs": "^0.5.0", "factory.ts": "^0.5.1", - "prettier": "~2.2.1", - "ramda": "~0.25.0" + "prettier": "~2.2.1" } } diff --git a/packages/utilities/src/__data__/index.ts b/packages/utilities/src/__data__/index.ts new file mode 100644 index 00000000000..4e832e3b55d --- /dev/null +++ b/packages/utilities/src/__data__/index.ts @@ -0,0 +1 @@ +export * from './regionsData'; diff --git a/packages/manager/src/__data__/regionsData.ts b/packages/utilities/src/__data__/regionsData.ts similarity index 100% rename from packages/manager/src/__data__/regionsData.ts rename to packages/utilities/src/__data__/regionsData.ts diff --git a/packages/manager/src/factories/accountAvailability.ts b/packages/utilities/src/factories/accountAvailability.ts similarity index 79% rename from packages/manager/src/factories/accountAvailability.ts rename to packages/utilities/src/factories/accountAvailability.ts index 05ce1dbd0ad..1d427dcf5b7 100644 --- a/packages/manager/src/factories/accountAvailability.ts +++ b/packages/utilities/src/factories/accountAvailability.ts @@ -1,5 +1,6 @@ -import { pickRandom } from '@linode/utilities'; -import { Factory } from '@linode/utilities'; +import { Factory } from './factoryProxy'; + +import { pickRandom } from '../helpers'; import type { AccountAvailability } from '@linode/api-v4'; diff --git a/packages/utilities/src/factories/index.ts b/packages/utilities/src/factories/index.ts index 603414c5554..ea88b488e7c 100644 --- a/packages/utilities/src/factories/index.ts +++ b/packages/utilities/src/factories/index.ts @@ -1,4 +1,8 @@ +export * from './accountAvailability'; export * from './config'; export * from './factoryProxy'; -export * from './linodeConfigInterfaceFactory'; +export * from './linodes'; +export * from './linodeConfigInterface'; export * from './linodeInterface'; +export * from './nodebalancer'; +export * from './regions'; diff --git a/packages/utilities/src/factories/linodeConfigInterfaceFactory.ts b/packages/utilities/src/factories/linodeConfigInterface.ts similarity index 100% rename from packages/utilities/src/factories/linodeConfigInterfaceFactory.ts rename to packages/utilities/src/factories/linodeConfigInterface.ts diff --git a/packages/utilities/src/factories/linodeInterface.ts b/packages/utilities/src/factories/linodeInterface.ts index bfefe51c182..2e7ae010b73 100644 --- a/packages/utilities/src/factories/linodeInterface.ts +++ b/packages/utilities/src/factories/linodeInterface.ts @@ -18,14 +18,14 @@ export const linodeInterfaceSettingsFactory = Factory.Sync.makeFactory( { - created: '2020-01-01 00:00:00', + created: '2025-03-19T03:58:04', default_route: { ipv4: true, }, id: Factory.each((i) => i), mac_address: 'a4:ac:39:b7:6e:42', public: null, - updated: '2020-01-01 00:00:00', + updated: '2025-03-19T03:58:04', version: 1, vlan: { ipam_address: '192.168.0.1', @@ -37,14 +37,14 @@ export const linodeInterfaceFactoryVlan = Factory.Sync.makeFactory( { - created: '2020-01-01 00:00:00', + created: '2025-03-19T03:58:04', default_route: { ipv4: true, }, id: Factory.each((i) => i), mac_address: 'a4:ac:39:b7:6e:42', public: null, - updated: '2020-01-01 00:00:00', + updated: '2025-03-19T03:58:04', version: 1, vlan: null, vpc: { @@ -69,7 +69,7 @@ export const linodeInterfaceFactoryVPC = Factory.Sync.makeFactory( { - created: '2020-01-01 00:00:00', + created: '2025-03-19T03:58:04', default_route: { ipv4: true, }, @@ -91,7 +91,7 @@ export const linodeInterfaceFactoryPublic = Factory.Sync.makeFactory { diff --git a/packages/manager/src/utilities/createDevicesFromStrings.ts b/packages/utilities/src/helpers/createDevicesFromStrings.ts similarity index 100% rename from packages/manager/src/utilities/createDevicesFromStrings.ts rename to packages/utilities/src/helpers/createDevicesFromStrings.ts diff --git a/packages/manager/src/utilities/createStringsFromDevices.test.ts b/packages/utilities/src/helpers/createStringsFromDevices.test.ts similarity index 96% rename from packages/manager/src/utilities/createStringsFromDevices.test.ts rename to packages/utilities/src/helpers/createStringsFromDevices.test.ts index b255f7f92c8..2b851ec87e6 100644 --- a/packages/manager/src/utilities/createStringsFromDevices.test.ts +++ b/packages/utilities/src/helpers/createStringsFromDevices.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest'; + import { createStringsFromDevices } from './createStringsFromDevices'; describe('LinodeRescue', () => { diff --git a/packages/manager/src/utilities/createStringsFromDevices.ts b/packages/utilities/src/helpers/createStringsFromDevices.ts similarity index 70% rename from packages/manager/src/utilities/createStringsFromDevices.ts rename to packages/utilities/src/helpers/createStringsFromDevices.ts index 4fe6ffeee5a..d31f27dff46 100644 --- a/packages/manager/src/utilities/createStringsFromDevices.ts +++ b/packages/utilities/src/helpers/createStringsFromDevices.ts @@ -1,7 +1,9 @@ -import { DiskDevice, VolumeDevice } from '@linode/api-v4/lib/linodes'; -import { compose, reduce, toPairs } from 'ramda'; - -import { DevicesAsStrings } from 'src/utilities/createDevicesFromStrings'; +import type { + Devices, + DiskDevice, + VolumeDevice, +} from '@linode/api-v4/lib/linodes'; +import type { DevicesAsStrings } from './createDevicesFromStrings'; const rdx = ( result: DevicesAsStrings, @@ -31,4 +33,5 @@ const isVolume = ( return typeof (device as VolumeDevice).volume_id === 'number'; }; -export const createStringsFromDevices = compose(reduce(rdx, {}), toPairs); +export const createStringsFromDevices = (devices: Devices) => + Object.entries(devices).reduce(rdx, {}); diff --git a/packages/manager/src/utilities/doesRegionSupportFeature.test.ts b/packages/utilities/src/helpers/doesRegionSupportFeature.test.ts similarity index 85% rename from packages/manager/src/utilities/doesRegionSupportFeature.test.ts rename to packages/utilities/src/helpers/doesRegionSupportFeature.test.ts index b2fae919e5b..366d5eb4120 100644 --- a/packages/manager/src/utilities/doesRegionSupportFeature.test.ts +++ b/packages/utilities/src/helpers/doesRegionSupportFeature.test.ts @@ -1,4 +1,6 @@ -import { regions } from 'src/__data__/regionsData'; +import { regions } from '../__data__'; + +import { describe, expect, it } from 'vitest'; import { doesRegionSupportFeature } from './doesRegionSupportFeature'; diff --git a/packages/manager/src/utilities/doesRegionSupportFeature.ts b/packages/utilities/src/helpers/doesRegionSupportFeature.ts similarity index 100% rename from packages/manager/src/utilities/doesRegionSupportFeature.ts rename to packages/utilities/src/helpers/doesRegionSupportFeature.ts diff --git a/packages/manager/src/utilities/formatDuration.test.ts b/packages/utilities/src/helpers/formatDuration.test.ts similarity index 96% rename from packages/manager/src/utilities/formatDuration.test.ts rename to packages/utilities/src/helpers/formatDuration.test.ts index 1db03ec14e1..281615d66fb 100644 --- a/packages/manager/src/utilities/formatDuration.test.ts +++ b/packages/utilities/src/helpers/formatDuration.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest'; + import { Duration } from 'luxon'; import { formatDuration } from './formatDuration'; diff --git a/packages/manager/src/utilities/formatDuration.ts b/packages/utilities/src/helpers/formatDuration.ts similarity index 100% rename from packages/manager/src/utilities/formatDuration.ts rename to packages/utilities/src/helpers/formatDuration.ts diff --git a/packages/manager/src/utilities/formatUptime.test.ts b/packages/utilities/src/helpers/formatUptime.test.ts similarity index 96% rename from packages/manager/src/utilities/formatUptime.test.ts rename to packages/utilities/src/helpers/formatUptime.test.ts index 92ac96042cd..fb2738f4617 100644 --- a/packages/manager/src/utilities/formatUptime.test.ts +++ b/packages/utilities/src/helpers/formatUptime.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest'; + import { Duration } from 'luxon'; import { formatUptime } from './formatUptime'; diff --git a/packages/manager/src/utilities/formatUptime.ts b/packages/utilities/src/helpers/formatUptime.ts similarity index 99% rename from packages/manager/src/utilities/formatUptime.ts rename to packages/utilities/src/helpers/formatUptime.ts index 064b91a93e8..44c99b207db 100644 --- a/packages/manager/src/utilities/formatUptime.ts +++ b/packages/utilities/src/helpers/formatUptime.ts @@ -1,4 +1,5 @@ import { Duration } from 'luxon'; + export const formatUptime = (uptime: number) => { /** * We get uptime from the Longview API in diff --git a/packages/utilities/src/helpers/index.ts b/packages/utilities/src/helpers/index.ts index adaf31c4581..976db2d3ac6 100644 --- a/packages/utilities/src/helpers/index.ts +++ b/packages/utilities/src/helpers/index.ts @@ -5,20 +5,30 @@ export * from './arePropsEqual'; export * from './arrayToList'; export * from './breakpoints'; export * from './capitalize'; +export * from './createDevicesFromStrings'; +export * from './createStringsFromDevices'; export * from './deepStringTransform'; +export * from './doesRegionSupportFeature'; export * from './downloadFile'; export * from './env'; export * from './escapeRegExp'; export * from './evenizeNumber'; +export * from './formatDuration'; export * from './formatStorageUnits'; +export * from './formatUptime'; export * from './getAll'; export * from './getIsLegacyInterfaceArray'; export * from './getNewRegionLabel'; export * from './groupByTags'; export * from './getDisplayName'; +export * from './initWindows'; +export * from './isNilOrEmpty'; export * from './isNumber'; +export * from './isToday'; export * from './link'; export * from './manuallySetVPCConfigInterfacesToActive'; +export * from './mapIdsToDevices'; +export * from './maybeCastToNumber'; export * from './metadata'; export * from './minute-conversion'; export * from './mockLocalStorage'; diff --git a/packages/manager/src/utilities/initWindows.test.ts b/packages/utilities/src/helpers/initWindows.test.ts similarity index 93% rename from packages/manager/src/utilities/initWindows.test.ts rename to packages/utilities/src/helpers/initWindows.test.ts index 880d53b6ad2..2ae6107f286 100644 --- a/packages/manager/src/utilities/initWindows.test.ts +++ b/packages/utilities/src/helpers/initWindows.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest'; + import { initWindows } from './initWindows'; const timezone1 = 'America/New_York'; diff --git a/packages/manager/src/utilities/initWindows.ts b/packages/utilities/src/helpers/initWindows.ts similarity index 86% rename from packages/manager/src/utilities/initWindows.ts rename to packages/utilities/src/helpers/initWindows.ts index 27780f36fe9..26ef3ec0597 100644 --- a/packages/manager/src/utilities/initWindows.ts +++ b/packages/utilities/src/helpers/initWindows.ts @@ -1,6 +1,5 @@ import { evenizeNumber } from '@linode/utilities'; import { DateTime } from 'luxon'; -import { sortBy } from 'ramda'; export const initWindows = (timezone: string, unshift?: boolean) => { let windows = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22].map((hour) => { @@ -14,7 +13,7 @@ export const initWindows = (timezone: string, unshift?: boolean) => { ]; }); - windows = sortBy((window) => window[0], windows); + windows = windows.sort((a, b) => a[0].localeCompare(b[0])); if (unshift) { windows.unshift(['Choose a time', 'Scheduling']); diff --git a/packages/utilities/src/helpers/isNilOrEmpty.test.ts b/packages/utilities/src/helpers/isNilOrEmpty.test.ts new file mode 100644 index 00000000000..33487b36c01 --- /dev/null +++ b/packages/utilities/src/helpers/isNilOrEmpty.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { isNilOrEmpty } from './isNilOrEmpty'; + +describe('isNilOrEmpty function', () => { + it('should return true if variable is null or undefined or empty object', () => { + const x = null; + const y = undefined; + const obj = {}; + const arr: number[] = []; + const set = new Set(); + const map = new Map(); + + expect(isNilOrEmpty(x)).toBe(true); + expect(isNilOrEmpty(y)).toBe(true); + expect(isNilOrEmpty(obj)).toBe(true); + expect(isNilOrEmpty(arr)).toBe(true); + expect(isNilOrEmpty(set)).toBe(true); + expect(isNilOrEmpty(map)).toBe(true); + }); + + it('should return false if variable is of not empty', () => { + const str = 'test'; + const num = 15; + const obj = { key: 'value' }; + + expect(isNilOrEmpty(str)).toBe(false); + expect(isNilOrEmpty(num)).toBe(false); + expect(isNilOrEmpty(obj)).toBe(false); + }); + + it('should return false if an array, set or map is of not empty', () => { + const arr: number[] = [1, 2, 3]; + const set = new Set([1, 2, 3]); + const map = new Map([['key', 'value']]); + + expect(isNilOrEmpty(arr)).toBe(false); + expect(isNilOrEmpty(set)).toBe(false); + expect(isNilOrEmpty(map)).toBe(false); + }); +}); diff --git a/packages/utilities/src/helpers/isNilOrEmpty.ts b/packages/utilities/src/helpers/isNilOrEmpty.ts new file mode 100644 index 00000000000..c8535cf0a0f --- /dev/null +++ b/packages/utilities/src/helpers/isNilOrEmpty.ts @@ -0,0 +1,8 @@ +export const isNilOrEmpty = (v: null | number | object | string | undefined) => + v === null || + v === undefined || + v === '' || + (typeof v === 'object' && + (v instanceof Set || v instanceof Map + ? v.size === 0 + : Object.keys(v || {}).length === 0)); diff --git a/packages/manager/src/utilities/isToday.test.ts b/packages/utilities/src/helpers/isToday.test.ts similarity index 95% rename from packages/manager/src/utilities/isToday.test.ts rename to packages/utilities/src/helpers/isToday.test.ts index 3fc32c76bdc..004b052e546 100644 --- a/packages/manager/src/utilities/isToday.test.ts +++ b/packages/utilities/src/helpers/isToday.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest'; + import { DateTime } from 'luxon'; import { isToday } from './isToday'; diff --git a/packages/manager/src/utilities/isToday.ts b/packages/utilities/src/helpers/isToday.ts similarity index 100% rename from packages/manager/src/utilities/isToday.ts rename to packages/utilities/src/helpers/isToday.ts diff --git a/packages/manager/src/utilities/mapIdsToDevices.test.ts b/packages/utilities/src/helpers/mapIdsToDevices.test.ts similarity index 86% rename from packages/manager/src/utilities/mapIdsToDevices.test.ts rename to packages/utilities/src/helpers/mapIdsToDevices.test.ts index af30a9537d1..b379a125582 100644 --- a/packages/manager/src/utilities/mapIdsToDevices.test.ts +++ b/packages/utilities/src/helpers/mapIdsToDevices.test.ts @@ -1,10 +1,9 @@ -import { nodeBalancerFactory } from 'src/factories'; -import { linodeFactory } from 'src/factories'; +import { linodeFactory, nodeBalancerFactory } from '../factories'; +import { describe, it, expect } from 'vitest'; import { mapIdsToDevices } from './mapIdsToDevices'; -import type { NodeBalancer } from '@linode/api-v4'; -import type { Linode } from '@linode/api-v4'; +import type { NodeBalancer, Linode } from '@linode/api-v4'; describe('mapIdsToDevices', () => { const linodes = linodeFactory.buildList(5); diff --git a/packages/manager/src/utilities/mapIdsToDevices.ts b/packages/utilities/src/helpers/mapIdsToDevices.ts similarity index 91% rename from packages/manager/src/utilities/mapIdsToDevices.ts rename to packages/utilities/src/helpers/mapIdsToDevices.ts index f8d6e856d92..065b86ff360 100644 --- a/packages/manager/src/utilities/mapIdsToDevices.ts +++ b/packages/utilities/src/helpers/mapIdsToDevices.ts @@ -1,4 +1,4 @@ -import { isNotNullOrUndefined } from '@linode/utilities'; +import { isNotNullOrUndefined } from '../helpers'; import type { Linode, NodeBalancer } from '@linode/api-v4'; diff --git a/packages/manager/src/utilities/maybeCastToNumber.ts b/packages/utilities/src/helpers/maybeCastToNumber.ts similarity index 100% rename from packages/manager/src/utilities/maybeCastToNumber.ts rename to packages/utilities/src/helpers/maybeCastToNumber.ts diff --git a/packages/utilities/src/helpers/sort-by.test.ts b/packages/utilities/src/helpers/sort-by.test.ts index aaa1017ff38..6109eb26bc6 100644 --- a/packages/utilities/src/helpers/sort-by.test.ts +++ b/packages/utilities/src/helpers/sort-by.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { sortByVersion } from '@linode/utilities'; +import { sortByVersion } from './sort-by'; describe('sortByVersion', () => { it('should identify the later major version as greater', () => { diff --git a/packages/utilities/src/helpers/stringUtils.test.ts b/packages/utilities/src/helpers/stringUtils.test.ts index 7456ac93783..35f6f4e8a27 100644 --- a/packages/utilities/src/helpers/stringUtils.test.ts +++ b/packages/utilities/src/helpers/stringUtils.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - getNextLabel, - getNumberAtEnd, - isNumeric, - removeNumberAtEnd, - truncateAndJoinList, -} from './stringUtils'; +import { isNumeric, truncateAndJoinList } from './stringUtils'; describe('truncateAndJoinList', () => { const strList = ['a', 'b', 'c']; @@ -57,44 +51,3 @@ describe('isNumeric', () => { expect(isNumeric('my-linode')).toBe(false); }); }); - -describe('getNumberAtEnd', () => { - it('should return 1 when given test-1', () => { - expect(getNumberAtEnd('test-1')).toBe(1); - }); - it('should return null if there is no number in the string', () => { - expect(getNumberAtEnd('test')).toBe(null); - }); - it('should get the last number in the string', () => { - expect(getNumberAtEnd('test-1-2-3')).toBe(3); - }); - it('should handle a string that only contains numbers', () => { - expect(getNumberAtEnd('123')).toBe(123); - }); -}); - -describe('removeNumberAtEnd', () => { - it('should return 1 in "test-1"', () => { - expect(removeNumberAtEnd('test-1')).toBe('test-'); - }); - it('should return the same string if there is no number at the end', () => { - expect(removeNumberAtEnd('test')).toBe('test'); - }); - it('should return an empty string if the input is just a number', () => { - expect(removeNumberAtEnd('123')).toBe(''); - }); - it('should not remove the first number', () => { - expect(removeNumberAtEnd('1-2-3')).toBe('1-2-'); - }); -}); - -describe('getNextLabel', () => { - it('should append a number to get the next label', () => { - expect(getNextLabel({ label: 'test' }, [{ label: 'test' }])).toBe('test-1'); - }); - it('should not duplicate labels so that the returned label is unique', () => { - expect(getNextLabel({ label: 'test' }, [{ label: 'test-1' }])).toBe( - 'test-2' - ); - }); -}); diff --git a/packages/utilities/src/helpers/stringUtils.ts b/packages/utilities/src/helpers/stringUtils.ts index e6a886355e3..c183b3af87d 100644 --- a/packages/utilities/src/helpers/stringUtils.ts +++ b/packages/utilities/src/helpers/stringUtils.ts @@ -28,47 +28,3 @@ export const truncateAndJoinList = ( export const wrapInQuotes = (s: string) => '"' + s + '"'; export const isNumeric = (s: string) => /^\d+$/.test(s); - -export function getNumberAtEnd(str: string) { - // Use a regular expression to match one or more digits at the end of the string - const match = str.match(/\d+$/); - - // If there is a match, return the matched number; otherwise, return null - return match ? parseInt(match[0], 10) : null; -} - -export function removeNumberAtEnd(str: string) { - // Use a regular expression to match one or more digits at the end of the string - const regex = /\d+$/; - - // Use the replace() method to remove the matched portion - return str.replace(regex, ''); -} - -/** - * Gets the next available unique entity label - */ -export function getNextLabel( - selectedEntity: T, - allEntities: T[] -): string { - const numberAtEnd = getNumberAtEnd(selectedEntity.label); - - let labelToReturn = ''; - - if (numberAtEnd === null) { - labelToReturn = `${selectedEntity.label}-1`; - } else { - labelToReturn = `${removeNumberAtEnd(selectedEntity.label)}${ - numberAtEnd + 1 - }`; - } - - if (allEntities.some((r) => r.label === labelToReturn)) { - return getNextLabel( - { ...selectedEntity, label: labelToReturn }, - allEntities - ); - } - return labelToReturn; -} diff --git a/packages/utilities/src/hooks/index.ts b/packages/utilities/src/hooks/index.ts index 59524c35a61..fa20cc914bf 100644 --- a/packages/utilities/src/hooks/index.ts +++ b/packages/utilities/src/hooks/index.ts @@ -2,6 +2,7 @@ export * from './useDebouncedValue'; export * from './useDialog'; export * from './useEditableLabelState'; export * from './useErrors'; +export * from './useFormattedDate'; export * from './useFormValidateOnChange'; export * from './useInterval'; export * from './useOpenClose'; diff --git a/packages/utilities/src/hooks/useFormattedDate.test.ts b/packages/utilities/src/hooks/useFormattedDate.test.ts new file mode 100644 index 00000000000..05779761922 --- /dev/null +++ b/packages/utilities/src/hooks/useFormattedDate.test.ts @@ -0,0 +1,13 @@ +import { renderHook } from '@testing-library/react'; +import { useFormattedDate } from './useFormattedDate'; +import { DateTime } from 'luxon'; +import { describe, expect, it } from 'vitest'; + +describe('useFormattedDate', () => { + it('returns the correctly formatted date', () => { + const { result } = renderHook(() => useFormattedDate()); + + const expectedDate = DateTime.local().toFormat('yyyy-MM-dd'); + expect(result.current).toBe(expectedDate); + }); +}); diff --git a/packages/manager/src/hooks/useFormattedDate.ts b/packages/utilities/src/hooks/useFormattedDate.ts similarity index 100% rename from packages/manager/src/hooks/useFormattedDate.ts rename to packages/utilities/src/hooks/useFormattedDate.ts diff --git a/packages/utilities/src/index.ts b/packages/utilities/src/index.ts index 2565a4b33af..21f4f2b8f5c 100644 --- a/packages/utilities/src/index.ts +++ b/packages/utilities/src/index.ts @@ -1,3 +1,5 @@ +export * from './__data__'; + export * from './constants'; export * from './factories'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/types.ts b/packages/utilities/src/types/LinodeCreateType.ts similarity index 100% rename from packages/manager/src/features/Linodes/LinodeCreate/types.ts rename to packages/utilities/src/types/LinodeCreateType.ts diff --git a/packages/utilities/src/types/index.ts b/packages/utilities/src/types/index.ts index 2565f7a7d2c..86d9b2bfdad 100644 --- a/packages/utilities/src/types/index.ts +++ b/packages/utilities/src/types/index.ts @@ -1 +1,2 @@ +export * from './LinodeCreateType'; export * from './ManagerPreferences'; diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index b53439a6cc3..71280c0fb6c 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-04-08] - v0.63.0 + + +### Upcoming Features: + +- Update `ipv6` vpc schema validation for subnets, separate `createSubnetSchema` into `createSubnetSchemaIPv4` and `createSubnetSchemaWithIPv6` ([#11896](https://github.com/linode/manager/pull/11896)) + ## [2025-03-25] - v0.62.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index 16ce9dc9020..bf51a614846 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.62.0", + "version": "0.63.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts index 60ad4ed0a2b..278f9603d4e 100644 --- a/packages/validation/src/databases.schema.ts +++ b/packages/validation/src/databases.schema.ts @@ -1,4 +1,4 @@ -import { number } from 'yup'; +import { boolean, mixed, number } from 'yup'; import { array, object, string } from 'yup'; const LABEL_MESSAGE = 'Label must be between 3 and 32 characters'; @@ -33,3 +33,120 @@ export const updateDatabaseSchema = object({ .nullable(), type: string().notRequired(), }); + +/** + * Creates a base Yup validator based on the field type. + */ +const createValidator = (key: string, field: any) => { + const fieldTypes = Array.isArray(field.type) ? field.type : [field.type]; + + switch (true) { + case fieldTypes.includes('integer'): + return number().integer(`${key} must be a whole number`); + + case fieldTypes.includes('number'): + return number(); + + case fieldTypes.includes('string'): + return string(); + + case fieldTypes.includes('boolean'): + return boolean(); + + default: + return null; + } +}; + +/** + * Applies validation constraints (min, max, length, pattern) to a Yup validator. + */ +const applyConstraints = (validator: any, key: string, field: any) => { + if (!validator) return null; + + if (field.minimum !== undefined) { + validator = validator.min( + field.minimum, + `${key} must be at least ${field.minimum}` + ); + } + if (field.maximum !== undefined) { + validator = validator.max( + field.maximum, + `${key} must be at most ${field.maximum}` + ); + } + if (field.minLength !== undefined) { + validator = validator.min( + field.minLength, + `${key} must be at least ${field.minLength} characters` + ); + } + if (field.maxLength !== undefined) { + validator = validator.max( + field.maxLength, + `${key} must be at most ${field.maxLength} characters` + ); + } + if (field.pattern) { + let pattern = field.pattern; + if (key === 'default_time_zone') { + pattern = '^(SYSTEM|[+-](0[0-9]|1[0-2]):([0-5][0-9]))$'; + } + validator = validator.matches( + new RegExp(pattern), + `Please ensure that ${key} follows the format ${field.example}` + ); + } + + return validator; +}; + +/** + * Processes a single field from the API configuration and adds it to the schema. + */ +const processField = (schemaShape: Record, field: any) => { + if (!field.label || !field.type) { + return; + } + + const key = field.label; + let validator = createValidator(key, field); + validator = applyConstraints(validator, key, field); + + if (validator) { + schemaShape[key] = object().shape({ value: validator }); + } +}; + +/** + * Main function that creates a Yup validation schema dynamically based on API configurations. + */ +export const createDynamicAdvancedConfigSchema = (allConfigurations: any[]) => { + if (!Array.isArray(allConfigurations) || allConfigurations.length === 0) { + return object().shape({}); + } + + const schemaShape: Record = {}; + + allConfigurations.forEach((field) => processField(schemaShape, field)); + return object().shape({ + configs: array().of( + object({ + label: string().required(), + value: mixed().when('label', (label, schema) => { + if (Array.isArray(label)) { + label = label[0]; + } + + if (typeof label !== 'string' || !schemaShape[label]) { + return schema; + } + + const valueSchema = schemaShape[label]?.fields?.value; + return valueSchema ? valueSchema : schema; + }), + }) + ), + }); +}; diff --git a/packages/validation/src/firewalls.schema.ts b/packages/validation/src/firewalls.schema.ts index dbda3097726..6c149f675bb 100644 --- a/packages/validation/src/firewalls.schema.ts +++ b/packages/validation/src/firewalls.schema.ts @@ -212,9 +212,9 @@ export const FirewallDeviceSchema = object({ export const UpdateFirewallSettingsSchema = object({ default_firewall_ids: object({ - interface_public: number(), - interface_vpc: number(), - linode: number(), - nodebalancer: number(), + interface_public: number().nullable(), + interface_vpc: number().nullable(), + linode: number().nullable(), + nodebalancer: number().nullable(), }), }); diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 63a975ea90a..8d918571637 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -535,7 +535,7 @@ const BaseInterfaceIPv4AddressSchema = object({ }); const VPCInterfaceIPv4RangeSchema = object({ - range: string().required(), + range: string().required('Range is required.'), }); const PublicInterfaceRangeSchema = object({ @@ -543,7 +543,7 @@ const PublicInterfaceRangeSchema = object({ }); const CreateVPCInterfaceIpv4AddressSchema = object({ - address: string().required(), + address: string().required('VPC IPv4 is required.'), primary: boolean(), nat_1_1_address: string().nullable(), }); diff --git a/packages/validation/src/vpcs.schema.ts b/packages/validation/src/vpcs.schema.ts index 076eaa8ca81..13263afc0f4 100644 --- a/packages/validation/src/vpcs.schema.ts +++ b/packages/validation/src/vpcs.schema.ts @@ -13,7 +13,7 @@ const labelTestDetails = { const IP_EITHER_BOTH_NOT_NEITHER = 'A subnet must have either IPv4 or IPv6, or both, but not neither.'; -// @TODO VPC - remove below constant when IPv6 is added +// @TODO VPC IPv6 - remove below constant when IPv6 is in GA const TEMPORARY_IPV4_REQUIRED_MESSAGE = 'A subnet must have an IPv4 range.'; export const determineIPType = (ip: string) => { @@ -38,16 +38,19 @@ export const determineIPType = (ip: string) => { * @param { value } - the IP address string to be validated * @param { shouldHaveIPMask } - a boolean indicating whether the value should have a mask (e.g., /32) or not * @param { mustBeIPMask } - a boolean indicating whether the value MUST be an IP mask/prefix length or not + * @param { isIPv6Subnet } - a boolean indicating whether the IPv6 value is for a subnet */ export const vpcsValidateIP = ({ value, shouldHaveIPMask, mustBeIPMask, + isIPv6Subnet, }: { value: string | undefined | null; shouldHaveIPMask: boolean; mustBeIPMask: boolean; + isIPv6Subnet?: boolean; }): boolean => { if (!value) { return false; @@ -95,9 +98,17 @@ export const vpcsValidateIP = ({ if (isIPv6) { // VPCs must be assigned an IPv6 prefix of /52, /48, or /44 - if (!['52', '48', '44'].includes(mask)) { + const invalidVPCIPv6Prefix = !['52', '48', '44'].includes(mask); + if (!isIPv6Subnet && invalidVPCIPv6Prefix) { return false; } + + // VPC subnets must be assigned an IPv6 prefix of 52-62 + const invalidVPCIPv6SubnetPrefix = +mask < 52 || +mask > 62; + if (isIPv6Subnet && invalidVPCIPv6SubnetPrefix) { + return false; + } + if (shouldHaveIPMask) { ipaddr.IPv6.parseCIDR(value); } else { @@ -127,16 +138,93 @@ export const updateVPCSchema = object({ description: string(), }); -export const createSubnetSchema = object().shape( +const VPCIPv6Schema = object({ + range: string() + .optional() + .test({ + name: 'IPv6 prefix length', + message: 'Must be the prefix length 52, 48, or 44 of the IP, e.g. /52', + test: (value) => { + if (value && value.length > 0) { + vpcsValidateIP({ + value, + shouldHaveIPMask: true, + mustBeIPMask: false, + }); + } + }, + }), +}); + +const VPCIPv6SubnetSchema = object({ + range: string() + .required() + .test({ + name: 'IPv6 prefix length', + message: 'Must be the prefix length (52-62) of the IP, e.g. /52', + test: (value) => { + if (value && value !== 'auto' && value.length > 0) { + vpcsValidateIP({ + value, + shouldHaveIPMask: true, + mustBeIPMask: false, + isIPv6Subnet: true, + }); + } + }, + }), +}); + +// @TODO VPC IPv6: Delete this when IPv6 is in GA +export const createSubnetSchemaIPv4 = object({ + label: labelValidation.required(LABEL_REQUIRED), + ipv4: string().when('ipv6', { + is: (value: unknown) => + value === '' || value === null || value === undefined, + then: (schema) => + schema.required(TEMPORARY_IPV4_REQUIRED_MESSAGE).test({ + name: 'IPv4 CIDR format', + message: 'The IPv4 range must be in CIDR format.', + test: (value) => + vpcsValidateIP({ + value, + shouldHaveIPMask: true, + mustBeIPMask: false, + }), + }), + otherwise: (schema) => + lazy((value: string | undefined) => { + switch (typeof value) { + case 'undefined': + return schema.notRequired().nullable(); + + case 'string': + return schema.notRequired().test({ + name: 'IPv4 CIDR format', + message: 'The IPv4 range must be in CIDR format.', + test: (value) => + vpcsValidateIP({ + value, + shouldHaveIPMask: true, + mustBeIPMask: false, + }), + }); + + default: + return schema.notRequired().nullable(); + } + }), + }), +}); + +export const createSubnetSchemaWithIPv6 = object().shape( { label: labelValidation.required(LABEL_REQUIRED), ipv4: string().when('ipv6', { is: (value: unknown) => value === '' || value === null || value === undefined, then: (schema) => - // @TODO VPC - change required message back to IP_EITHER_BOTH_NOT_NEITHER when IPv6 is supported - // Since only IPv4 is currently supported, subnets must have an IPv4 - schema.required(TEMPORARY_IPV4_REQUIRED_MESSAGE).test({ + schema.required(IP_EITHER_BOTH_NOT_NEITHER).test({ name: 'IPv4 CIDR format', message: 'The IPv4 range must be in CIDR format.', test: (value) => @@ -169,44 +257,13 @@ export const createSubnetSchema = object().shape( } }), }), - ipv6: string().when('ipv4', { - is: (value: unknown) => - value === '' || value === null || value === undefined, - then: (schema) => - schema.required(IP_EITHER_BOTH_NOT_NEITHER).test({ - name: 'IPv6 prefix length', - message: 'Must be the prefix length (64-125) of the IP, e.g. /64', - test: (value) => - vpcsValidateIP({ - value, - shouldHaveIPMask: true, - mustBeIPMask: true, - }), - }), - otherwise: (schema) => - lazy((value: string | undefined) => { - switch (typeof value) { - case 'undefined': - return schema.notRequired().nullable(); - - case 'string': - return schema.notRequired().test({ - name: 'IPv6 prefix length', - message: - 'Must be the prefix length (64-125) of the IP, e.g. /64', - test: (value) => - vpcsValidateIP({ - value, - shouldHaveIPMask: true, - mustBeIPMask: true, - }), - }); - - default: - return schema.notRequired().nullable(); - } - }), - }), + ipv6: array() + .of(VPCIPv6SubnetSchema) + .when('ipv4', { + is: (value: unknown) => + value === '' || value === null || value === undefined, + then: (schema) => schema.required(IP_EITHER_BOTH_NOT_NEITHER), + }), }, [ ['ipv6', 'ipv4'], @@ -214,30 +271,17 @@ export const createSubnetSchema = object().shape( ] ); -const createVPCIPv6Schema = object({ - range: string() - .optional() - .test({ - name: 'IPv6 prefix length', - message: 'Must be the prefix length 52, 48, or 44 of the IP, e.g. /52', - test: (value) => { - if (value && value !== 'auto' && value.length > 0) { - vpcsValidateIP({ - value, - shouldHaveIPMask: true, - mustBeIPMask: false, - }); - } - }, - }), - allocation_class: string().optional(), -}); +const createVPCIPv6Schema = VPCIPv6Schema.concat( + object({ + allocation_class: string().optional(), + }) +); export const createVPCSchema = object({ label: labelValidation.required(LABEL_REQUIRED), description: string(), region: string().required('Region is required'), - subnets: array().of(createSubnetSchema), + subnets: array().of(createSubnetSchemaIPv4), ipv6: array().of(createVPCIPv6Schema).max(1).optional(), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 751e2fe21aa..1e7968dc60c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: '@linode/search': specifier: workspace:* version: link:../search + '@linode/shared': + specifier: workspace:* + version: link:../shared '@linode/ui': specifier: workspace:* version: link:../ui @@ -137,8 +140,8 @@ importers: specifier: ^7.8.3 version: 7.8.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@reach/tabs': - specifier: ^0.10.5 - version: 0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^0.18.0 + version: 0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/react': specifier: ^7.119.1 version: 7.120.0(react@18.3.1) @@ -199,9 +202,6 @@ importers: ipaddr.js: specifier: ^1.9.1 version: 1.9.1 - js-sha256: - specifier: ^0.11.0 - version: 0.11.0 jspdf: specifier: ^3.0.1 version: 3.0.1 @@ -309,50 +309,50 @@ importers: specifier: ^2.3.0 version: 2.3.0(cypress@14.0.1) '@linode/eslint-plugin-cloud-manager': - specifier: ^0.0.7 - version: 0.0.7(eslint@7.32.0) + specifier: ^0.0.10 + version: 0.0.10(eslint@7.32.0) '@storybook/addon-a11y': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/addon-actions': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/addon-controls': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/addon-docs': specifier: ^8.6.7 - version: 8.6.7(@types/react@18.3.12)(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(@types/react@18.3.12)(storybook@8.6.9(prettier@2.2.1)) '@storybook/addon-mdx-gfm': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/addon-measure': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/addon-storysource': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/addon-viewport': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/blocks': specifier: ^8.6.7 - version: 8.6.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1)) '@storybook/manager-api': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/preview-api': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/react': specifier: ^8.6.7 - version: 8.6.7(@storybook/test@8.6.7(storybook@8.6.7(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1))(typescript@5.7.3) + version: 8.6.9(@storybook/test@8.6.9(storybook@8.6.9(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1))(typescript@5.7.3) '@storybook/react-vite': specifier: ^8.6.7 - version: 8.6.7(@storybook/test@8.6.7(storybook@8.6.7(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.34.8)(storybook@8.6.7(prettier@2.2.1))(typescript@5.7.3)(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 8.6.9(@storybook/test@8.6.9(storybook@8.6.9(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.38.0)(storybook@8.6.9(prettier@2.2.1))(typescript@5.7.3)(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/theming': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@swc/core': specifier: ^1.10.9 version: 1.10.11 @@ -454,7 +454,7 @@ importers: version: 6.21.0(eslint@7.32.0)(typescript@5.7.3) '@vitejs/plugin-react-swc': specifier: ^3.7.2 - version: 3.7.2(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.7.2(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^3.0.7 version: 3.0.7(vitest@3.0.7) @@ -479,12 +479,21 @@ importers: cypress-file-upload: specifier: ^5.0.8 version: 5.0.8(cypress@14.0.1) + cypress-mochawesome-reporter: + specifier: ^3.8.2 + version: 3.8.2(cypress@14.0.1)(mocha@10.8.2) + cypress-multi-reporters: + specifier: ^2.0.5 + version: 2.0.5(mocha@10.8.2) + cypress-on-fix: + specifier: ^1.1.0 + version: 1.1.0 cypress-real-events: specifier: ^1.14.0 version: 1.14.0(cypress@14.0.1) cypress-vite: specifier: ^1.6.0 - version: 1.6.0(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 1.6.0(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) dotenv: specifier: ^16.0.3 version: 16.4.5 @@ -559,16 +568,16 @@ importers: version: 1.5.5(redux@4.2.1) storybook: specifier: ^8.6.7 - version: 8.6.7(prettier@2.2.1) + version: 8.6.9(prettier@2.2.1) storybook-dark-mode: specifier: 4.0.1 - version: 4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1)) + version: 4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1)) vite: - specifier: ^6.2.2 - version: 6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + specifier: ^6.2.4 + version: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) vite-plugin-svgr: specifier: ^3.2.0 - version: 3.3.0(rollup@4.34.8)(typescript@5.7.3)(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.3.0(rollup@4.38.0)(typescript@5.7.3)(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/queries: dependencies: @@ -592,8 +601,8 @@ importers: version: 18.3.1(react@18.3.1) devDependencies: '@linode/eslint-plugin-cloud-manager': - specifier: ^0.0.7 - version: 0.0.7(eslint@7.32.0) + specifier: ^0.0.10 + version: 0.0.10(eslint@7.32.0) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -673,8 +682,120 @@ importers: specifier: ^4.0.3 version: 4.2.0 vite: - specifier: ^5 || ^6 - version: 6.1.1(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + specifier: ^6.2.4 + version: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + + packages/shared: + dependencies: + '@linode/api-v4': + specifier: workspace:* + version: link:../api-v4 + '@linode/queries': + specifier: workspace:* + version: link:../queries + '@linode/ui': + specifier: workspace:* + version: link:../ui + '@linode/utilities': + specifier: workspace:* + version: link:../utilities + '@mui/material': + specifier: ^6.4.5 + version: 6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: 5.51.24 + version: 5.51.24(react@18.3.1) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@linode/eslint-plugin-cloud-manager': + specifier: ^0.0.10 + version: 0.0.10(eslint@7.32.0) + '@storybook/addon-actions': + specifier: ^8.6.7 + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) + '@storybook/react': + specifier: ^8.6.7 + version: 8.6.9(@storybook/test@8.6.9(storybook@8.6.9(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1))(typescript@5.7.3) + '@testing-library/dom': + specifier: ^10.1.0 + version: 10.4.0 + '@testing-library/jest-dom': + specifier: ~6.4.2 + version: 6.4.8 + '@testing-library/react': + specifier: ~16.0.0 + version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.4.0) + '@types/react': + specifier: ^18.2.55 + version: 18.3.12 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.3.1 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@7.32.0)(typescript@5.7.3) + eslint: + specifier: ^7.1.0 + version: 7.32.0 + eslint-config-prettier: + specifier: ~8.1.0 + version: 8.1.0(eslint@7.32.0) + eslint-plugin-cypress: + specifier: ^2.11.3 + version: 2.15.2(eslint@7.32.0) + eslint-plugin-jsx-a11y: + specifier: ^6.7.1 + version: 6.10.2(eslint@7.32.0) + eslint-plugin-perfectionist: + specifier: ^1.4.0 + version: 1.5.1(eslint@7.32.0)(typescript@5.7.3) + eslint-plugin-prettier: + specifier: ~3.3.1 + version: 3.3.1(eslint-config-prettier@8.1.0(eslint@7.32.0))(eslint@7.32.0)(prettier@2.2.1) + eslint-plugin-ramda: + specifier: ^2.5.1 + version: 2.5.1 + eslint-plugin-react: + specifier: ^7.19.0 + version: 7.37.2(eslint@7.32.0) + eslint-plugin-react-hooks: + specifier: ^3.0.0 + version: 3.0.0(eslint@7.32.0) + eslint-plugin-react-refresh: + specifier: ^0.4.13 + version: 0.4.13(eslint@7.32.0) + eslint-plugin-scanjs-rules: + specifier: ^0.2.1 + version: 0.2.1 + eslint-plugin-sonarjs: + specifier: ^0.5.0 + version: 0.5.0(eslint@7.32.0) + eslint-plugin-testing-library: + specifier: ^3.1.2 + version: 3.10.2(eslint@7.32.0)(typescript@5.7.3) + eslint-plugin-xss: + specifier: ^0.1.10 + version: 0.1.12 + lint-staged: + specifier: ^15.2.9 + version: 15.4.3 + prettier: + specifier: ~2.2.1 + version: 2.2.1 + vite-plugin-svgr: + specifier: ^3.2.0 + version: 3.3.0(rollup@4.38.0)(typescript@5.7.3)(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/ui: dependencies: @@ -713,17 +834,17 @@ importers: version: 4.9.13(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) devDependencies: '@linode/eslint-plugin-cloud-manager': - specifier: ^0.0.7 - version: 0.0.7(eslint@7.32.0) + specifier: ^0.0.10 + version: 0.0.10(eslint@7.32.0) '@storybook/addon-actions': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/preview-api': specifier: ^8.6.7 - version: 8.6.7(storybook@8.6.7(prettier@2.2.1)) + version: 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/react': specifier: ^8.6.7 - version: 8.6.7(@storybook/test@8.6.7(storybook@8.6.7(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1))(typescript@5.7.3) + version: 8.6.9(@storybook/test@8.6.9(storybook@8.6.9(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1))(typescript@5.7.3) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -768,16 +889,19 @@ importers: version: 2.2.1 vite-plugin-svgr: specifier: ^3.2.0 - version: 3.3.0(rollup@4.34.8)(typescript@5.7.3)(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.3.0(rollup@4.38.0)(typescript@5.7.3)(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: '@linode/api-v4': specifier: workspace:* version: link:../api-v4 - '@types/ramda': - specifier: 0.25.16 - version: 0.25.16 + luxon: + specifier: 3.4.4 + version: 3.4.4 + ramda: + specifier: ~0.25.0 + version: 0.25.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -786,8 +910,8 @@ importers: version: 18.3.1(react@18.3.1) devDependencies: '@linode/eslint-plugin-cloud-manager': - specifier: ^0.0.7 - version: 0.0.7(eslint@7.32.0) + specifier: ^0.0.10 + version: 0.0.10(eslint@7.32.0) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -797,6 +921,12 @@ importers: '@testing-library/react': specifier: ~16.0.0 version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/luxon': + specifier: 3.4.2 + version: 3.4.2 + '@types/ramda': + specifier: 0.25.16 + version: 0.25.16 '@types/react': specifier: ^18.2.55 version: 18.3.12 @@ -827,9 +957,6 @@ importers: prettier: specifier: ~2.2.1 version: 2.2.1 - ramda: - specifier: ~0.25.0 - version: 0.25.0 packages/validation: dependencies: @@ -993,34 +1120,29 @@ packages: resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.26.10': - resolution: {integrity: sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==} + '@babel/helpers@7.26.0': + resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} engines: {node: '>=6.9.0'} '@babel/highlight@7.25.9': resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.26.10': - resolution: {integrity: sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.26.2': resolution: {integrity: sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/runtime@7.26.10': - resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} + '@babel/runtime@7.26.0': + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} engines: {node: '>=6.9.0'} - '@babel/template@7.25.9': - resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} + '@babel/runtime@7.27.0': + resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} engines: {node: '>=6.9.0'} - '@babel/template@7.26.9': - resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==} + '@babel/template@7.25.9': + resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} '@babel/traverse@7.25.9': @@ -1031,10 +1153,6 @@ packages: resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} engines: {node: '>=6.9.0'} - '@babel/types@7.26.10': - resolution: {integrity: sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==} - engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -1171,23 +1289,17 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.24.2': - resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.25.1': resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.24.2': - resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + '@esbuild/aix-ppc64@0.25.2': + resolution: {integrity: sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==} engines: {node: '>=18'} - cpu: [arm64] - os: [android] + cpu: [ppc64] + os: [aix] '@esbuild/android-arm64@0.25.1': resolution: {integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==} @@ -1195,10 +1307,10 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.24.2': - resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + '@esbuild/android-arm64@0.25.2': + resolution: {integrity: sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==} engines: {node: '>=18'} - cpu: [arm] + cpu: [arm64] os: [android] '@esbuild/android-arm@0.25.1': @@ -1207,10 +1319,10 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.24.2': - resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + '@esbuild/android-arm@0.25.2': + resolution: {integrity: sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==} engines: {node: '>=18'} - cpu: [x64] + cpu: [arm] os: [android] '@esbuild/android-x64@0.25.1': @@ -1219,11 +1331,11 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.24.2': - resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + '@esbuild/android-x64@0.25.2': + resolution: {integrity: sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==} engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] + cpu: [x64] + os: [android] '@esbuild/darwin-arm64@0.25.1': resolution: {integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==} @@ -1231,10 +1343,10 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.24.2': - resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + '@esbuild/darwin-arm64@0.25.2': + resolution: {integrity: sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==} engines: {node: '>=18'} - cpu: [x64] + cpu: [arm64] os: [darwin] '@esbuild/darwin-x64@0.25.1': @@ -1243,11 +1355,11 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.24.2': - resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + '@esbuild/darwin-x64@0.25.2': + resolution: {integrity: sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==} engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] + cpu: [x64] + os: [darwin] '@esbuild/freebsd-arm64@0.25.1': resolution: {integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==} @@ -1255,10 +1367,10 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.24.2': - resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + '@esbuild/freebsd-arm64@0.25.2': + resolution: {integrity: sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==} engines: {node: '>=18'} - cpu: [x64] + cpu: [arm64] os: [freebsd] '@esbuild/freebsd-x64@0.25.1': @@ -1267,11 +1379,11 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.24.2': - resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + '@esbuild/freebsd-x64@0.25.2': + resolution: {integrity: sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==} engines: {node: '>=18'} - cpu: [arm64] - os: [linux] + cpu: [x64] + os: [freebsd] '@esbuild/linux-arm64@0.25.1': resolution: {integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==} @@ -1279,10 +1391,10 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.24.2': - resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + '@esbuild/linux-arm64@0.25.2': + resolution: {integrity: sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==} engines: {node: '>=18'} - cpu: [arm] + cpu: [arm64] os: [linux] '@esbuild/linux-arm@0.25.1': @@ -1291,10 +1403,10 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.24.2': - resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + '@esbuild/linux-arm@0.25.2': + resolution: {integrity: sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==} engines: {node: '>=18'} - cpu: [ia32] + cpu: [arm] os: [linux] '@esbuild/linux-ia32@0.25.1': @@ -1303,10 +1415,10 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.24.2': - resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + '@esbuild/linux-ia32@0.25.2': + resolution: {integrity: sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==} engines: {node: '>=18'} - cpu: [loong64] + cpu: [ia32] os: [linux] '@esbuild/linux-loong64@0.25.1': @@ -1315,10 +1427,10 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.24.2': - resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + '@esbuild/linux-loong64@0.25.2': + resolution: {integrity: sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==} engines: {node: '>=18'} - cpu: [mips64el] + cpu: [loong64] os: [linux] '@esbuild/linux-mips64el@0.25.1': @@ -1327,10 +1439,10 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.24.2': - resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + '@esbuild/linux-mips64el@0.25.2': + resolution: {integrity: sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==} engines: {node: '>=18'} - cpu: [ppc64] + cpu: [mips64el] os: [linux] '@esbuild/linux-ppc64@0.25.1': @@ -1339,10 +1451,10 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.24.2': - resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + '@esbuild/linux-ppc64@0.25.2': + resolution: {integrity: sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==} engines: {node: '>=18'} - cpu: [riscv64] + cpu: [ppc64] os: [linux] '@esbuild/linux-riscv64@0.25.1': @@ -1351,10 +1463,10 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.24.2': - resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + '@esbuild/linux-riscv64@0.25.2': + resolution: {integrity: sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==} engines: {node: '>=18'} - cpu: [s390x] + cpu: [riscv64] os: [linux] '@esbuild/linux-s390x@0.25.1': @@ -1363,10 +1475,10 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.24.2': - resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + '@esbuild/linux-s390x@0.25.2': + resolution: {integrity: sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==} engines: {node: '>=18'} - cpu: [x64] + cpu: [s390x] os: [linux] '@esbuild/linux-x64@0.25.1': @@ -1375,11 +1487,11 @@ packages: cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.24.2': - resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + '@esbuild/linux-x64@0.25.2': + resolution: {integrity: sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==} engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] + cpu: [x64] + os: [linux] '@esbuild/netbsd-arm64@0.25.1': resolution: {integrity: sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==} @@ -1387,10 +1499,10 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.24.2': - resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + '@esbuild/netbsd-arm64@0.25.2': + resolution: {integrity: sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==} engines: {node: '>=18'} - cpu: [x64] + cpu: [arm64] os: [netbsd] '@esbuild/netbsd-x64@0.25.1': @@ -1399,11 +1511,11 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.24.2': - resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + '@esbuild/netbsd-x64@0.25.2': + resolution: {integrity: sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==} engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] + cpu: [x64] + os: [netbsd] '@esbuild/openbsd-arm64@0.25.1': resolution: {integrity: sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==} @@ -1411,10 +1523,10 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.24.2': - resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + '@esbuild/openbsd-arm64@0.25.2': + resolution: {integrity: sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==} engines: {node: '>=18'} - cpu: [x64] + cpu: [arm64] os: [openbsd] '@esbuild/openbsd-x64@0.25.1': @@ -1423,11 +1535,11 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.24.2': - resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + '@esbuild/openbsd-x64@0.25.2': + resolution: {integrity: sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==} engines: {node: '>=18'} cpu: [x64] - os: [sunos] + os: [openbsd] '@esbuild/sunos-x64@0.25.1': resolution: {integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==} @@ -1435,11 +1547,11 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.24.2': - resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + '@esbuild/sunos-x64@0.25.2': + resolution: {integrity: sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==} engines: {node: '>=18'} - cpu: [arm64] - os: [win32] + cpu: [x64] + os: [sunos] '@esbuild/win32-arm64@0.25.1': resolution: {integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==} @@ -1447,10 +1559,10 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.24.2': - resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + '@esbuild/win32-arm64@0.25.2': + resolution: {integrity: sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==} engines: {node: '>=18'} - cpu: [ia32] + cpu: [arm64] os: [win32] '@esbuild/win32-ia32@0.25.1': @@ -1459,10 +1571,10 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.24.2': - resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + '@esbuild/win32-ia32@0.25.2': + resolution: {integrity: sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==} engines: {node: '>=18'} - cpu: [x64] + cpu: [ia32] os: [win32] '@esbuild/win32-x64@0.25.1': @@ -1471,6 +1583,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.25.2': + resolution: {integrity: sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.4.1': resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1646,8 +1764,8 @@ packages: '@linode/design-language-system@4.0.0': resolution: {integrity: sha512-SKM4AG0GpFjgirKI+7bG3RT6ai3VU7MJJLUvaZsHf0OgmEJ25qWH7DqGOx5FWSTtzX0YemJSrwnKMpL+3CLawg==} - '@linode/eslint-plugin-cloud-manager@0.0.7': - resolution: {integrity: sha512-83ZDbDQGsXCKxagX6CWszFZbsuX/fHSFn/i+P1FGYDm/0qnIo2XypB/lTdJhHJwvq1j2z+0VDZIMP7YLF5U6Sg==} + '@linode/eslint-plugin-cloud-manager@0.0.10': + resolution: {integrity: sha512-zaZZ8QHd89e3lSr6ZwKhqwKEzOASiJJksPdbyti68W2M3iC76CBuG6akAmC1/e/Z6KUNq1mHKNAxTUCBgkOmCg==} peerDependencies: eslint: ^6.8.0 @@ -1855,29 +1973,34 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@reach/auto-id@0.10.5': - resolution: {integrity: sha512-we4/bwjFxJ3F+2eaddQ1HltbKvJ7AB8clkN719El7Zugpn/vOjfPMOVUiBqTmPGLUvkYrq4tpuFwLvk2HyOVHg==} + '@reach/auto-id@0.18.0': + resolution: {integrity: sha512-XwY1IwhM7mkHZFghhjiqjQ6dstbOdpbFLdggeke75u8/8icT8uEHLbovFUgzKjy9qPvYwZIB87rLiR8WdtOXCg==} + peerDependencies: + react: ^16.8.0 || 17.x + react-dom: ^16.8.0 || 17.x + + '@reach/descendants@0.18.0': + resolution: {integrity: sha512-GXUxnM6CfrX5URdnipPIl3Tlc6geuz4xb4n61y4tVWXQX1278Ra9Jz9DMRN8x4wheHAysvrYwnR/SzAlxQzwtA==} peerDependencies: - react: ^16.8.0 - react-dom: ^16.8.0 + react: ^16.8.0 || 17.x + react-dom: ^16.8.0 || 17.x - '@reach/descendants@0.10.5': - resolution: {integrity: sha512-8HhN4DwS/HsPQ+Ym/Ft/XJ1spXBYdE8hqpnbYR9UcU7Nx3oDbTIdhjA6JXXt23t5avYIx2jRa8YHCtVKSHuiwA==} + '@reach/polymorphic@0.18.0': + resolution: {integrity: sha512-N9iAjdMbE//6rryZZxAPLRorzDcGBnluf7YQij6XDLiMtfCj1noa7KyLpEc/5XCIB/EwhX3zCluFAwloBKdblA==} peerDependencies: - react: ^16.8.0 - react-dom: ^16.8.0 + react: ^16.8.0 || 17.x - '@reach/tabs@0.10.5': - resolution: {integrity: sha512-oQJxQ9FwFsXo2HxEzJxFU/wP31bPVh4VU54NlhHW9f49uofyYkIKBbAhdF0Zb3TnaFp4cGKPHX39pXBYGPDkAQ==} + '@reach/tabs@0.18.0': + resolution: {integrity: sha512-gTRJzStWJJtgMhn9FDEmKogAJMcqNaGZx0i1SGoTdVM+D29DBhVeRdO8qEg+I2l2k32DkmuZxG/Mrh+GZTjczQ==} peerDependencies: - react: ^16.8.0 - react-dom: ^16.8.0 + react: ^16.8.0 || 17.x + react-dom: ^16.8.0 || 17.x - '@reach/utils@0.10.5': - resolution: {integrity: sha512-5E/xxQnUbmpI/LrufBAOXjunl96DnqX6B4zC2MO2KH/dRzLug5gM5VuOwV26egsp0jvsSPxojwciOhS43px3qw==} + '@reach/utils@0.18.0': + resolution: {integrity: sha512-KdVMdpTgDyK8FzdKO9SCpiibuy/kbv3pwgfXshTI6tEcQT1OOwj7BAksnzGC0rPz0UholwC+AgkqEl3EJX3M1A==} peerDependencies: - react: ^16.8.0 - react-dom: ^16.8.0 + react: ^16.8.0 || 17.x + react-dom: ^16.8.0 || 17.x '@rollup/pluginutils@5.1.3': resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} @@ -1893,96 +2016,196 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.38.0': + resolution: {integrity: sha512-ldomqc4/jDZu/xpYU+aRxo3V4mGCV9HeTgUBANI3oIQMOL+SsxB+S2lxMpkFp5UamSS3XuTMQVbsS24R4J4Qjg==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.34.8': resolution: {integrity: sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.38.0': + resolution: {integrity: sha512-VUsgcy4GhhT7rokwzYQP+aV9XnSLkkhlEJ0St8pbasuWO/vwphhZQxYEKUP3ayeCYLhk6gEtacRpYP/cj3GjyQ==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.34.8': resolution: {integrity: sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.38.0': + resolution: {integrity: sha512-buA17AYXlW9Rn091sWMq1xGUvWQFOH4N1rqUxGJtEQzhChxWjldGCCup7r/wUnaI6Au8sKXpoh0xg58a7cgcpg==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.34.8': resolution: {integrity: sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.38.0': + resolution: {integrity: sha512-Mgcmc78AjunP1SKXl624vVBOF2bzwNWFPMP4fpOu05vS0amnLcX8gHIge7q/lDAHy3T2HeR0TqrriZDQS2Woeg==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.34.8': resolution: {integrity: sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.38.0': + resolution: {integrity: sha512-zzJACgjLbQTsscxWqvrEQAEh28hqhebpRz5q/uUd1T7VTwUNZ4VIXQt5hE7ncs0GrF+s7d3S4on4TiXUY8KoQA==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.34.8': resolution: {integrity: sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.38.0': + resolution: {integrity: sha512-hCY/KAeYMCyDpEE4pTETam0XZS4/5GXzlLgpi5f0IaPExw9kuB+PDTOTLuPtM10TlRG0U9OSmXJ+Wq9J39LvAg==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.34.8': resolution: {integrity: sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.38.0': + resolution: {integrity: sha512-mimPH43mHl4JdOTD7bUMFhBdrg6f9HzMTOEnzRmXbOZqjijCw8LA5z8uL6LCjxSa67H2xiLFvvO67PT05PRKGg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.34.8': resolution: {integrity: sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.38.0': + resolution: {integrity: sha512-tPiJtiOoNuIH8XGG8sWoMMkAMm98PUwlriOFCCbZGc9WCax+GLeVRhmaxjJtz6WxrPKACgrwoZ5ia/uapq3ZVg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.34.8': resolution: {integrity: sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.38.0': + resolution: {integrity: sha512-wZco59rIVuB0tjQS0CSHTTUcEde+pXQWugZVxWaQFdQQ1VYub/sTrNdY76D1MKdN2NB48JDuGABP6o6fqos8mA==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.34.8': resolution: {integrity: sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.38.0': + resolution: {integrity: sha512-fQgqwKmW0REM4LomQ+87PP8w8xvU9LZfeLBKybeli+0yHT7VKILINzFEuggvnV9M3x1Ed4gUBmGUzCo/ikmFbQ==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-loongarch64-gnu@4.34.8': resolution: {integrity: sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==} cpu: [loong64] os: [linux] + '@rollup/rollup-linux-loongarch64-gnu@4.38.0': + resolution: {integrity: sha512-hz5oqQLXTB3SbXpfkKHKXLdIp02/w3M+ajp8p4yWOWwQRtHWiEOCKtc9U+YXahrwdk+3qHdFMDWR5k+4dIlddg==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.34.8': resolution: {integrity: sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.38.0': + resolution: {integrity: sha512-NXqygK/dTSibQ+0pzxsL3r4Xl8oPqVoWbZV9niqOnIHV/J92fe65pOir0xjkUZDRSPyFRvu+4YOpJF9BZHQImw==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.34.8': resolution: {integrity: sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.38.0': + resolution: {integrity: sha512-GEAIabR1uFyvf/jW/5jfu8gjM06/4kZ1W+j1nWTSSB3w6moZEBm7iBtzwQ3a1Pxos2F7Gz+58aVEnZHU295QTg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.38.0': + resolution: {integrity: sha512-9EYTX+Gus2EGPbfs+fh7l95wVADtSQyYw4DfSBcYdUEAmP2lqSZY0Y17yX/3m5VKGGJ4UmIH5LHLkMJft3bYoA==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.34.8': resolution: {integrity: sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.38.0': + resolution: {integrity: sha512-Mpp6+Z5VhB9VDk7RwZXoG2qMdERm3Jw07RNlXHE0bOnEeX+l7Fy4bg+NxfyN15ruuY3/7Vrbpm75J9QHFqj5+Q==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.34.8': resolution: {integrity: sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.38.0': + resolution: {integrity: sha512-vPvNgFlZRAgO7rwncMeE0+8c4Hmc+qixnp00/Uv3ht2x7KYrJ6ERVd3/R0nUtlE6/hu7/HiiNHJ/rP6knRFt1w==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.34.8': resolution: {integrity: sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.38.0': + resolution: {integrity: sha512-q5Zv+goWvQUGCaL7fU8NuTw8aydIL/C9abAVGCzRReuj5h30TPx4LumBtAidrVOtXnlB+RZkBtExMsfqkMfb8g==} + cpu: [x64] + os: [linux] + '@rollup/rollup-win32-arm64-msvc@4.34.8': resolution: {integrity: sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.38.0': + resolution: {integrity: sha512-u/Jbm1BU89Vftqyqbmxdq14nBaQjQX1HhmsdBWqSdGClNaKwhjsg5TpW+5Ibs1mb8Es9wJiMdl86BcmtUVXNZg==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.34.8': resolution: {integrity: sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.38.0': + resolution: {integrity: sha512-mqu4PzTrlpNHHbu5qleGvXJoGgHpChBlrBx/mEhTPpnAL1ZAYFlvHD7rLK839LLKQzqEQMFJfGrrOHItN4ZQqA==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.34.8': resolution: {integrity: sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.38.0': + resolution: {integrity: sha512-jjqy3uWlecfB98Psxb5cD6Fny9Fupv9LrDSPTQZUROqjvZmcCqNu4UMl7qqhlUUGpwiAkotj6GYu4SZdcr/nLw==} + cpu: [x64] + os: [win32] + '@sentry-internal/feedback@7.120.0': resolution: {integrity: sha512-+nU2PXMAyrYyK64PlfxXyRZ+LIl6IWAcdnBeX916WqOJy2WWmtdOrAX8muVwLVIXHzp1EMG1nEZgtpL/Vr2XKQ==} engines: {node: '>=12'} @@ -2046,67 +2269,67 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@storybook/addon-a11y@8.6.7': - resolution: {integrity: sha512-/pGRa27AVpoFG0J2+PTKSQCk6ytbRkcR+5fi75iLlqgp7YZN9rVJ8SYyEXALf/B8Gw9hSk2uxCyT3dA7ZTy52Q==} + '@storybook/addon-a11y@8.6.9': + resolution: {integrity: sha512-X5s5RFLORwFjDXcEJitFKar0MMIUgp9JUfcT9VhQfJjnvZf7urf+9M2UGD9TwWAta5EAUBpGDkt9cqDi2UvTxA==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/addon-actions@8.6.7': - resolution: {integrity: sha512-XgZCwIcZGThEyD7e2q7rN/jzg7ZHUxn/ln403eex04jWAGBBbtC2IVuowwCWV8HwDihnhpCZEP6HlgjakOYZbQ==} + '@storybook/addon-actions@8.6.9': + resolution: {integrity: sha512-H2v17sMbSl8jhSulPxcOyChsFbzik9E7mgCWIf4P114KcIUokWLVuALnSOeqHME6lY0pPBZs3DgvVVMVMm7zNw==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/addon-controls@8.6.7': - resolution: {integrity: sha512-6ReB1Sc1qlqvAM7NUmtw2K1cKCgGBs8zYRgL44Q2ti+r55a2ownhm6WUm/kZs2ixSkV9ehm1osiqbGBfAn0Isw==} + '@storybook/addon-controls@8.6.9': + resolution: {integrity: sha512-YXBYsbHqdYhmrbGI+wv9LAr/LlKnPt9f9GL+9rw82lnYadWObYxzUxs+PPLNO5tc14fd2g+FMVHOfovaRdFvrQ==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/addon-docs@8.6.7': - resolution: {integrity: sha512-kgNPEVuLGNJE8EdVQi5Tg2DYgR66/gut07jvhqnJfNqUkj6UpBHad0JR1uwrd7xS3kJs29Fs7UyU87RJnSlwcg==} + '@storybook/addon-docs@8.6.9': + resolution: {integrity: sha512-yAP59G5Vd+E6O9KLfBR5ALdOFA5yEZ0n1f8Ne9jwF+NGu1U8KNIfWnZmBYaBGe+bpYn0CWV5AfdFvw83bzHYpw==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/addon-highlight@8.6.7': - resolution: {integrity: sha512-4KE1RF4XfqII7XrJPgf/1W0t0EWRKmik5Rrpb6WofXfgZ2QYzLFnyESjf67/g2TMgDnle2drfa/pt5tGV4+I2Q==} + '@storybook/addon-highlight@8.6.9': + resolution: {integrity: sha512-I0gBHgaH74wX6yf5S7zUmdfr25hwPONpSAqPPGBSNYu0Jj9Je+ANr1y4T1I3cOaEvf73QntDhCgHC6/iqY90Fw==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/addon-mdx-gfm@8.6.7': - resolution: {integrity: sha512-IfGgPnOMq51yBpnaY2w5hlm4pBgIMig61vsigqySU7KKFY6qxD/LcIJAxOPh2s9dLhYGYDrO0hFGR9fQ7Niu5A==} + '@storybook/addon-mdx-gfm@8.6.9': + resolution: {integrity: sha512-NG8wDB27WM3f24r5A69G1lcA58jisnPQIdT91tNEj089tlRoN0m0eXEmv5X4Gd13M0JgiuEhLU8ywAgpmeEHuQ==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/addon-measure@8.6.7': - resolution: {integrity: sha512-4dkkCltjKRcJH+ZMv5nbNT0LBQfcXIydVfN9mAvhDsiPFD5eZcHbN4XVfUslECWgrkaa/a6FE1W9PNEUBjCJaA==} + '@storybook/addon-measure@8.6.9': + resolution: {integrity: sha512-2GrHtaYZgM7qeil5/XfNJrdnan7hoLLUyU7w7fph0EVl7tiwmhtp4He0PX9hrT/Abk2HxeCP4WU2fAGwIuTkYg==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/addon-storysource@8.6.7': - resolution: {integrity: sha512-tIoTQp3MMyF3S4XarMOBVO40DofILO3Mz8upT4wGEfQULLjgCkS2K5c4BbT4de1hF49JsqvPByVlavntWQFTdg==} + '@storybook/addon-storysource@8.6.9': + resolution: {integrity: sha512-BPEUhEuo6JijM71ZNAPziXQur1HzN12iekYJnpfsJZF/cUwZWjTF2HeXBn0Z2DjOjqtBzoSPaJgT7CLVAKucsQ==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/addon-viewport@8.6.7': - resolution: {integrity: sha512-kTrt6ByCbBIbqoRqQO9watDl5nSIKCC+R0/EmpEl6ZtzBV3l8trZHdvCHhIqOyv7nfaa7pIeTTG1GD6Gdrxk3w==} + '@storybook/addon-viewport@8.6.9': + resolution: {integrity: sha512-1xkozyB1zs3eSNTc8ePAMcajUfbKvNMTjs5LYdts2N1Ss0xeZ+K/gphfRg0GaYsNvRYi5piufag/niHCGkT3hA==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/blocks@8.6.7': - resolution: {integrity: sha512-IFhIKO7R1UPpnoG/5tZH0FgC79oYgXNf+7aGUwq29M/CQWy6p/Pvp0y4P962btY1UZRol+SsU//33nH8o6yNRw==} + '@storybook/blocks@8.6.9': + resolution: {integrity: sha512-+vSRkHLD7ho3Wd1WVA1KrYAnv7BnGHOhHWHAgTR5IdeMdgzQxm6+HHeqGB5sncilA0AjVC6udBIgHbCSuD61dA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^8.6.7 + storybook: ^8.6.9 peerDependenciesMeta: react: optional: true react-dom: optional: true - '@storybook/builder-vite@8.6.7': - resolution: {integrity: sha512-hgYnVu2cy8clrmDwidu4XjvFMTEi9WiblLH5cPI3LWQjVajIQmDpcWVp6kbD063sIOphh9zYP7cVKGO7ktMB/g==} + '@storybook/builder-vite@8.6.9': + resolution: {integrity: sha512-8U11A7sLPvvcnJQ3pXyoX1LdJDpa4+JOYcASL9A+DL591jkfYKxhim7R4BOHO55aetmqQAoA/LEAD5runu7zoQ==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 vite: ^4.0.0 || ^5.0.0 || ^6.0.0 '@storybook/components@8.4.5': @@ -2114,8 +2337,8 @@ packages: peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@storybook/components@8.6.7': - resolution: {integrity: sha512-8pnjH1w7PZ/Iiuve1/BJY7EO/kmu0qdE34X1ZM8DyHzuy33EL/PfUuhxNkrL4ayMXrEDp/EJMHx2bqO1RdRV6A==} + '@storybook/components@8.6.9': + resolution: {integrity: sha512-CqWUAYK/RgV++sXfiDG63DM2JF2FeidvnMO5/bki2hFbEqgs0/yy7BKUjhsGmuri5y+r9B2FJhW0WnE6PI8NWw==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 @@ -2124,18 +2347,18 @@ packages: peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@storybook/core@8.6.7': - resolution: {integrity: sha512-FcvLFA+Qn3+D6LgQkk0MOXA5FBz8DGc0UZmZuVbIwIUV4MV4ywCMwtKdG0cyhtzQg0YNyfiIYWJr7lZ4jLLhYg==} + '@storybook/core@8.6.9': + resolution: {integrity: sha512-psYxJAlj34ZaDAk+OvT/He6ZuUh0eGiHVtZNe0xWbNp5pQvOBjf+dg48swdI6KEbVs3aeU+Wnyra/ViU2RtA+Q==} peerDependencies: prettier: ^2 || ^3 peerDependenciesMeta: prettier: optional: true - '@storybook/csf-plugin@8.6.7': - resolution: {integrity: sha512-HK7yQD4kFu04JOKnUwoFeR58r5WY6ucF0D8zfW4Gx+r8hBJ5K4t3z6k2dlIlRQF1X5+2vNkQOwD8liHjckuZ8Q==} + '@storybook/csf-plugin@8.6.9': + resolution: {integrity: sha512-IQnhyaVUkcRR9e4xiHN83xMQtTMH+lJp472iMifUIqxx/Yw137BTef2DEEp6EnRct4yKrch24+Nl65LWg0mRpQ==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -2147,49 +2370,49 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@storybook/instrumenter@8.6.7': - resolution: {integrity: sha512-FeQiV0g5crCWs0P1wKY4xZzb4PxAYNcrm2+9LLGVqwnC7qzrSCPf0p10MlveVfwsen1m6Wbqfe+wl21c31Hfmg==} + '@storybook/instrumenter@8.6.9': + resolution: {integrity: sha512-Gp6OSiu9KA/p1HWd7VW9TtpWX32ZBfqRVrOm4wW1AM6B4XACbQWFE/aQ25HwU834yfdJkr2BW+uUH8DBAQ6kTw==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/manager-api@8.6.7': - resolution: {integrity: sha512-BA8RxaLP07WGF660LWo7qB3Jomr/+MPuCZmuKPqXxPhfIovqYjr0hnugxJBjEah0ic31aNX4NucNfDRuV7F5sA==} + '@storybook/manager-api@8.6.9': + resolution: {integrity: sha512-mxq9B9rxAraOCBapGKsUDfI+8yNtFhTgKMZCxmHoUCxvAHaIt4S9JcdX0qQQKUsBTr/b2hHm0O7A8DYrbgBRfw==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@storybook/preview-api@8.6.7': - resolution: {integrity: sha512-Rz83Nx43v3Dn9/SjhIsorkcx1gPmlclueuzf6YywJTqE1E/L4dgoe2mOA9MfF0jr0bh3TwEA2J3ii0Jstg1Orw==} + '@storybook/preview-api@8.6.9': + resolution: {integrity: sha512-hW3Z8NBrGs2bNunaHgrLjpfrOcWsxH0ejAqaba8MolPXjzNs0lTFF/Ela7pUsh2m1R4/kiD+WfddQzyipUo4Mg==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@storybook/react-dom-shim@8.6.7': - resolution: {integrity: sha512-+JH7gbRI6NRbt9o0l1rY4wFdeVt8wGRddm0b55OBlwBGlFo2nvGVOH73J4AGphXVhfY7z33I3TXIjXQ561UdEQ==} + '@storybook/react-dom-shim@8.6.9': + resolution: {integrity: sha512-SjqP6r5yy87OJRAiq1JzFazn6VWfptOA2HaxOiP8zRhJgG41K0Vseh8tbZdycj1AzJYSCcnKaIcfd/GEo/41+g==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/react-vite@8.6.7': - resolution: {integrity: sha512-KiTeYaZ+AUQ1AFHSItP8dhUbd2v7Qy8+BB7w64VxQMw/dw5n0Z38lo4Tzdlkn22q2smW2ce4QwAzh2pfTz3b8g==} + '@storybook/react-vite@8.6.9': + resolution: {integrity: sha512-V81hRb2zv+LsKJnyjXQMYzL7ojdp92C3ThQ3r+SFGxKxY9t1JdoxloRmeyrN6XHyIAYhiJRwni0E1RagtPWG1g==} engines: {node: '>=18.0.0'} peerDependencies: - '@storybook/test': 8.6.7 + '@storybook/test': 8.6.9 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.7 + storybook: ^8.6.9 vite: ^4.0.0 || ^5.0.0 || ^6.0.0 peerDependenciesMeta: '@storybook/test': optional: true - '@storybook/react@8.6.7': - resolution: {integrity: sha512-6R8znSm7kzsoAJyRbEiDWE+5xjeAIzwEcfT60fqx+uMdd0vDFM7f2uT4fYy+CijWas1oFWcNV/LMd3EqSkBGsQ==} + '@storybook/react@8.6.9': + resolution: {integrity: sha512-xu4eJyYNz3mHeqnHn80KZZ2s22ZfqqCTzCNCVAyM6MWTxUwIpLX6FXC/vmcT1gPwwTl2KcRHZXaE7snB3aOLuw==} engines: {node: '>=18.0.0'} peerDependencies: - '@storybook/test': 8.6.7 + '@storybook/test': 8.6.9 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.7 + storybook: ^8.6.9 typescript: '>= 4.2.x' peerDependenciesMeta: '@storybook/test': @@ -2197,18 +2420,18 @@ packages: typescript: optional: true - '@storybook/source-loader@8.6.7': - resolution: {integrity: sha512-ycfrPHCs5OUrJTLCXDxvxLVB1zjL7IEepPs53o4RGRWO8xV1z0QfXXiX1drk48rep6dDu+a3mRWfNJ8m0RV/GA==} + '@storybook/source-loader@8.6.9': + resolution: {integrity: sha512-Ogh3HjJoAiKD7svqqyA+bM8xOZm2kuMs0jkYsrfuZG6BlO9xVuTSviGRWHowUEEznQZOBz8a++SmTaqNEoen6g==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/test@8.6.7': - resolution: {integrity: sha512-uF1JbBtdT7tuiXfEtHsUShBHIhm2vc0C39nKVJaTWyK9CybajXaj2Ny3IRa3oY9NKnklwGgN+kZ/Z9YiIOc4MQ==} + '@storybook/test@8.6.9': + resolution: {integrity: sha512-lIJA6jup3ZZNkKFyUiy1q2tHWZv5q5bTaLxTnI85XIWr+sFCZG5oo3pOQESBkX4V95rv8sq9gEmEWySZvW7MBw==} peerDependencies: - storybook: ^8.6.7 + storybook: ^8.6.9 - '@storybook/theming@8.6.7': - resolution: {integrity: sha512-F/i4XS5bew9dvtNiHvDJF0mko1IUbPM9PUjTYPaw6cK8ytS0kdec703MsJ/GUA7seeEWBeGdZjV3ua0pys650A==} + '@storybook/theming@8.6.9': + resolution: {integrity: sha512-FQafe66itGnIh0V42R65tgFKyz0RshpIs0pTrxrdByuB2yKsep+f8ZgKLJE3fCKw/Egw4bUuICo2m8d7uOOumA==} peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 @@ -2507,6 +2730,9 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/estree@1.0.7': + resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/googlepay@0.7.6': resolution: {integrity: sha512-5003wG+qvf4Ktf1hC9IJuRakNzQov00+Xf09pAWGJLpdOjUrq0SSLCpXX7pwSeTG9r5hrdzq1iFyZcW7WVyr4g==} @@ -2650,9 +2876,6 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} - '@types/warning@3.0.3': - resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==} - '@types/xml2js@0.4.14': resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} @@ -3148,6 +3371,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -3276,6 +3503,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -3318,6 +3548,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -3349,8 +3583,8 @@ packages: engines: {node: '>=18'} hasBin: true - consola@3.4.1: - resolution: {integrity: sha512-zaUUWockhqxFf4bSXS+kTJwxWvAyMuKtShx0BWcGrMEUqbETcBCT91iQs9pECNx7yz8VH4VeWW/1KAbhE8kiww==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} consolidated-events@2.0.2: @@ -3445,6 +3679,22 @@ packages: peerDependencies: cypress: '>3.0.0' + cypress-mochawesome-reporter@3.8.2: + resolution: {integrity: sha512-oJZkNzhNmN9ZD+LmZyFuPb8aWaIijyHyqYh52YOBvR6B6ckfJNCHP3A98a+/nG0H4t46CKTNwo+wNpMa4d2kjA==} + engines: {node: '>=14'} + hasBin: true + peerDependencies: + cypress: '>=6.2.0' + + cypress-multi-reporters@2.0.5: + resolution: {integrity: sha512-5ReXlNE7C/9/rpDI3z0tAJbPXsTHK7P3ogvUtBntQlmctRQ+sSMts7dIQY5MTb0XfBSge3CuwvNvaoqtw90KSQ==} + engines: {node: '>=6.0.0'} + peerDependencies: + mocha: '>=3.1.2' + + cypress-on-fix@1.1.0: + resolution: {integrity: sha512-qGdbC0vZLmR3lYPpWWZvMqgDTeA2v04zu3DBdBmJHbG+BjwlFNYGnL7Y+X4LBrB+AyCCCeCuXhV80UXA90UhWg==} + cypress-real-events@1.14.0: resolution: {integrity: sha512-XmI8y3OZLh6cjRroPalzzS++iv+pGCaD9G9kfIbtspgv7GVsDt30dkZvSXfgZb4rAN+3pOkMVB7e0j4oXydW7Q==} peerDependencies: @@ -3527,6 +3777,9 @@ packages: resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} engines: {node: '>= 0.4'} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -3556,6 +3809,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decamelize@4.0.0: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} @@ -3742,13 +3999,13 @@ packages: peerDependencies: esbuild: '>=0.12 <1' - esbuild@0.24.2: - resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + esbuild@0.25.1: + resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==} engines: {node: '>=18'} hasBin: true - esbuild@0.25.1: - resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==} + esbuild@0.25.2: + resolution: {integrity: sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==} engines: {node: '>=18'} hasBin: true @@ -3756,6 +4013,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -4030,6 +4290,10 @@ packages: find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4096,6 +4360,14 @@ packages: framebus@6.0.0: resolution: {integrity: sha512-bL9V68hVaVBCY9rveoWbPFFI9hAXIJtESs51B+9XmzvMt38+wP8b4VdiJsavjMS6NfPZ/afQ/jc2qaHmSGI1kQ==} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} @@ -4108,6 +4380,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fsu@1.1.1: + resolution: {integrity: sha512-xQVsnjJ/5pQtcKh+KjUoZGzVWn4uNkchxTF6Lwjr4Gf7nQr8fmUfhKJ62zE77+xQg9xnxi5KUps7XGs+VC986A==} + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -4638,9 +4913,6 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-sha256@0.11.0: - resolution: {integrity: sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4704,6 +4976,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} @@ -4806,6 +5081,10 @@ packages: localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4820,9 +5099,21 @@ packages: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.isempty@4.4.0: + resolution: {integrity: sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==} + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isobject@3.0.2: + resolution: {integrity: sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -5116,6 +5407,20 @@ packages: engines: {node: '>= 14.0.0'} hasBin: true + mochawesome-merge@4.4.1: + resolution: {integrity: sha512-QCzsXrfH5ewf4coUGvrAOZSpRSl9Vg39eqL2SpKKGkUw390f18hx9C90BNWTA4f/teD2nA0Inb1yxYPpok2gvg==} + engines: {node: '>=10.0.0'} + hasBin: true + + mochawesome-report-generator@6.2.0: + resolution: {integrity: sha512-Ghw8JhQFizF0Vjbtp9B0i//+BOkV5OWcQCPpbO0NGOoxV33o+gKDYU0Pr2pGxkIHnqZ+g5mYiXF7GMNgAcDpSg==} + hasBin: true + + mochawesome@7.1.3: + resolution: {integrity: sha512-Vkb3jR5GZ1cXohMQQ73H3cZz7RoxGjjUo0G5hu0jLaW+0FdUxUwg3Cj29bqQdh0rFcnyV06pWmqmi5eBPnEuNQ==} + peerDependencies: + mocha: '>=7' + moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} @@ -5146,8 +5451,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.8: - resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -5247,6 +5552,10 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -5265,10 +5574,18 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -5277,6 +5594,10 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5723,6 +6044,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requireindex@1.1.0: resolution: {integrity: sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==} engines: {node: '>=0.10.5'} @@ -5789,6 +6113,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.38.0: + resolution: {integrity: sha512-5SsIRtJy9bf1ErAOiFMFzl64Ex9X5V7bnJ+WlFMb+zmP459OSWCEG7b0ERZ+PEU7xPt4OG3RHbrp1LJlXxYTrw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -5848,6 +6177,9 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5984,8 +6316,8 @@ packages: storybook-dark-mode@4.0.1: resolution: {integrity: sha512-9l3qY8NdgwZnY+NlO1XHB3eUb6FmZo9GazJeUSeFkjRqwA5FmnMSeq0YVqEOqfwniM/TvQwOiTYd5g/hC2wugA==} - storybook@8.6.7: - resolution: {integrity: sha512-9gktoFMQDSCINNGQH869d/sar9rVtAhr0HchcvDA6bssAqgQJvTphY4qC9lH54SxfTJm/7Sy+BKEngMK+dziJg==} + storybook@8.6.9: + resolution: {integrity: sha512-Iw4+R4V3yX7MhXJaLBAT4oLtZ+SaTzX8KvUNZiQzvdD+TrFKVA3QKV8gvWjstGyU2dd+afE1Ph6EG5Xa2Az2CA==} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -6123,6 +6455,12 @@ packages: resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==} engines: {node: '>=10.0.0'} + tcomb-validation@3.4.1: + resolution: {integrity: sha512-urVVMQOma4RXwiVCa2nM2eqrAomHROHvWPuj6UkDGz/eb5kcy0x6P0dVt6kzpUZtYMNoAqJLWmz1BPtxrtjtrA==} + + tcomb@3.2.29: + resolution: {integrity: sha512-di2Hd1DB2Zfw6StGv861JoAF5h/uQVu/QJp2g8KVbtfKnoHdBQl5M32YWq6mnSYBQ1vFFrns5B1haWJL7rKaOQ==} + terser@5.36.0: resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} engines: {node: '>=10'} @@ -6412,6 +6750,10 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -6462,6 +6804,10 @@ packages: v8-compile-cache@2.4.0: resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==} + validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + value-equal@1.0.1: resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} @@ -6488,48 +6834,8 @@ packages: peerDependencies: vite: ^2.6.0 || 3 || 4 - vite@6.1.1: - resolution: {integrity: sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.3.0 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vite@6.2.2: - resolution: {integrity: sha512-yW7PeMM+LkDzc7CgJuRLMW2Jz0FxMOsVJ8Lv3gpgW9WLcb9cTW+121UEr1hvmfR7w3SegR5ItvYyzVz1vxNJgQ==} + vite@6.2.4: + resolution: {integrity: sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -6600,9 +6906,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - warning@4.0.3: - resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} - webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -6648,6 +6951,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.15: resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} engines: {node: '>= 0.4'} @@ -6726,6 +7032,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6738,6 +7047,10 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -6750,6 +7063,10 @@ packages: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -6886,7 +7203,7 @@ snapshots: '@babel/generator': 7.26.2 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) - '@babel/helpers': 7.26.10 + '@babel/helpers': 7.26.0 '@babel/parser': 7.26.2 '@babel/template': 7.25.9 '@babel/traverse': 7.25.9 @@ -6937,10 +7254,10 @@ snapshots: '@babel/helper-validator-option@7.25.9': {} - '@babel/helpers@7.26.10': + '@babel/helpers@7.26.0': dependencies: - '@babel/template': 7.26.9 - '@babel/types': 7.26.10 + '@babel/template': 7.25.9 + '@babel/types': 7.26.0 '@babel/highlight@7.25.9': dependencies: @@ -6949,15 +7266,15 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/parser@7.26.10': - dependencies: - '@babel/types': 7.26.10 - '@babel/parser@7.26.2': dependencies: '@babel/types': 7.26.0 - '@babel/runtime@7.26.10': + '@babel/runtime@7.26.0': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/runtime@7.27.0': dependencies: regenerator-runtime: 0.14.1 @@ -6967,12 +7284,6 @@ snapshots: '@babel/parser': 7.26.2 '@babel/types': 7.26.0 - '@babel/template@7.26.9': - dependencies: - '@babel/code-frame': 7.26.2 - '@babel/parser': 7.26.10 - '@babel/types': 7.26.10 - '@babel/traverse@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -6990,11 +7301,6 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/types@7.26.10': - dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@bcoe/v8-coverage@1.0.2': {} '@braintree/asset-loader@2.0.0': {} @@ -7093,7 +7399,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.25.9 - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -7124,7 +7430,7 @@ snapshots: '@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.13.5 '@emotion/serialize': 1.3.3 @@ -7150,7 +7456,7 @@ snapshots: '@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@emotion/babel-plugin': 11.13.5 '@emotion/is-prop-valid': 1.3.1 '@emotion/react': 11.13.5(@types/react@18.3.12)(react@18.3.1) @@ -7173,156 +7479,156 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} - '@esbuild/aix-ppc64@0.24.2': - optional: true - '@esbuild/aix-ppc64@0.25.1': optional: true - '@esbuild/android-arm64@0.24.2': + '@esbuild/aix-ppc64@0.25.2': optional: true '@esbuild/android-arm64@0.25.1': optional: true - '@esbuild/android-arm@0.24.2': + '@esbuild/android-arm64@0.25.2': optional: true '@esbuild/android-arm@0.25.1': optional: true - '@esbuild/android-x64@0.24.2': + '@esbuild/android-arm@0.25.2': optional: true '@esbuild/android-x64@0.25.1': optional: true - '@esbuild/darwin-arm64@0.24.2': + '@esbuild/android-x64@0.25.2': optional: true '@esbuild/darwin-arm64@0.25.1': optional: true - '@esbuild/darwin-x64@0.24.2': + '@esbuild/darwin-arm64@0.25.2': optional: true '@esbuild/darwin-x64@0.25.1': optional: true - '@esbuild/freebsd-arm64@0.24.2': + '@esbuild/darwin-x64@0.25.2': optional: true '@esbuild/freebsd-arm64@0.25.1': optional: true - '@esbuild/freebsd-x64@0.24.2': + '@esbuild/freebsd-arm64@0.25.2': optional: true '@esbuild/freebsd-x64@0.25.1': optional: true - '@esbuild/linux-arm64@0.24.2': + '@esbuild/freebsd-x64@0.25.2': optional: true '@esbuild/linux-arm64@0.25.1': optional: true - '@esbuild/linux-arm@0.24.2': + '@esbuild/linux-arm64@0.25.2': optional: true '@esbuild/linux-arm@0.25.1': optional: true - '@esbuild/linux-ia32@0.24.2': + '@esbuild/linux-arm@0.25.2': optional: true '@esbuild/linux-ia32@0.25.1': optional: true - '@esbuild/linux-loong64@0.24.2': + '@esbuild/linux-ia32@0.25.2': optional: true '@esbuild/linux-loong64@0.25.1': optional: true - '@esbuild/linux-mips64el@0.24.2': + '@esbuild/linux-loong64@0.25.2': optional: true '@esbuild/linux-mips64el@0.25.1': optional: true - '@esbuild/linux-ppc64@0.24.2': + '@esbuild/linux-mips64el@0.25.2': optional: true '@esbuild/linux-ppc64@0.25.1': optional: true - '@esbuild/linux-riscv64@0.24.2': + '@esbuild/linux-ppc64@0.25.2': optional: true '@esbuild/linux-riscv64@0.25.1': optional: true - '@esbuild/linux-s390x@0.24.2': + '@esbuild/linux-riscv64@0.25.2': optional: true '@esbuild/linux-s390x@0.25.1': optional: true - '@esbuild/linux-x64@0.24.2': + '@esbuild/linux-s390x@0.25.2': optional: true '@esbuild/linux-x64@0.25.1': optional: true - '@esbuild/netbsd-arm64@0.24.2': + '@esbuild/linux-x64@0.25.2': optional: true '@esbuild/netbsd-arm64@0.25.1': optional: true - '@esbuild/netbsd-x64@0.24.2': + '@esbuild/netbsd-arm64@0.25.2': optional: true '@esbuild/netbsd-x64@0.25.1': optional: true - '@esbuild/openbsd-arm64@0.24.2': + '@esbuild/netbsd-x64@0.25.2': optional: true '@esbuild/openbsd-arm64@0.25.1': optional: true - '@esbuild/openbsd-x64@0.24.2': + '@esbuild/openbsd-arm64@0.25.2': optional: true '@esbuild/openbsd-x64@0.25.1': optional: true - '@esbuild/sunos-x64@0.24.2': + '@esbuild/openbsd-x64@0.25.2': optional: true '@esbuild/sunos-x64@0.25.1': optional: true - '@esbuild/win32-arm64@0.24.2': + '@esbuild/sunos-x64@0.25.2': optional: true '@esbuild/win32-arm64@0.25.1': optional: true - '@esbuild/win32-ia32@0.24.2': + '@esbuild/win32-arm64@0.25.2': optional: true '@esbuild/win32-ia32@0.25.1': optional: true - '@esbuild/win32-x64@0.24.2': + '@esbuild/win32-ia32@0.25.2': optional: true '@esbuild/win32-x64@0.25.1': optional: true + '@esbuild/win32-x64@0.25.2': + optional: true + '@eslint-community/eslint-utils@4.4.1(eslint@7.32.0)': dependencies: eslint: 7.32.0 @@ -7333,7 +7639,7 @@ snapshots: '@eslint/eslintrc@0.4.3': dependencies: ajv: 6.12.6 - debug: 4.3.7 + debug: 4.4.0(supports-color@8.1.1) espree: 7.3.1 globals: 13.24.0 ignore: 4.0.6 @@ -7355,7 +7661,7 @@ snapshots: '@humanwhocodes/config-array@0.5.0': dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.7 + debug: 4.4.0(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -7499,12 +7805,12 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.7.3)(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.7.3)(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: glob: 10.4.5 magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.7.3) - vite: 6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) optionalDependencies: typescript: 5.7.3 @@ -7539,7 +7845,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.3.7 + debug: 4.4.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7547,7 +7853,7 @@ snapshots: '@linode/design-language-system@4.0.0': {} - '@linode/eslint-plugin-cloud-manager@0.0.7(eslint@7.32.0)': + '@linode/eslint-plugin-cloud-manager@0.0.10(eslint@7.32.0)': dependencies: eslint: 7.32.0 @@ -7575,7 +7881,7 @@ snapshots: '@mui/icons-material@6.4.5(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@mui/material': 6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 optionalDependencies: @@ -7583,7 +7889,7 @@ snapshots: '@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@mui/core-downloads-tracker': 6.4.5 '@mui/system': 6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@mui/types': 7.2.21(@types/react@18.3.12) @@ -7604,7 +7910,7 @@ snapshots: '@mui/private-theming@6.4.3(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@mui/utils': 6.4.3(@types/react@18.3.12)(react@18.3.1) prop-types: 15.8.1 react: 18.3.1 @@ -7613,7 +7919,7 @@ snapshots: '@mui/styled-engine@6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@emotion/cache': 11.13.5 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -7626,7 +7932,7 @@ snapshots: '@mui/system@6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@mui/private-theming': 6.4.3(@types/react@18.3.12)(react@18.3.1) '@mui/styled-engine': 6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) '@mui/types': 7.2.21(@types/react@18.3.12) @@ -7646,7 +7952,7 @@ snapshots: '@mui/utils@6.4.3(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@mui/types': 7.2.21(@types/react@18.3.12) '@types/prop-types': 15.7.14 clsx: 2.1.1 @@ -7658,7 +7964,7 @@ snapshots: '@mui/x-date-pickers@7.27.0(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(dayjs@1.11.13)(luxon@3.4.4)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@mui/material': 6.4.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/system': 6.4.3(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@mui/utils': 6.4.3(@types/react@18.3.12)(react@18.3.1) @@ -7680,7 +7986,7 @@ snapshots: '@mui/x-internals@7.26.0(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@mui/utils': 6.4.3(@types/react@18.3.12)(react@18.3.1) react: 18.3.1 transitivePeerDependencies: @@ -7741,103 +8047,161 @@ snapshots: '@popperjs/core@2.11.8': {} - '@reach/auto-id@0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@reach/auto-id@0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@reach/utils': 0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@reach/utils': 0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 - '@reach/descendants@0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@reach/descendants@0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@reach/utils': 0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@reach/utils': 0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 - '@reach/tabs@0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@reach/polymorphic@0.18.0(react@18.3.1)': dependencies: - '@reach/auto-id': 0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@reach/descendants': 0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@reach/utils': 0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - prop-types: 15.8.1 + react: 18.3.1 + + '@reach/tabs@0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@reach/auto-id': 0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@reach/descendants': 0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@reach/polymorphic': 0.18.0(react@18.3.1) + '@reach/utils': 0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 - '@reach/utils@0.10.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@reach/utils@0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@types/warning': 3.0.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 - warning: 4.0.3 - '@rollup/pluginutils@5.1.3(rollup@4.34.8)': + '@rollup/pluginutils@5.1.3(rollup@4.38.0)': dependencies: '@types/estree': 1.0.6 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.34.8 + rollup: 4.38.0 '@rollup/rollup-android-arm-eabi@4.34.8': optional: true + '@rollup/rollup-android-arm-eabi@4.38.0': + optional: true + '@rollup/rollup-android-arm64@4.34.8': optional: true + '@rollup/rollup-android-arm64@4.38.0': + optional: true + '@rollup/rollup-darwin-arm64@4.34.8': optional: true + '@rollup/rollup-darwin-arm64@4.38.0': + optional: true + '@rollup/rollup-darwin-x64@4.34.8': optional: true + '@rollup/rollup-darwin-x64@4.38.0': + optional: true + '@rollup/rollup-freebsd-arm64@4.34.8': optional: true + '@rollup/rollup-freebsd-arm64@4.38.0': + optional: true + '@rollup/rollup-freebsd-x64@4.34.8': optional: true + '@rollup/rollup-freebsd-x64@4.38.0': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.34.8': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.38.0': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.34.8': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.38.0': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.34.8': optional: true + '@rollup/rollup-linux-arm64-gnu@4.38.0': + optional: true + '@rollup/rollup-linux-arm64-musl@4.34.8': optional: true + '@rollup/rollup-linux-arm64-musl@4.38.0': + optional: true + '@rollup/rollup-linux-loongarch64-gnu@4.34.8': optional: true + '@rollup/rollup-linux-loongarch64-gnu@4.38.0': + optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.34.8': optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.38.0': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.34.8': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.38.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.38.0': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.34.8': optional: true + '@rollup/rollup-linux-s390x-gnu@4.38.0': + optional: true + '@rollup/rollup-linux-x64-gnu@4.34.8': optional: true + '@rollup/rollup-linux-x64-gnu@4.38.0': + optional: true + '@rollup/rollup-linux-x64-musl@4.34.8': optional: true + '@rollup/rollup-linux-x64-musl@4.38.0': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.34.8': optional: true + '@rollup/rollup-win32-arm64-msvc@4.38.0': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.34.8': optional: true + '@rollup/rollup-win32-ia32-msvc@4.38.0': + optional: true + '@rollup/rollup-win32-x64-msvc@4.34.8': optional: true + '@rollup/rollup-win32-x64-msvc@4.38.0': + optional: true + '@sentry-internal/feedback@7.120.0': dependencies: '@sentry/core': 7.120.0 @@ -7935,110 +8299,110 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@storybook/addon-a11y@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-a11y@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: - '@storybook/addon-highlight': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/addon-highlight': 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/global': 5.0.0 - '@storybook/test': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/test': 8.6.9(storybook@8.6.9(prettier@2.2.1)) axe-core: 4.10.2 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/addon-actions@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-actions@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: '@storybook/global': 5.0.0 '@types/uuid': 9.0.8 dequal: 2.0.3 polished: 4.3.1 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) uuid: 9.0.1 - '@storybook/addon-controls@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-controls@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: '@storybook/global': 5.0.0 dequal: 2.0.3 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) ts-dedent: 2.2.0 - '@storybook/addon-docs@8.6.7(@types/react@18.3.12)(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-docs@8.6.9(@types/react@18.3.12)(storybook@8.6.9(prettier@2.2.1))': dependencies: '@mdx-js/react': 3.1.0(@types/react@18.3.12)(react@18.3.1) - '@storybook/blocks': 8.6.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1)) - '@storybook/csf-plugin': 8.6.7(storybook@8.6.7(prettier@2.2.1)) - '@storybook/react-dom-shim': 8.6.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1)) + '@storybook/blocks': 8.6.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1)) + '@storybook/csf-plugin': 8.6.9(storybook@8.6.9(prettier@2.2.1)) + '@storybook/react-dom-shim': 8.6.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-highlight@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-highlight@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/addon-mdx-gfm@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-mdx-gfm@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: remark-gfm: 4.0.0 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) ts-dedent: 2.2.0 transitivePeerDependencies: - supports-color - '@storybook/addon-measure@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-measure@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) tiny-invariant: 1.3.3 - '@storybook/addon-storysource@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-storysource@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: - '@storybook/source-loader': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/source-loader': 8.6.9(storybook@8.6.9(prettier@2.2.1)) estraverse: 5.3.0 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) tiny-invariant: 1.3.3 - '@storybook/addon-viewport@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/addon-viewport@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: memoizerific: 1.11.3 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/blocks@8.6.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1))': + '@storybook/blocks@8.6.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1))': dependencies: '@storybook/icons': 1.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) ts-dedent: 2.2.0 optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.6.7(storybook@8.6.7(prettier@2.2.1))(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/builder-vite@8.6.9(storybook@8.6.9(prettier@2.2.1))(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@storybook/csf-plugin': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/csf-plugin': 8.6.9(storybook@8.6.9(prettier@2.2.1)) browser-assert: 1.2.1 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) ts-dedent: 2.2.0 - vite: 6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - '@storybook/components@8.4.5(storybook@8.6.7(prettier@2.2.1))': + '@storybook/components@8.4.5(storybook@8.6.9(prettier@2.2.1))': dependencies: - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/components@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/components@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/core-events@8.4.5(storybook@8.6.7(prettier@2.2.1))': + '@storybook/core-events@8.4.5(storybook@8.6.9(prettier@2.2.1))': dependencies: - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/core@8.6.7(prettier@2.2.1)(storybook@8.6.7(prettier@2.2.1))': + '@storybook/core@8.6.9(prettier@2.2.1)(storybook@8.6.9(prettier@2.2.1))': dependencies: - '@storybook/theming': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/theming': 8.6.9(storybook@8.6.9(prettier@2.2.1)) better-opn: 3.0.2 browser-assert: 1.2.1 - esbuild: 0.25.1 - esbuild-register: 3.6.0(esbuild@0.25.1) + esbuild: 0.25.2 + esbuild-register: 3.6.0(esbuild@0.25.2) jsdoc-type-pratt-parser: 4.1.0 process: 0.11.10 recast: 0.23.9 @@ -8053,9 +8417,9 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/csf-plugin@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) unplugin: 1.16.0 '@storybook/global@5.0.0': {} @@ -8065,84 +8429,84 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/instrumenter@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/instrumenter@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: '@storybook/global': 5.0.0 '@vitest/utils': 2.1.5 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/manager-api@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/manager-api@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/preview-api@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/preview-api@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/react-dom-shim@8.6.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1))': + '@storybook/react-dom-shim@8.6.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/react-vite@8.6.7(@storybook/test@8.6.7(storybook@8.6.7(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.34.8)(storybook@8.6.7(prettier@2.2.1))(typescript@5.7.3)(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/react-vite@8.6.9(@storybook/test@8.6.9(storybook@8.6.9(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.38.0)(storybook@8.6.9(prettier@2.2.1))(typescript@5.7.3)(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.7.3)(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@rollup/pluginutils': 5.1.3(rollup@4.34.8) - '@storybook/builder-vite': 8.6.7(storybook@8.6.7(prettier@2.2.1))(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@storybook/react': 8.6.7(@storybook/test@8.6.7(storybook@8.6.7(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1))(typescript@5.7.3) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.7.3)(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@rollup/pluginutils': 5.1.3(rollup@4.38.0) + '@storybook/builder-vite': 8.6.9(storybook@8.6.9(prettier@2.2.1))(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/react': 8.6.9(@storybook/test@8.6.9(storybook@8.6.9(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1))(typescript@5.7.3) find-up: 5.0.0 magic-string: 0.30.17 react: 18.3.1 react-docgen: 7.1.0 react-dom: 18.3.1(react@18.3.1) resolve: 1.22.8 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) tsconfig-paths: 4.2.0 - vite: 6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) optionalDependencies: - '@storybook/test': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/test': 8.6.9(storybook@8.6.9(prettier@2.2.1)) transitivePeerDependencies: - rollup - supports-color - typescript - '@storybook/react@8.6.7(@storybook/test@8.6.7(storybook@8.6.7(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1))(typescript@5.7.3)': + '@storybook/react@8.6.9(@storybook/test@8.6.9(storybook@8.6.9(prettier@2.2.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1))(typescript@5.7.3)': dependencies: - '@storybook/components': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/components': 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.6.7(storybook@8.6.7(prettier@2.2.1)) - '@storybook/preview-api': 8.6.7(storybook@8.6.7(prettier@2.2.1)) - '@storybook/react-dom-shim': 8.6.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1)) - '@storybook/theming': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/manager-api': 8.6.9(storybook@8.6.9(prettier@2.2.1)) + '@storybook/preview-api': 8.6.9(storybook@8.6.9(prettier@2.2.1)) + '@storybook/react-dom-shim': 8.6.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1)) + '@storybook/theming': 8.6.9(storybook@8.6.9(prettier@2.2.1)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) optionalDependencies: - '@storybook/test': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/test': 8.6.9(storybook@8.6.9(prettier@2.2.1)) typescript: 5.7.3 - '@storybook/source-loader@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/source-loader@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: es-toolkit: 1.27.0 estraverse: 5.3.0 prettier: 3.3.3 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/test@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/test@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/instrumenter': 8.6.9(storybook@8.6.9(prettier@2.2.1)) '@testing-library/dom': 10.4.0 '@testing-library/jest-dom': 6.5.0 '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) '@vitest/expect': 2.0.5 '@vitest/spy': 2.0.5 - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) - '@storybook/theming@8.6.7(storybook@8.6.7(prettier@2.2.1))': + '@storybook/theming@8.6.9(storybook@8.6.9(prettier@2.2.1))': dependencies: - storybook: 8.6.7(prettier@2.2.1) + storybook: 8.6.9(prettier@2.2.1) '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.0)': dependencies: @@ -8310,14 +8674,14 @@ snapshots: '@testing-library/cypress@10.0.3(cypress@14.0.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@testing-library/dom': 10.4.0 cypress: 14.0.1 '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@types/aria-query': 5.0.4 aria-query: 5.3.0 chalk: 4.1.2 @@ -8328,7 +8692,7 @@ snapshots: '@testing-library/jest-dom@6.4.8': dependencies: '@adobe/css-tools': 4.4.1 - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 aria-query: 5.3.2 chalk: 3.0.0 css.escape: 1.5.1 @@ -8348,7 +8712,7 @@ snapshots: '@testing-library/react@16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 '@testing-library/dom': 10.4.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -8364,24 +8728,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.26.10 - '@babel/types': 7.26.10 + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.26.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.26.10 - '@babel/types': 7.26.10 + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.26.0 '@types/braintree-web@3.96.15': dependencies: @@ -8438,6 +8802,8 @@ snapshots: '@types/estree@1.0.6': {} + '@types/estree@1.0.7': {} + '@types/googlepay@0.7.6': {} '@types/hast@3.0.4': @@ -8579,8 +8945,6 @@ snapshots: '@types/uuid@9.0.8': {} - '@types/warning@3.0.3': {} - '@types/xml2js@0.4.14': dependencies: '@types/node': 20.17.6 @@ -8651,7 +9015,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.7.3) '@typescript-eslint/utils': 6.21.0(eslint@7.32.0)(typescript@5.7.3) - debug: 4.3.7 + debug: 4.4.0(supports-color@8.1.1) eslint: 7.32.0 ts-api-utils: 1.4.0(typescript@5.7.3) optionalDependencies: @@ -8698,7 +9062,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.7 + debug: 4.4.0(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -8754,10 +9118,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@3.7.2(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitejs/plugin-react-swc@3.7.2(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@swc/core': 1.10.11 - vite: 6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' @@ -8793,14 +9157,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.7(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(vite@6.1.1(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitest/mocker@3.0.7(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@vitest/spy': 3.0.7 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.6.5(@types/node@20.17.6)(typescript@5.7.3) - vite: 6.1.1(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/pretty-format@2.0.5': dependencies: @@ -9093,7 +9457,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 cosmiconfig: 7.1.0 resolve: 1.22.8 @@ -9197,13 +9561,15 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + camelcase@6.3.0: {} caniuse-lite@1.0.30001680: {} canvg@3.0.11: dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.27.0 '@types/raf': 3.4.3 core-js: 3.39.0 raf: 3.4.1 @@ -9331,6 +9697,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -9373,6 +9745,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@12.1.0: {} commander@13.1.0: {} @@ -9398,7 +9772,7 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 - consola@3.4.1: {} + consola@3.4.2: {} consolidated-events@2.0.2: {} @@ -9486,15 +9860,41 @@ snapshots: dependencies: cypress: 14.0.1 + cypress-mochawesome-reporter@3.8.2(cypress@14.0.1)(mocha@10.8.2): + dependencies: + commander: 10.0.1 + cypress: 14.0.1 + fs-extra: 10.1.0 + mochawesome: 7.1.3(mocha@10.8.2) + mochawesome-merge: 4.4.1 + mochawesome-report-generator: 6.2.0 + transitivePeerDependencies: + - mocha + + cypress-multi-reporters@2.0.5(mocha@10.8.2): + dependencies: + debug: 4.4.0(supports-color@8.1.1) + lodash: 4.17.21 + mocha: 10.8.2 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + + cypress-on-fix@1.1.0: + dependencies: + debug: 4.4.0(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + cypress-real-events@1.14.0(cypress@14.0.1): dependencies: cypress: 14.0.1 - cypress-vite@1.6.0(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + cypress-vite@1.6.0(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: chokidar: 3.6.0 debug: 4.4.0(supports-color@8.1.1) - vite: 6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -9611,6 +10011,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.1 + dateformat@4.6.3: {} + dayjs@1.11.13: {} debug@3.2.7(supports-color@8.1.1): @@ -9629,6 +10031,8 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decamelize@1.2.0: {} + decamelize@4.0.0: {} decimal.js-light@2.5.1: {} @@ -9687,7 +10091,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 csstype: 3.1.3 dompurify@3.2.4: @@ -9858,41 +10262,13 @@ snapshots: es-toolkit@1.27.0: {} - esbuild-register@3.6.0(esbuild@0.25.1): + esbuild-register@3.6.0(esbuild@0.25.2): dependencies: debug: 4.4.0(supports-color@8.1.1) - esbuild: 0.25.1 + esbuild: 0.25.2 transitivePeerDependencies: - supports-color - esbuild@0.24.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.24.2 - '@esbuild/android-arm': 0.24.2 - '@esbuild/android-arm64': 0.24.2 - '@esbuild/android-x64': 0.24.2 - '@esbuild/darwin-arm64': 0.24.2 - '@esbuild/darwin-x64': 0.24.2 - '@esbuild/freebsd-arm64': 0.24.2 - '@esbuild/freebsd-x64': 0.24.2 - '@esbuild/linux-arm': 0.24.2 - '@esbuild/linux-arm64': 0.24.2 - '@esbuild/linux-ia32': 0.24.2 - '@esbuild/linux-loong64': 0.24.2 - '@esbuild/linux-mips64el': 0.24.2 - '@esbuild/linux-ppc64': 0.24.2 - '@esbuild/linux-riscv64': 0.24.2 - '@esbuild/linux-s390x': 0.24.2 - '@esbuild/linux-x64': 0.24.2 - '@esbuild/netbsd-arm64': 0.24.2 - '@esbuild/netbsd-x64': 0.24.2 - '@esbuild/openbsd-arm64': 0.24.2 - '@esbuild/openbsd-x64': 0.24.2 - '@esbuild/sunos-x64': 0.24.2 - '@esbuild/win32-arm64': 0.24.2 - '@esbuild/win32-ia32': 0.24.2 - '@esbuild/win32-x64': 0.24.2 - esbuild@0.25.1: optionalDependencies: '@esbuild/aix-ppc64': 0.25.1 @@ -9921,8 +10297,38 @@ snapshots: '@esbuild/win32-ia32': 0.25.1 '@esbuild/win32-x64': 0.25.1 + esbuild@0.25.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.2 + '@esbuild/android-arm': 0.25.2 + '@esbuild/android-arm64': 0.25.2 + '@esbuild/android-x64': 0.25.2 + '@esbuild/darwin-arm64': 0.25.2 + '@esbuild/darwin-x64': 0.25.2 + '@esbuild/freebsd-arm64': 0.25.2 + '@esbuild/freebsd-x64': 0.25.2 + '@esbuild/linux-arm': 0.25.2 + '@esbuild/linux-arm64': 0.25.2 + '@esbuild/linux-ia32': 0.25.2 + '@esbuild/linux-loong64': 0.25.2 + '@esbuild/linux-mips64el': 0.25.2 + '@esbuild/linux-ppc64': 0.25.2 + '@esbuild/linux-riscv64': 0.25.2 + '@esbuild/linux-s390x': 0.25.2 + '@esbuild/linux-x64': 0.25.2 + '@esbuild/netbsd-arm64': 0.25.2 + '@esbuild/netbsd-x64': 0.25.2 + '@esbuild/openbsd-arm64': 0.25.2 + '@esbuild/openbsd-x64': 0.25.2 + '@esbuild/sunos-x64': 0.25.2 + '@esbuild/win32-arm64': 0.25.2 + '@esbuild/win32-ia32': 0.25.2 + '@esbuild/win32-x64': 0.25.2 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -10177,7 +10583,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 esutils@2.0.3: {} @@ -10312,6 +10718,11 @@ snapshots: find-root@1.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -10383,6 +10794,18 @@ snapshots: dependencies: '@braintree/uuid': 0.1.0 + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fs-extra@9.1.0: dependencies: at-least-node: 1.0.0 @@ -10395,6 +10818,8 @@ snapshots: fsevents@2.3.3: optional: true + fsu@1.1.1: {} + function-bind@1.1.2: {} function.prototype.name@1.1.6: @@ -10588,7 +11013,7 @@ snapshots: history@4.10.1: dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 loose-envify: 1.4.0 resolve-pathname: 3.0.0 tiny-invariant: 1.3.3 @@ -10927,8 +11352,6 @@ snapshots: joycon@3.1.1: {} - js-sha256@0.11.0: {} - js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -10992,6 +11415,10 @@ snapshots: json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jsonfile@6.1.0: dependencies: universalify: 2.0.1 @@ -11004,7 +11431,7 @@ snapshots: jspdf@3.0.1: dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.27.0 atob: 2.1.2 btoa: 1.2.1 fflate: 0.8.2 @@ -11134,6 +11561,10 @@ snapshots: dependencies: lie: 3.1.1 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -11144,8 +11575,16 @@ snapshots: lodash.get@4.4.2: {} + lodash.isempty@4.4.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isobject@3.0.2: {} + lodash.isplainobject@4.0.6: {} + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} lodash.once@4.1.1: {} @@ -11635,6 +12074,41 @@ snapshots: yargs-parser: 20.2.9 yargs-unparser: 2.0.0 + mochawesome-merge@4.4.1: + dependencies: + fs-extra: 7.0.1 + glob: 7.2.3 + yargs: 15.4.1 + + mochawesome-report-generator@6.2.0: + dependencies: + chalk: 4.1.2 + dateformat: 4.6.3 + escape-html: 1.0.3 + fs-extra: 10.1.0 + fsu: 1.1.1 + lodash.isfunction: 3.0.9 + opener: 1.5.2 + prop-types: 15.8.1 + tcomb: 3.2.29 + tcomb-validation: 3.4.1 + validator: 13.12.0 + yargs: 17.7.2 + + mochawesome@7.1.3(mocha@10.8.2): + dependencies: + chalk: 4.1.2 + diff: 5.2.0 + json-stringify-safe: 5.0.1 + lodash.isempty: 4.4.0 + lodash.isfunction: 3.0.9 + lodash.isobject: 3.0.2 + lodash.isstring: 4.0.1 + mocha: 10.8.2 + mochawesome-report-generator: 6.2.0 + strip-ansi: 6.0.1 + uuid: 8.3.2 + moment@2.30.1: {} mrmime@2.0.0: {} @@ -11676,7 +12150,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.8: {} + nanoid@3.3.11: {} natural-compare-lite@1.4.0: {} @@ -11776,6 +12250,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + opener@1.5.2: {} + optionator@0.8.3: dependencies: deep-is: 0.1.4 @@ -11800,10 +12276,18 @@ snapshots: outvariant@1.4.3: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -11812,6 +12296,8 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -11888,7 +12374,7 @@ snapshots: polished@4.3.1: dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 possible-typed-array-names@1.0.0: {} @@ -11903,7 +12389,7 @@ snapshots: postcss@8.5.3: dependencies: - nanoid: 3.3.8 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -12003,7 +12489,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/traverse': 7.25.9 - '@babel/types': 7.26.10 + '@babel/types': 7.26.0 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 '@types/doctrine': 0.0.9 @@ -12052,7 +12538,7 @@ snapshots: react-redux@7.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1): dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 loose-envify: 1.4.0 @@ -12065,7 +12551,7 @@ snapshots: react-router-dom@5.3.4(react@18.3.1): dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -12082,7 +12568,7 @@ snapshots: react-router@5.3.4(react@18.3.1): dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -12103,7 +12589,7 @@ snapshots: react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -12119,7 +12605,7 @@ snapshots: react-waypoint@10.3.0(react@18.3.1): dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 consolidated-events: 2.0.2 prop-types: 15.8.1 react: 18.3.1 @@ -12162,7 +12648,7 @@ snapshots: recompose@0.30.0(react@18.3.1): dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 change-emitter: 0.1.6 fbjs: 0.8.18 hoist-non-react-statics: 2.5.5 @@ -12188,7 +12674,7 @@ snapshots: redux@4.2.1: dependencies: - '@babel/runtime': 7.26.10 + '@babel/runtime': 7.26.0 reflect.getprototypeof@1.0.6: dependencies: @@ -12262,6 +12748,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requireindex@1.1.0: {} requires-port@1.0.0: {} @@ -12340,6 +12828,32 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.8 fsevents: 2.3.3 + rollup@4.38.0: + dependencies: + '@types/estree': 1.0.7 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.38.0 + '@rollup/rollup-android-arm64': 4.38.0 + '@rollup/rollup-darwin-arm64': 4.38.0 + '@rollup/rollup-darwin-x64': 4.38.0 + '@rollup/rollup-freebsd-arm64': 4.38.0 + '@rollup/rollup-freebsd-x64': 4.38.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.38.0 + '@rollup/rollup-linux-arm-musleabihf': 4.38.0 + '@rollup/rollup-linux-arm64-gnu': 4.38.0 + '@rollup/rollup-linux-arm64-musl': 4.38.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.38.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.38.0 + '@rollup/rollup-linux-riscv64-gnu': 4.38.0 + '@rollup/rollup-linux-riscv64-musl': 4.38.0 + '@rollup/rollup-linux-s390x-gnu': 4.38.0 + '@rollup/rollup-linux-x64-gnu': 4.38.0 + '@rollup/rollup-linux-x64-musl': 4.38.0 + '@rollup/rollup-win32-arm64-msvc': 4.38.0 + '@rollup/rollup-win32-ia32-msvc': 4.38.0 + '@rollup/rollup-win32-x64-msvc': 4.38.0 + fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} run-async@2.4.1: {} @@ -12398,6 +12912,8 @@ snapshots: dependencies: randombytes: 2.1.0 + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -12549,14 +13065,14 @@ snapshots: std-env@3.8.0: {} - storybook-dark-mode@4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.7(prettier@2.2.1)): + storybook-dark-mode@4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.9(prettier@2.2.1)): dependencies: - '@storybook/components': 8.4.5(storybook@8.6.7(prettier@2.2.1)) - '@storybook/core-events': 8.4.5(storybook@8.6.7(prettier@2.2.1)) + '@storybook/components': 8.4.5(storybook@8.6.9(prettier@2.2.1)) + '@storybook/core-events': 8.4.5(storybook@8.6.9(prettier@2.2.1)) '@storybook/global': 5.0.0 '@storybook/icons': 1.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@storybook/manager-api': 8.6.7(storybook@8.6.7(prettier@2.2.1)) - '@storybook/theming': 8.6.7(storybook@8.6.7(prettier@2.2.1)) + '@storybook/manager-api': 8.6.9(storybook@8.6.9(prettier@2.2.1)) + '@storybook/theming': 8.6.9(storybook@8.6.9(prettier@2.2.1)) fast-deep-equal: 3.1.3 memoizerific: 1.11.3 transitivePeerDependencies: @@ -12564,9 +13080,9 @@ snapshots: - react-dom - storybook - storybook@8.6.7(prettier@2.2.1): + storybook@8.6.9(prettier@2.2.1): dependencies: - '@storybook/core': 8.6.7(prettier@2.2.1)(storybook@8.6.7(prettier@2.2.1)) + '@storybook/core': 8.6.9(prettier@2.2.1)(storybook@8.6.9(prettier@2.2.1)) optionalDependencies: prettier: 2.2.1 transitivePeerDependencies: @@ -12730,6 +13246,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tcomb-validation@3.4.1: + dependencies: + tcomb: 3.2.29 + + tcomb@3.2.29: {} + terser@5.36.0: dependencies: '@jridgewell/source-map': 0.3.6 @@ -12870,7 +13392,7 @@ snapshots: bundle-require: 5.1.0(esbuild@0.25.1) cac: 6.7.14 chokidar: 4.0.3 - consola: 3.4.1 + consola: 3.4.2 debug: 4.4.0(supports-color@8.1.1) esbuild: 0.25.1 joycon: 3.1.1 @@ -13017,6 +13539,8 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + universalify@0.1.2: {} + universalify@0.2.0: {} universalify@2.0.1: {} @@ -13066,6 +13590,8 @@ snapshots: v8-compile-cache@2.4.0: {} + validator@13.12.0: {} + value-equal@1.0.1: {} verror@1.10.0: @@ -13107,7 +13633,7 @@ snapshots: debug: 4.4.0(supports-color@8.1.1) es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@types/node' - jiti @@ -13122,35 +13648,22 @@ snapshots: - tsx - yaml - vite-plugin-svgr@3.3.0(rollup@4.34.8)(typescript@5.7.3)(vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-plugin-svgr@3.3.0(rollup@4.38.0)(typescript@5.7.3)(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.34.8) + '@rollup/pluginutils': 5.1.3(rollup@4.38.0) '@svgr/core': 8.1.0(typescript@5.7.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) - vite: 6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@6.1.1(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): - dependencies: - esbuild: 0.24.2 - postcss: 8.5.3 - rollup: 4.34.8 - optionalDependencies: - '@types/node': 20.17.6 - fsevents: 2.3.3 - jiti: 1.21.6 - terser: 5.36.0 - tsx: 4.19.3 - yaml: 2.6.1 - - vite@6.2.2(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: - esbuild: 0.25.1 + esbuild: 0.25.2 postcss: 8.5.3 - rollup: 4.34.8 + rollup: 4.38.0 optionalDependencies: '@types/node': 20.17.6 fsevents: 2.3.3 @@ -13162,7 +13675,7 @@ snapshots: vitest@3.0.7(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.0.7)(jiti@1.21.6)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: '@vitest/expect': 3.0.7 - '@vitest/mocker': 3.0.7(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(vite@6.1.1(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/mocker': 3.0.7(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(vite@6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/pretty-format': 3.0.7 '@vitest/runner': 3.0.7 '@vitest/snapshot': 3.0.7 @@ -13178,7 +13691,7 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.1.1(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.2.4(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) vite-node: 3.0.7(@types/node@20.17.6)(jiti@1.21.6)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: @@ -13204,10 +13717,6 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - warning@4.0.3: - dependencies: - loose-envify: 1.4.0 - webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {} @@ -13270,6 +13779,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.3 + which-module@2.0.1: {} + which-typed-array@1.1.15: dependencies: available-typed-arrays: 1.0.7 @@ -13340,12 +13851,19 @@ snapshots: xmlchars@2.2.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yaml@2.6.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} @@ -13357,6 +13875,20 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@16.2.0: dependencies: cliui: 7.0.4 diff --git a/scripts/changelog/generate-changelogs.mjs b/scripts/changelog/generate-changelogs.mjs index cded57d65c4..ab176479379 100644 --- a/scripts/changelog/generate-changelogs.mjs +++ b/scripts/changelog/generate-changelogs.mjs @@ -76,9 +76,9 @@ try { if (file === "README.md") { return; } - + // Logic to parse the changeset file and generate the changelog content - const filePath = path.join(changesetDirectory(linodePackage), file); + const filePath = changesetDirectory(linodePackage) + path.sep + file; const content = fs.readFileSync(filePath, "utf-8"); const matches = content.match( new RegExp(`"@linode/${linodePackage}": ([^\n]+)`) diff --git a/scripts/changelog/utils/constants.mjs b/scripts/changelog/utils/constants.mjs index 9b61ea0035e..a0919fc8866 100644 --- a/scripts/changelog/utils/constants.mjs +++ b/scripts/changelog/utils/constants.mjs @@ -10,6 +10,7 @@ export const PACKAGES = [ "api-v4", "manager", "queries", + "shared", "ui", "utilities", "validation", @@ -27,11 +28,54 @@ export const CHANGESET_TYPES = [ export const OWNER = "linode"; export const REPO = "manager"; -export const changelogPath = (linodePackage) => - path.join(__dirname, `../../../packages/${linodePackage}/CHANGELOG.md`); -export const changesetDirectory = (linodePackage) => - path.join(__dirname, `../../../packages/${linodePackage}/.changeset`); -export const packageJsonPath = (linodePackage) => - path.join(__dirname, `../../../packages/${linodePackage}/package.json`); +const CHANGELOG_PATHS = { + "api-v4": path.join(__dirname, "../../../packages/api-v4/CHANGELOG.md"), + "manager": path.join(__dirname, "../../../packages/manager/CHANGELOG.md"), + "queries": path.join(__dirname, "../../../packages/queries/CHANGELOG.md"), + "ui": path.join(__dirname, "../../../packages/ui/CHANGELOG.md"), + "shared": path.join(__dirname, "../../../packages/shared/CHANGELOG.md"), + "utilities": path.join(__dirname, "../../../packages/utilities/CHANGELOG.md"), + "validation": path.join(__dirname, "../../../packages/validation/CHANGELOG.md"), +}; +const CHANGESET_DIRECTORIES = { + "api-v4": path.join(__dirname, "../../../packages/api-v4/.changeset"), + "manager": path.join(__dirname, "../../../packages/manager/.changeset"), + "queries": path.join(__dirname, "../../../packages/queries/.changeset"), + "ui": path.join(__dirname, "../../../packages/ui/.changeset"), + "utilities": path.join(__dirname, "../../../packages/utilities/.changeset"), + "validation": path.join(__dirname, "../../../packages/validation/.changeset"), + "shared": path.join(__dirname, "../../../packages/shared/.changeset"), +}; + +const PACKAGE_JSON_PATHS = { + "api-v4": path.join(__dirname, "../../../packages/api-v4/package.json"), + "manager": path.join(__dirname, "../../../packages/manager/package.json"), + "queries": path.join(__dirname, "../../../packages/queries/package.json"), + "ui": path.join(__dirname, "../../../packages/ui/package.json"), + "utilities": path.join(__dirname, "../../../packages/utilities/package.json"), + "validation": path.join(__dirname, "../../../packages/validation/package.json"), + "shared": path.join(__dirname, "../../../packages/shared/package.json"), +}; + +export const changelogPath = (linodePackage) => { + if (!CHANGELOG_PATHS[linodePackage]) { + throw new Error(`Invalid package: ${linodePackage}`); + } + return CHANGELOG_PATHS[linodePackage]; +}; + +export const changesetDirectory = (linodePackage) => { + if (!CHANGESET_DIRECTORIES[linodePackage]) { + throw new Error(`Invalid package: ${linodePackage}`); + } + return CHANGESET_DIRECTORIES[linodePackage]; +}; + +export const packageJsonPath = (linodePackage) => { + if (!PACKAGE_JSON_PATHS[linodePackage]) { + throw new Error(`Invalid package: ${linodePackage}`); + } + return PACKAGE_JSON_PATHS[linodePackage]; +}; \ No newline at end of file diff --git a/scripts/changelog/utils/deleteChangesets.mjs b/scripts/changelog/utils/deleteChangesets.mjs index d43fe9d61e2..65cd43e9ae6 100644 --- a/scripts/changelog/utils/deleteChangesets.mjs +++ b/scripts/changelog/utils/deleteChangesets.mjs @@ -20,15 +20,19 @@ export const deleteChangesets = async (linodePackage) => { const files = await readdir(changesetDir); for (const file of files) { - if (file !== "README.md") { - const filePath = path.join(changesetDir, file); - try { - await unlink(filePath); - console.warn(`Deleted: ${filePath}`); - await git.rm(filePath); - } catch (error) { - console.error(`Error occurred while deleting ${filePath}:`, error); - } + + if (file === "README.md") { + continue; + } + + const filePath = changesetDir + path.sep + file; + + try { + await unlink(filePath); + console.warn("Deleted:", filePath); + await git.rm(filePath); + } catch (error) { + console.error("Error occurred while deleting:", filePath, error); } } diff --git a/scripts/package-versions/index.js b/scripts/package-versions/index.js index 831ef3823f8..6a13305ff4c 100644 --- a/scripts/package-versions/index.js +++ b/scripts/package-versions/index.js @@ -11,7 +11,8 @@ * - `` (Optional) Desired Validation package version. * - `` (Optional) Desired UI package version. * - `` (Optional) Desired Utilities package version. - * - `` (Optional) Desired Queries package version. + * - `` (Optional) Desired Queries package version. + * - `` (Optional) Desired Shared package version. * * Optional Flags: * - `-f | --force` Forces the script to update package versions without @@ -53,6 +54,16 @@ const flags = args.filter((arg) => { */ const root = path.resolve(import.meta.dirname, '..', '..'); +const PACKAGE_PATHS = { + 'manager': path.resolve(root, 'packages', 'manager', 'package.json'), + 'api-v4': path.resolve(root, 'packages', 'api-v4', 'package.json'), + 'validation': path.resolve(root, 'packages', 'validation', 'package.json'), + 'shared': path.resolve(root, 'packages', 'shared', 'package.json'), + 'ui': path.resolve(root, 'packages', 'ui', 'package.json'), + 'utilities': path.resolve(root, 'packages', 'utilities', 'package.json'), + 'queries': path.resolve(root, 'packages', 'queries', 'package.json') +}; + /** * Gets the path to the package.json file for the package with the given name. * @@ -61,7 +72,11 @@ const root = path.resolve(import.meta.dirname, '..', '..'); * @returns {string} Package path for `packageName`. */ const getPackagePath = (packageName) => { - return path.join(root, 'packages', packageName, 'package.json'); + if (!PACKAGE_PATHS[packageName]) { + throw new Error(`Invalid package name: ${packageName}`); + } + + return PACKAGE_PATHS[packageName]; }; /** @@ -105,6 +120,7 @@ const [ desiredUiVersion, desiredUtilitiesVersion, desiredQueriesVersion, + desiredSharedVersion, ] = desiredVersions; // Describes packages that should be modified by this script. @@ -115,6 +131,7 @@ const jobs = [ { name: 'ui', path: getPackagePath('ui'), desiredVersion: desiredUiVersion }, { name: 'utilities', path: getPackagePath('utilities'), desiredVersion: desiredUtilitiesVersion }, { name: 'queries', path: getPackagePath('queries'), desiredVersion: desiredQueriesVersion }, + { name: 'shared', path: getPackagePath('shared'), desiredVersion: desiredSharedVersion }, ]; // Describes the files that will be written to, and the changes that will be made. diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 224072aade5..d54bb838a3a 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -2,6 +2,7 @@ export default [ "packages/api-v4", "packages/manager", "packages/search", + "packages/shared", "packages/ui", "packages/utilities", ];