diff --git a/docs/development-guide/13-coding-standards.md b/docs/development-guide/13-coding-standards.md index 159281d7989..d840fadc201 100644 --- a/docs/development-guide/13-coding-standards.md +++ b/docs/development-guide/13-coding-standards.md @@ -14,7 +14,19 @@ We use [ESLint](https://eslint.org/) to enforce coding and formatting standards. If you are using VSCode it is **highly** recommended to use the [ESlint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). The [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) is also recommended, as it can be configured to format your code on save. ## React +### useEffect() +`useEffect()` should only be used for handling true side effects - specifically API calls, subscriptions, and DOM mutations that must occur outside React's render cycle. While you may encounter instances where `useEffect()` is used differently throughout our existing codebase, we're actively working to remove those instances. Existing code that does not adhere to the hook's proper use should not be used as precedent for implementing new `useEffect()` instances. All state updates and data transformations should be handled through event handlers and direct state management. +When Not to Use Effects: +- Prop synchronization with state +- Derived state calculations +- Post-render state updates +- Props/state triggers for child components +- Chaining state updates + +Reference: https://react.dev/learn/you-might-not-need-an-effect + +### useId() [Several new hooks were introduced with the release of React 18](https://react.dev/blog/2022/03/29/react-v18#new-hooks). It should be noted that the `useId()` hook is particularly useful for generating unique IDs for accessibility attributes. For this use case, `useId()` is preferred over hardcoding the ID because components may be rendered more than once on a page, but IDs must be unique. diff --git a/docs/tooling/analytics.md b/docs/tooling/analytics.md index a0ab6b9e474..9c8c994c08a 100644 --- a/docs/tooling/analytics.md +++ b/docs/tooling/analytics.md @@ -1,8 +1,35 @@ # Analytics +## Pendo + +Cloud Manager uses [Pendo](https://www.pendo.io/pendo-for-your-customers/) to capture analytics, guide users, and improve the user experience. Pendo is the **preferred** method for collecting analytics, including user events, since it requires no development effort and can be accomplished via the Pendo UI. + +To view Pendo dashboards, Cloud Manager developers must follow internal processes to request access. + +### Set Up and Initialization + +Pendo is configured in [`usePendo.js`](https://github.com/linode/manager/blob/develop/packages/manager/src/hooks/usePendo.ts). This custom hook allows us to initialize the Pendo analytics script when the [App](https://github.com/linode/manager/blob/develop/packages/manager/src/App.tsx#L56) is mounted. + +Important notes: + +- Pendo is only loaded 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 is currently not using any client-side (cookies or local) storage. +- 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. + +### Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo + +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. +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 -Cloud Manager uses Adobe Analytics to capture page view and custom event statistics. To view analytics, Cloud Manager developers must follow internal processes to request access to Adobe Analytics dashboards. +Cloud Manager uses Adobe Analytics to capture page view and custom event statistics, although Pendo is the preferred method for collecting this data where possible, as of Q4 2024. To view analytics, Cloud Manager developers must follow internal processes to request access to Adobe Analytics dashboards. ### Writing a Custom Event @@ -63,27 +90,3 @@ See the `LinodeCreateForm` form events as an example. 3. In the browser console, type `_satellite.setDebug(true)`. 4. Refresh the page. You should see Adobe debug log output in the console. Each page view change or custom event that fires should be visible in the logs. 5. When viewing dashboards in Adobe Analytics, it may take ~1 hour for analytics data to update. Once this happens, locally fired events will be visible in the dev dashboard. - -## Pendo - -Cloud Manager uses [Pendo](https://www.pendo.io/pendo-for-your-customers/) to capture analytics, guide users, and improve the user experience. To view Pendo dashboards, Cloud Manager developers must follow internal processes to request access. - -### Set Up and Initialization - -Pendo is configured in [`usePendo.js`](https://github.com/linode/manager/blob/develop/packages/manager/src/hooks/usePendo.ts). This custom hook allows us to initialize the Pendo analytics script when the [App](https://github.com/linode/manager/blob/develop/packages/manager/src/App.tsx#L56) is mounted. - -Important notes: - -- Pendo is only loaded 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). -- 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 is currently not using any client-side (cookies or local) storage. -- 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. - -### Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo - -1. Set the `REACT_APP_PENDO_API_KEY` environment variable in `.env`. -2. Use the browser tools Network tab, filter requests by "pendo", and check that successful network requests have been made to load Pendo scripts. (Also visible in 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. diff --git a/package.json b/package.json index b4fbea4ca1f..46ba965031f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test:sdk": "yarn workspace @linode/api-v4 test", "test:search": "yarn workspace @linode/search test", "test:ui": "yarn workspace @linode/ui test", - "package-versions": "node ./scripts/package-versions/index.js", + "package-versions": "yarn workspace @linode/scripts package-versions", "storybook": "yarn workspace linode-manager storybook", "cy:run": "yarn workspace linode-manager cy:run", "cy:e2e": "yarn workspace linode-manager cy:e2e", @@ -39,23 +39,25 @@ "cy:component": "yarn workspace linode-manager cy:component", "cy:component:run": "yarn workspace linode-manager cy:component:run", "cy:rec-snap": "yarn workspace linode-manager cy:rec-snap", - "changeset": "node scripts/changelog/changeset.mjs", - "generate-changelogs": "node scripts/changelog/generate-changelogs.mjs", + "changeset": "yarn workspace @linode/scripts changeset", + "generate-changelogs": "yarn workspace @linode/scripts generate-changelogs", "coverage": "yarn workspace linode-manager coverage", "coverage:summary": "yarn workspace linode-manager coverage:summary", - "junit:summary": "tsx scripts/junit-summary/index.ts", - "generate-tod": "tsx scripts/tod-payload/index.ts", + "junit:summary": "YARN_SILENT=1 yarn workspace @linode/scripts junit:summary", + "generate-tod": "YARN_SILENT=1 yarn workspace @linode/scripts generate-tod", "docs": "bunx vitepress@1.0.0-rc.44 dev docs", "prepare": "husky" }, "resolutions": { "node-fetch": "^2.6.7", "yaml": "^2.3.0", - "semver": "^7.5.2" + "semver": "^7.5.2", + "cookie": "^0.7.0" }, "workspaces": { "packages": [ - "packages/*" + "packages/*", + "scripts" ] }, "version": "0.0.0", diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 06eefea7905..2094dabd71e 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,35 @@ +## [2025-01-14] - v0.132.0 + +### Added: + +- Types for UDP NodeBalancer support ([#11321](https://github.com/linode/manager/pull/11321)) +- Tags to `KubeNodePoolResponse` and `UpdateNodePoolData` ([#11368](https://github.com/linode/manager/pull/11368)) + +### Changed: + +- Type of `AlertDefinitionType` to `'system'|'user'` ([#11346](https://github.com/linode/manager/pull/11346)) +- Property names, and types of the CreateAlertDefinitionPayload and Alert interfaces ([#11392](https://github.com/linode/manager/pull/11392)) +- BaseDatabase total_disk_size_gb and used_disk_size_gb are always expected and used_disk_size_gb can be null ([#11426](https://github.com/linode/manager/pull/11426)) +- Renamed `AvailableMetrics` type to `MetricDefinition` ([#11433](https://github.com/linode/manager/pull/11433)) +- Changed MetricCritera, DimensionFilter and Alert Interfaces ([#11445](https://github.com/linode/manager/pull/11445)) + +### Fixed: + +- Nullable AccountBeta ended & description properties ([#11347](https://github.com/linode/manager/pull/11347)) +- Incorrect return type of `updateObjectACL` ([#11369](https://github.com/linode/manager/pull/11369)) + +### Removed: + +- getAccountInfoBeta endpoint ([#11413](https://github.com/linode/manager/pull/11413)) +- `MetricDefinitions` type ([#11433](https://github.com/linode/manager/pull/11433)) + +### Upcoming Features: + +- Fix types for IAM API ([#11397](https://github.com/linode/manager/pull/11397)) +- Add new `getAlertDefinitionByServiceTypeAndId` endpoint to fetch Cloud Pulse alert details by id and service type ([#11399](https://github.com/linode/manager/pull/11399)) +- New `Block Storage Performance B1` linode capability ([#11400](https://github.com/linode/manager/pull/11400)) +- Add `getKubernetesTypesBeta` function ([#11419](https://github.com/linode/manager/pull/11419)) + ## [2024-12-10] - v0.131.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index d26261a5949..ccb47929f77 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.131.0", + "version": "0.132.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/account/account.ts b/packages/api-v4/src/account/account.ts index 0b704f230b6..b612da3d082 100644 --- a/packages/api-v4/src/account/account.ts +++ b/packages/api-v4/src/account/account.ts @@ -35,18 +35,6 @@ export const getAccountInfo = () => { return Request(setURL(`${API_ROOT}/account`), setMethod('GET')); }; -/** - * getAccountInfoBeta - * - * Return beta endpoint account information, - * including contact and billing info. - * - * @TODO LKE-E - M3-8838: Clean up after released to GA, if not otherwise in use - */ -export const getAccountInfoBeta = () => { - return Request(setURL(`${BETA_API_ROOT}/account`), setMethod('GET')); -}; - /** * getNetworkUtilization * diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 6bd9ee37d3f..08b575c7606 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -59,32 +59,35 @@ export interface Account { export type BillingSource = 'linode' | 'akamai'; -export type AccountCapability = - | 'Akamai Cloud Load Balancer' - | 'Akamai Cloud Pulse' - | 'Block Storage' - | 'Block Storage Encryption' - | 'Cloud Firewall' - | 'CloudPulse' - | 'Disk Encryption' - | 'Kubernetes' - | 'Kubernetes Enterprise' - | 'Linodes' - | 'LKE HA Control Planes' - | 'LKE Network Access Control List (IP ACL)' - | 'Machine Images' - | 'Managed Databases' - | 'Managed Databases Beta' - | 'NETINT Quadra T1U' - | 'NodeBalancers' - | 'Object Storage Access Key Regions' - | 'Object Storage Endpoint Types' - | 'Object Storage' - | 'Placement Group' - | 'SMTP Enabled' - | 'Support Ticket Severity' - | 'Vlans' - | 'VPCs'; +export const accountCapabilities = [ + 'Akamai Cloud Load Balancer', + 'Akamai Cloud Pulse', + 'Block Storage', + 'Block Storage Encryption', + 'Cloud Firewall', + 'CloudPulse', + 'Disk Encryption', + 'Kubernetes', + 'Kubernetes Enterprise', + 'Linodes', + 'LKE HA Control Planes', + 'LKE Network Access Control List (IP ACL)', + 'Machine Images', + 'Managed Databases', + 'Managed Databases Beta', + 'NETINT Quadra T1U', + 'NodeBalancers', + 'Object Storage Access Key Regions', + 'Object Storage Endpoint Types', + 'Object Storage', + 'Placement Group', + 'SMTP Enabled', + 'Support Ticket Severity', + 'Vlans', + 'VPCs', +] as const; + +export type AccountCapability = typeof accountCapabilities[number]; export interface AccountAvailability { region: string; // will be slug of dc (matches id field of region object returned by API) @@ -604,8 +607,8 @@ export interface AccountBeta { label: string; started: string; id: string; - ended?: string; - description?: string; + ended: string | null; + description: string | null; /** * The datetime the account enrolled into the beta * @example 2024-10-23T14:22:29 diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 3c6f909b9db..54ece904adc 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -1,7 +1,14 @@ import { createAlertDefinitionSchema } from '@linode/validation'; -import Request, { setURL, setMethod, setData } from '../request'; +import Request, { + setURL, + setMethod, + setData, + setParams, + setXFilter, +} from '../request'; import { Alert, AlertServiceType, CreateAlertDefinitionPayload } from './types'; -import { BETA_API_ROOT as API_ROOT } from 'src/constants'; +import { BETA_API_ROOT as API_ROOT } from '../constants'; +import { Params, Filter, ResourcePage } from '../types'; export const createAlertDefinition = ( data: CreateAlertDefinitionPayload, @@ -16,3 +23,24 @@ export const createAlertDefinition = ( setMethod('POST'), setData(data, createAlertDefinitionSchema) ); + +export const getAlertDefinitions = (params?: Params, filters?: Filter) => + Request>( + setURL(`${API_ROOT}/monitor/alert-definitions`), + setMethod('GET'), + setParams(params), + setXFilter(filters) + ); + +export const getAlertDefinitionByServiceTypeAndId = ( + serviceType: string, + alertId: number +) => + Request( + setURL( + `${API_ROOT}/monitor/services/${encodeURIComponent( + serviceType + )}/alert-definitions/${encodeURIComponent(alertId)}` + ), + setMethod('GET') + ); diff --git a/packages/api-v4/src/cloudpulse/services.ts b/packages/api-v4/src/cloudpulse/services.ts index 5eb06faa0e5..902cb2ec4a2 100644 --- a/packages/api-v4/src/cloudpulse/services.ts +++ b/packages/api-v4/src/cloudpulse/services.ts @@ -3,13 +3,13 @@ import Request, { setData, setMethod, setURL } from '../request'; import { JWEToken, JWETokenPayLoad, - MetricDefinitions, + MetricDefinition, ServiceTypesList, } from './types'; -import { ResourcePage as Page } from 'src/types'; +import { ResourcePage } from 'src/types'; export const getMetricDefinitionsByServiceType = (serviceType: string) => { - return Request>( + return Request>( setURL( `${API_ROOT}/monitor/services/${encodeURIComponent( serviceType diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 4b64bf16c30..2e4e6c4658a 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -2,9 +2,24 @@ export type AlertSeverityType = 0 | 1 | 2 | 3; export type MetricAggregationType = 'avg' | 'sum' | 'min' | 'max' | 'count'; export type MetricOperatorType = 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; export type AlertServiceType = 'linode' | 'dbaas'; -type DimensionFilterOperatorType = 'eq' | 'neq' | 'startswith' | 'endswith'; -export type AlertDefinitionType = 'default' | 'custom'; +export type DimensionFilterOperatorType = + | 'eq' + | 'neq' + | 'startswith' + | 'endswith'; +export type AlertDefinitionType = 'system' | 'user'; export type AlertStatusType = 'enabled' | 'disabled'; +export type CriteriaConditionType = 'ALL'; +export type MetricUnitType = + | 'number' + | 'byte' + | 'second' + | 'percent' + | 'bit_per_second' + | 'millisecond' + | 'KB' + | 'MB' + | 'GB'; export interface Dashboard { id: number; label: string; @@ -74,11 +89,7 @@ export interface AclpWidget { size: number; } -export interface MetricDefinitions { - data: AvailableMetrics[]; -} - -export interface AvailableMetrics { +export interface MetricDefinition { label: string; metric: string; metric_type: string; @@ -142,37 +153,49 @@ export interface ServiceTypesList { export interface CreateAlertDefinitionPayload { label: string; + tags?: string[]; description?: string; entity_ids?: string[]; severity: AlertSeverityType; rule_criteria: { rules: MetricCriteria[]; }; - triggerCondition: TriggerCondition; + trigger_conditions: TriggerCondition; channel_ids: number[]; } export interface MetricCriteria { metric: string; aggregation_type: MetricAggregationType; operator: MetricOperatorType; - value: number; - dimension_filters: DimensionFilter[]; + threshold: number; + dimension_filters?: DimensionFilter[]; } +export interface AlertDefinitionMetricCriteria + extends Omit { + unit: string; + label: string; + dimension_filters?: AlertDefinitionDimensionFilter[]; +} export interface DimensionFilter { dimension_label: string; operator: DimensionFilterOperatorType; value: string; } +export interface AlertDefinitionDimensionFilter extends DimensionFilter { + label: string; +} export interface TriggerCondition { polling_interval_seconds: number; evaluation_period_seconds: number; trigger_occurrences: number; + criteria_condition: CriteriaConditionType; } export interface Alert { id: number; label: string; + tags: string[]; description: string; has_more_resources: boolean; status: AlertStatusType; @@ -181,9 +204,9 @@ export interface Alert { service_type: AlertServiceType; entity_ids: string[]; rule_criteria: { - rules: MetricCriteria[]; + rules: AlertDefinitionMetricCriteria[]; }; - triggerCondition: TriggerCondition; + trigger_conditions: TriggerCondition; channels: { id: string; label: string; diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 87b4458308a..711dbb3aec3 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -165,16 +165,8 @@ interface BaseDatabase extends DatabaseInstance { port: number; /** @Deprecated used by rdbms-legacy only, rdbms-default always uses TLS */ ssl_connection: boolean; - /** - * total_disk_size_gb is feature flagged by the API. - * It may not be defined. - */ - total_disk_size_gb?: number; - /** - * used_disk_size_gb is feature flagged by the API. - * It may not be defined. - */ - used_disk_size_gb?: number; + total_disk_size_gb: number; + used_disk_size_gb: number | null; } /** @deprecated TODO (UIE-8214) remove POST GA */ diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 8aa9fd0ce17..3749b644e64 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -1,9 +1,4 @@ -export interface IamUserPermissions { - account_access: AccountAccessType[]; - resource_access: ResourceAccess[]; -} - -type ResourceType = +export type ResourceTypePermissions = | 'linode' | 'firewall' | 'nodebalancer' @@ -16,24 +11,28 @@ type ResourceType = | 'account' | 'vpc'; -type AccountAccessType = +export type AccountAccessType = | 'account_linode_admin' | 'linode_creator' + | 'linode_contributor' | 'firewall_creator'; -type RoleType = 'linode_contributor' | 'firewall_admin'; +export type RoleType = + | 'linode_contributor' + | 'firewall_admin' + | 'linode_creator' + | 'firewall_creator'; +export interface IamUserPermissions { + account_access: AccountAccessType[]; + resource_access: ResourceAccess[]; +} export interface ResourceAccess { resource_id: number; - resource_type: ResourceType; + resource_type: ResourceTypePermissions; roles: RoleType[]; } -export interface IamAccountPermissions { - account_access: Access[]; - resource_access: Access[]; -} - type PermissionType = | 'create_linode' | 'update_linode' @@ -41,13 +40,20 @@ type PermissionType = | 'delete_linode' | 'view_linode'; -interface Access { - resource_type: ResourceType; +export interface IamAccountPermissions { + account_access: IamAccess[]; + resource_access: IamAccess[]; +} + +export interface IamAccess { + resource_type: ResourceTypePermissions; roles: Roles[]; } export interface Roles { name: string; description: string; - permissions?: PermissionType[]; + permissions: PermissionType[]; } + +export type IamAccessType = keyof IamAccountPermissions; diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 4281ab35c1c..3a3d68917f0 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -275,6 +275,18 @@ export const getKubernetesTypes = (params?: Params) => setParams(params) ); +/** + * getKubernetesTypesBeta + * + * Returns a paginated list of available Kubernetes types from beta API; used for dynamic pricing. + */ +export const getKubernetesTypesBeta = (params?: Params) => + Request>( + setURL(`${BETA_API_ROOT}/lke/types`), + setMethod('GET'), + setParams(params) + ); + /** * getKubernetesClusterControlPlaneACL * diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index 262db13dfc0..6642fcec895 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -23,6 +23,7 @@ export interface KubeNodePoolResponse { count: number; id: number; nodes: PoolNodeResponse[]; + tags: string[]; type: string; autoscaler: AutoscaleSettings; disk_encryption?: EncryptionStatus; // @TODO LDE: remove optionality once LDE is fully rolled out @@ -42,6 +43,7 @@ export interface CreateNodePoolData { export interface UpdateNodePoolData { autoscaler: AutoscaleSettings; count: number; + tags: string[]; } export interface AutoscaleSettings { diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 6f3d94caad3..5689c24ba34 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -20,7 +20,7 @@ export interface Linode { id: number; alerts: LinodeAlerts; backups: LinodeBackups; - capabilities?: LinodeCapabilities[]; // @TODO BSE: Remove optionality once BSE is fully rolled out + capabilities: LinodeCapabilities[]; created: string; disk_encryption?: EncryptionStatus; // @TODO LDE: Remove optionality once LDE is fully rolled out region: string; @@ -55,7 +55,10 @@ export interface LinodeBackups { last_successful: string | null; } -export type LinodeCapabilities = 'Block Storage Encryption' | 'SMTP Enabled'; +export type LinodeCapabilities = + | 'Block Storage Encryption' + | 'SMTP Enabled' + | 'Block Storage Performance B1'; export type Window = | 'Scheduling' diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index f8e93154b0b..68f89c7ac32 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -1,8 +1,31 @@ +type TCPAlgorithm = 'roundrobin' | 'leastconn' | 'source'; +type UDPAlgorithm = 'roundrobin' | 'leastconn' | 'ring_hash'; + +export type Algorithm = TCPAlgorithm | UDPAlgorithm; + +export type Protocol = 'http' | 'https' | 'tcp' | 'udp'; + +type TCPStickiness = 'none' | 'table' | 'http_cookie'; +type UDPStickiness = 'none' | 'session' | 'source_ip'; + +export type Stickiness = TCPStickiness | UDPStickiness; + export interface NodeBalancer { id: number; label: string; hostname: string; + /** + * Maximum number of new TCP connections that a client (identified by a specific source IP) + * is allowed to initiate every second. + */ client_conn_throttle: number; + /** + * Maximum number of new UDP sessions that a client (identified by a specific source IP) + * is allowed to initiate every second. + * + * @todo Remove optionality once UDP support is live + */ + client_udp_sess_throttle?: number; region: string; ipv4: string; ipv6: null | string; @@ -31,11 +54,15 @@ export interface BalancerTransfer { total: number; } +/** + * 'none' is reserved for nodes used in UDP configurations. They don't support different modes. + */ export type NodeBalancerConfigNodeMode = | 'accept' | 'reject' | 'backup' - | 'drain'; + | 'drain' + | 'none'; export interface NodeBalancerConfig { id: number; @@ -44,22 +71,31 @@ export interface NodeBalancerConfig { check_passive: boolean; ssl_cert: string; nodes_status: NodesStatus; - protocol: 'http' | 'https' | 'tcp'; + protocol: Protocol; ssl_commonname: string; check_interval: number; check_attempts: number; check_timeout: number; check_body: string; check_path: string; + /** + * @todo Remove optionality once UDP support is live + */ + udp_check_port?: number; + /** + * @readonly This is returned by the API but *not* editable + * @todo Remove optionality once UDP support is live + * @default 16 + */ + udp_session_timeout?: number; proxy_protocol: NodeBalancerProxyProtocol; check: 'none' | 'connection' | 'http' | 'http_body'; ssl_key: string; - stickiness: 'none' | 'table' | 'http_cookie'; - algorithm: 'roundrobin' | 'leastconn' | 'source'; + stickiness: Stickiness; + algorithm: Algorithm; ssl_fingerprint: string; cipher_suite: 'recommended' | 'legacy'; nodes: NodeBalancerConfigNode[]; - modifyStatus?: 'new'; } export type NodeBalancerProxyProtocol = 'none' | 'v1' | 'v2'; @@ -82,9 +118,36 @@ export interface NodeBalancerStats { export interface CreateNodeBalancerConfig { port?: number; - protocol?: 'http' | 'https' | 'tcp'; - algorithm?: 'roundrobin' | 'leastconn' | 'source'; - stickiness?: 'none' | 'table' | 'http_cookie'; + /** + * If `udp` is chosen: + * - `check_passive` must be `false` or unset + * - `proxy_protocol` must be `none` or unset + * - The various SSL related fields like `ssl_cert`, `ssl_key`, `cipher_suite_recommended` should not be set + */ + protocol?: Protocol; + /** + * @default "none" + */ + proxy_protocol?: NodeBalancerProxyProtocol; + /** + * The algorithm for this configuration. + * + * TCP and HTTP support `roundrobin`, `leastconn`, and `source` + * UDP supports `roundrobin`, `leastconn`, and `ring_hash` + * + * @default roundrobin + */ + algorithm?: Algorithm; + /** + * Session stickiness for this configuration. + * + * TCP and HTTP support `none`, `table`, and `http_cookie` + * UDP supports `none`, `session`, and `source_ip` + * + * @default `session` for UDP + * @default `none` for TCP and HTTP + */ + stickiness?: Stickiness; check?: 'none' | 'connection' | 'http' | 'http_body'; check_interval?: number; check_timeout?: number; @@ -92,6 +155,11 @@ export interface CreateNodeBalancerConfig { check_path?: string; check_body?: string; check_passive?: boolean; + /** + * Must be between 1 and 65535 + * @default 80 + */ + udp_check_port?: number; cipher_suite?: 'recommended' | 'legacy'; ssl_cert?: string; ssl_key?: string; @@ -102,6 +170,9 @@ export type UpdateNodeBalancerConfig = CreateNodeBalancerConfig; export interface CreateNodeBalancerConfigNode { address: string; label: string; + /** + * Should not be specified when creating a node used on a UDP configuration + */ mode?: NodeBalancerConfigNodeMode; weight?: number; } @@ -126,8 +197,21 @@ export interface NodeBalancerConfigNodeWithPort extends NodeBalancerConfigNode { export interface CreateNodeBalancerPayload { region?: string; label?: string; + /** + * The connections per second throttle for TCP and HTTP connections + * + * Must be between 0 and 20. Set to 0 to disable throttling. + * @default 0 + */ client_conn_throttle?: number; - configs: any; + /** + * The connections per second throttle for UDP sessions + * + * Must be between 0 and 20. Set to 0 to disable throttling. + * @default 0 + */ + client_udp_sess_throttle?: number; + configs: CreateNodeBalancerConfig[]; firewall_id?: number; tags?: string[]; } diff --git a/packages/api-v4/src/object-storage/objects.ts b/packages/api-v4/src/object-storage/objects.ts index 892b79ddb7f..7cb8f718dcc 100644 --- a/packages/api-v4/src/object-storage/objects.ts +++ b/packages/api-v4/src/object-storage/objects.ts @@ -64,7 +64,7 @@ export const updateObjectACL = ( name: string, acl: Omit ) => - Request( + Request<{}>( setMethod('PUT'), setURL( `${API_ROOT}/object-storage/buckets/${encodeURIComponent( diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 99870119ff7..cc85bbb89d0 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -89,10 +89,15 @@ module.exports = { { files: [ // for each new features added to the migration router, add its directory here - 'src/features/Betas/*', + 'src/features/Betas/**/*', + 'src/features/Domains/**/*', + 'src/features/Volumes/**/*', ], rules: { 'no-restricted-imports': [ + // This needs to remain an error however trying to link to a feature that is not yet migrated will break the router + // For those cases react-router-dom history.push is still needed + // using `eslint-disable-next-line no-restricted-imports` can help bypass those imports 'error', { paths: [ diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 55197641f90..6de6f42f5ce 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -9,7 +9,10 @@ import { Controls, Stories, } from '@storybook/blocks'; -import { wrapWithTheme } from '../src/utilities/testHelpers'; +import { + wrapWithTheme, + wrapWithThemeAndRouter, +} from '../src/utilities/testHelpers'; import { useDarkMode } from 'storybook-dark-mode'; import { DocsContainer as BaseContainer } from '@storybook/addon-docs'; import { themes } from '@storybook/theming'; @@ -42,9 +45,13 @@ export const DocsContainer = ({ children, context }) => { const preview: Preview = { decorators: [ - (Story) => { + (Story, context) => { const isDark = useDarkMode(); - return wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); + return context.parameters.tanStackRouter + ? wrapWithThemeAndRouter(, { + theme: isDark ? 'dark' : 'light', + }) + : wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); }, ], loaders: [ diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 662734981eb..820634b5612 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,127 @@ 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-01-14] - v1.134.0 + +### Added: + +- New DatePicker Component ([#11151](https://github.com/linode/manager/pull/11151)) +- Date Presets Functionality to Date Picker component ([#11395](https://github.com/linode/manager/pull/11395)) +- Notice for OS Distro Nearing EOL/EOS ([#11253](https://github.com/linode/manager/pull/11253)) +- aria-describedby to TextField with helper text ([#11351](https://github.com/linode/manager/pull/11351)) +- Node Pool Tags to LKE Cluster details page ([#11368](https://github.com/linode/manager/pull/11368)) +- MultipleIPInput Story in Storybook ([#11389](https://github.com/linode/manager/pull/11389)) +- Manage Tags to Volumes table action menu and moved actions inside menu ([#11421](https://github.com/linode/manager/pull/11421)) + +### Changed: + +- Database Resize: Updated tooltip text, plan selection descriptions, and summary text for new databases ([#11406](https://github.com/linode/manager/pull/11406)) +- Database Resize: Disable plans when the usable storage equals the used storage of the database cluster ([#11481](https://github.com/linode/manager/pull/11481)) +- DBaaS Settings Maintenance field Upgrade Version pending updates tooltip should display accurate text ([#11417](https://github.com/linode/manager/pull/11417)) + +### Fixed: + +- Create support ticket for buckets created through legacy flow ([#11300](https://github.com/linode/manager/pull/11300)) +- Incorrect Cloning Commands in Linode CLI Modal ([#11303](https://github.com/linode/manager/pull/11303)) +- Events landing page lists events in wrong order ([#11339](https://github.com/linode/manager/pull/11339)) +- Disallow word-break in billing contact info ([#11379](https://github.com/linode/manager/pull/11379)) +- Object Storage object uploader spinner spinning backwards ([#11384](https://github.com/linode/manager/pull/11384)) +- Document title from URL to appropriate keyword ([#11385](https://github.com/linode/manager/pull/11385)) +- DBaaS settings maintenance does not display review state and allows version upgrade when updates are available ([#11387](https://github.com/linode/manager/pull/11387)) +- Misplaced `errorGroup` prop causing console error in NodeBalancerConfigPanel ([#11398](https://github.com/linode/manager/pull/11398)) +- Account Cancellation Survey Button Color Issues ([#11412](https://github.com/linode/manager/pull/11412)) +- DBaaS Manage Access IP fields are displaying an IPv4 validation error message when both IPv6 and IPv4 are available. ([#11414](https://github.com/linode/manager/pull/11414)) +- `RegionHelperText` causing console errors ([#11416](https://github.com/linode/manager/pull/11416)) +- Linode Edit Config warning message when initially selecting a VPC as the primary interface ([#11424](https://github.com/linode/manager/pull/11424)) +- DBaaS Resize tab Used field is displaying just GB on provisioning database cluster ([#11426](https://github.com/linode/manager/pull/11426)) +- Various bugs in Managed tables ([#11431](https://github.com/linode/manager/pull/11431)) +- ARIA label of action menu in Domains Landing table row ([#11437](https://github.com/linode/manager/pull/11437)) +- VPC interface not being set as the primary interface when creating a Linode ([#11450](https://github.com/linode/manager/pull/11450)) +- `Create Token` button becomes disabled when all permissions are selected individually (without using 'select all') and child-account is hidden ([#11453](https://github.com/linode/manager/pull/11453)) +- Discrepancy in Object Storage Bucket size in CM ([#11460](https://github.com/linode/manager/pull/11460)) +- Object Storage `endpoint_type` sorting ([#11472](https://github.com/linode/manager/pull/11472)) +- Visibility of sensitive data in Managed and Longview with Mask Sensitive Data setting enabled ([#11476](https://github.com/linode/manager/pull/11476)) +- Display Kubernetes API endpoint for LKE-E cluster ([#11485](https://github.com/linode/manager/pull/11485)) +- Accuracy of "Add Node Pools" section on LKE Create page ([#11516](https://github.com/linode/manager/pull/11516)) + +### Removed: + +- `Images are not encrypted warning` warning ([#11443](https://github.com/linode/manager/pull/11443)) +- Temporarily remove Properties tab from Gen2 buckets ([#11491](https://github.com/linode/manager/pull/11491)) + +### Tech Stories: + +- Migrate `/volumes` to Tanstack router ([#11154](https://github.com/linode/manager/pull/11154)) +- Clean up NodeBalancer related types ([#11321](https://github.com/linode/manager/pull/11321)) +- Dev Tools fixes and improvements ([#11328](https://github.com/linode/manager/pull/11328)) +- Replace one-off hardcoded color values with color tokens pt4 ([#11345](https://github.com/linode/manager/pull/11345)) +- Refactor VPC Create to use `react-hook-form` instead of `formik` ([#11357](https://github.com/linode/manager/pull/11357)) +- Refactor VPCEditDrawer and SubnetEditDrawer to use `react-hook-form` instead of `formik` ([#11393](https://github.com/linode/manager/pull/11393)) +- Add `IMAGE_REGISTRY` Docker build argument ([#11360](https://github.com/linode/manager/pull/11360)) +- Remove `reselect` dependency ([#11364](https://github.com/linode/manager/pull/11364)) +- Update `useObjectAccess` to use a query key factory ([#11369](https://github.com/linode/manager/pull/11369)) +- Replace instances of `react-select` in Managed ([#11391](https://github.com/linode/manager/pull/11391)) +- Update our docs regarding useEffect best practices ([#11410](https://github.com/linode/manager/pull/11410)) +- Refactor Domains Routing (Tanstack Router) ([#11418](https://github.com/linode/manager/pull/11418)) +- Update Pendo URL with CNAME and update Analytics developer docs ([#11427](https://github.com/linode/manager/pull/11427)) +- Add MSW crud domains ([#11428](https://github.com/linode/manager/pull/11428)) +- Replace react-select instances in /Users with new Select ([#11430](https://github.com/linode/manager/pull/11430)) +- Fixed CloudPulse metric definition types ([#11433](https://github.com/linode/manager/pull/11433)) +- Patch `cookie` version as resolution for dependabot ([#11434](https://github.com/linode/manager/pull/11434)) +- Replace Select with Autocomplete component in Object Storage ([#11456](https://github.com/linode/manager/pull/11456)) +- Update `react-vnc` to 2.0.2 ([#11467](https://github.com/linode/manager/pull/11467)) + +### Tests: + +- Cypress component test for firewall inbound and outbound rules for mouse drag and drop ([#11344](https://github.com/linode/manager/pull/11344)) +- Cypress component tests for firewall rules drag and drop keyboard interaction ([#11341](https://github.com/linode/manager/pull/11341)) +- Mock LKE creation flow + APL coverage ([#11347](https://github.com/linode/manager/pull/11347)) +- Improve Linode end-to-end test stability by increasing timeouts ([#11350](https://github.com/linode/manager/pull/11350)) +- Fix `delete-volume.spec.ts` flaky test ([#11365](https://github.com/linode/manager/pull/11365)) +- Add Cypress test for Credit Card Expired banner ([#11383](https://github.com/linode/manager/pull/11383)) +- Cypress test flake: Rebuild Linode ([#11390](https://github.com/linode/manager/pull/11390)) +- Improve assertions made in `smoke-billing-activity.spec.ts` ([#11394](https://github.com/linode/manager/pull/11394)) +- Clean up `DatabaseBackups.test.tsx` ([#11394](https://github.com/linode/manager/pull/11394)) +- Fix account login and logout tests when using non-Prod environment ([#11407](https://github.com/linode/manager/pull/11407)) +- Add Cypress component tests for Autocomplete ([#11408](https://github.com/linode/manager/pull/11408)) +- Update mock region for LKE cluster creation test ([#11411](https://github.com/linode/manager/pull/11411)) +- Cypress tests to validate errors in Linode Create Backups tab ([#11422](https://github.com/linode/manager/pull/11422)) +- Cypress test to validate aria label of Linode IP Addresses action menu ([#11435](https://github.com/linode/manager/pull/11435)) +- Cypress test to validate CAA records are editable ([#11440](https://github.com/linode/manager/pull/11440)) +- Add test for LKE cluster rename flow ([#11444](https://github.com/linode/manager/pull/11444)) +- Add unit tests to validate aria-labels of Action Menu for Linode IPs & ranges ([#11448](https://github.com/linode/manager/pull/11448)) +- Add Cypress tests confirming Lionde Config Unrecommended status displays as expected in VPC Subnet table ([#11450](https://github.com/linode/manager/pull/11450)) +- Add Cypress test for LKE node pool tagging ([#11368](https://github.com/linode/manager/pull/11368)) +- Add coverage for Kube version upgrades in landing page ([#11478](https://github.com/linode/manager/pull/11478)) +- Fix Cypress test failures stemming from Debian 10 Image deprecation ([#11486](https://github.com/linode/manager/pull/11486)) +- Added Cypress test for restricted user Image non-Empty landing page ([#11335](https://github.com/linode/manager/pull/11335)) + +### Upcoming Features: + +- Update Kubernetes Versions in Create Cluster flow to support tiers for LKE-E ([#11359](https://github.com/linode/manager/pull/11359)) +- Switch from v4beta to v4 account endpoint for LKE-E ([#11413](https://github.com/linode/manager/pull/11413)) +- Update Kubernetes version upgrade components for LKE-E ([#11415](https://github.com/linode/manager/pull/11415)) +- Display LKE-E pricing in checkout bar ([#11419](https://github.com/linode/manager/pull/11419)) +- Designate LKE-E clusters with 'Enterprise' chip ([#11442](https://github.com/linode/manager/pull/11442)) +- Update LKE cluster details kube specs for LKE-E monthly pricing ([#11475](https://github.com/linode/manager/pull/11475)) +- Add new users table component for IAM ([#11367](https://github.com/linode/manager/pull/11367)) +- Add new user details components for IAM ([#11397](https://github.com/linode/manager/pull/11397)) +- High performance volume indicator ([#11400](https://github.com/linode/manager/pull/11400)) +- Add new no assigned roles component for IAM ([#11401](https://github.com/linode/manager/pull/11401)) +- Fix invalid routes in the IAM ([#11436](https://github.com/linode/manager/pull/11436)) +- Initial support for NodeBalancer UDP protocol ([#11405](https://github.com/linode/manager/pull/11405)) +- Add support for new optional filter - 'Tags' in monitor ([#11457](https://github.com/linode/manager/pull/11457)) +- Show ACLP supported regions per service type in region select ([#11382](https://github.com/linode/manager/pull/11382)) +- Add `CloudPulseAppliedFilter` and `CloudPulseAppliedFilterRenderer` components, update filter change handler function to add another parameter `label` ([#11354](https://github.com/linode/manager/pull/11354)) +- Add column for actions to Cloud Pulse alert definitions listing view and scaffolding for Definition Details page ([#11399](https://github.com/linode/manager/pull/11399)) +- Exhaustive unit tests for CloudPulse widgets ([#11464](https://github.com/linode/manager/pull/11464)) +- Add Alert Details Overview section in Cloud Pulse Alert Details page ([#11466](https://github.com/linode/manager/pull/11466)) +- AlertListing component and AlertTableRow component with Unit Tests ([#11346](https://github.com/linode/manager/pull/11346)) +- Update layout in CloudPulseDashboardWithFilters component, add a `getFilters` util method in `FilterBuilder.ts` ([#11388](https://github.com/linode/manager/pull/11388)) +- Metric, MetricCriteria, ClearIconButton components with Unit Tests ([#11392](https://github.com/linode/manager/pull/11392)) +- DimensionFilter, DimensionFilterField, TriggerCondition component along with Unit Tests ([#11445](https://github.com/linode/manager/pull/11445)) +- Improve Close Account Dialog UI ([#11469](https://github.com/linode/manager/pull/11469)) + ## [2024-12-20] - v1.133.2 ### Fixed: diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index 37e1785a48a..d7ba0a3eb4d 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -1,8 +1,12 @@ +# Registry to use when pulling images. +# Defaults to Docker Hub, but can be overriden to point to another registry if needed. +ARG IMAGE_REGISTRY=docker.io + # Node.js base image for Cloud Manager CI tasks. # # Extends from the Node.js base image that corresponds with our latest supported # version of Node, and includes other tools that we rely on like pnpm and bun. -FROM node:20.17-bullseye-slim as nodejs-cloud-manager +FROM ${IMAGE_REGISTRY}/node:20.17-bullseye-slim as nodejs-cloud-manager RUN npm install -g pnpm bun # `manager` @@ -28,7 +32,7 @@ CMD yarn start:manager:ci # # Builds an image containing Cypress and miscellaneous system utilities required # by the tests. -FROM cypress/included:13.11.0 as e2e-build +FROM ${IMAGE_REGISTRY}/cypress/included:13.11.0 as e2e-build RUN npm install -g pnpm bun USER node WORKDIR /home/node/app diff --git a/packages/manager/cypress/component/components/autocomplete.spec.tsx b/packages/manager/cypress/component/components/autocomplete.spec.tsx new file mode 100644 index 00000000000..b9b8e27d20e --- /dev/null +++ b/packages/manager/cypress/component/components/autocomplete.spec.tsx @@ -0,0 +1,688 @@ +import { Autocomplete } from '@linode/ui'; +import * as React from 'react'; +import { ui } from 'support/ui'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests, visualTests } from 'support/util/components'; +import { createSpy } from 'support/util/components'; + +type Option = { + label: string; + value: string; +}; + +componentTests('Autocomplete', (mount) => { + const options: Option[] = Array.from({ length: 3 }, (_, index) => { + const num = index + 1; + return { + label: `my-option-${num}`, + value: `my-option-${num}`, + }; + }); + + describe('Autocomplete interactions', () => { + describe('Open menu', () => { + /** + * - Confirms dropbdown can be opened by clicking the arrow button + */ + it('can open the drop-down menu by clicking the drop-down arrow', () => { + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .should('be.visible'); + ui.autocompletePopper + .findByTitle(`${options[1].label}`) + .should('be.visible'); + ui.autocompletePopper + .findByTitle(`${options[2].label}`) + .should('be.visible'); + }); + + /** + * - Confirms dropdown can be opened by typing in the textfield + */ + it('can open the drop-down menu by typing into the textfield area', () => { + mount(); + + // Focus text field by clicking "Autocomplete" label. + cy.findByText('Autocomplete').should('be.visible').click(); + + cy.focused().type(options[0].label); + + ui.autocompletePopper.find().within(() => { + cy.findByText(options[0].label).should('be.visible'); + cy.findByText(options[1].label).should('not.exist'); + cy.findByText(options[2].label).should('not.exist'); + }); + }); + + /** + * - Confirms dropdown menu when there are no options + */ + it('shows the open dropdown menu with no options text', () => { + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.contains('You have no options to choose from').should('be.visible'); + }); + }); + + describe('Closing menu', () => { + // esc, click away, up arrow + /** + * - Confirms autocomplete popper can be closed with the ESC key + */ + it('can close the autocomplete menu with ESC key', () => { + mount( + {}} + options={options} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(options[0].label) + .should('be.visible'); + + cy.get('input').type('{esc}'); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /** + * Confirms autocomplete can be closed by clicking away + */ + it('can close autocomplete popper by clicking away', () => { + mount( + <> + Other Element + {}} + options={options} + /> + + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(options[0].label) + .should('be.visible'); + + cy.get('#other-element').click(); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /** + * Confirms autocomplete can be closed by clicking the close button + */ + it('can close autocomplete popper by clicking the close button', () => { + mount( + {}} + options={options} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .should('be.visible'); + + ui.button + .findByAttribute('title', 'Close') + .should('be.visible') + .should('be.enabled') + .click(); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + }); + + describe('Single-select', () => { + /** + * - Confirms user can select an initial option + */ + it('can select an initial option', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={undefined} + /> + ); + + cy.get('input').should( + 'have.attr', + 'placeholder', + 'this is a placeholder' + ); + cy.get('input').should('have.attr', 'value', ''); + cy.findByText('Autocomplete').should('be.visible').click(); + cy.focused().type(options[0].label); + + ui.autocompletePopper + .findByTitle(options[0].label) + .scrollIntoView() + .should('be.visible') + .click(); + + // Confirm that selection change is reflected by input field value, and that + // the autocomplete popper has been dismissed. + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /** + * - Confirms user can change selection after having selected an option + */ + it('can change the selected option', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={options[0]} + /> + ); + + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + cy.findByText('Autocomplete').should('be.visible').click(); + cy.focused().type(options[1].label); + + ui.autocompletePopper + .findByTitle(options[1].label) + .scrollIntoView() + .should('be.visible') + .click(); + + // Confirm that selection change is reflected by input field value, and that + // the autocomplete popper has been dismissed. + cy.get('input').should('have.attr', 'value', `${options[1].label}`); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /** + * - Confirms selection option can be cleared + */ + it('clears the selected option', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={options[0]} + /> + ); + + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('input').should('have.attr', 'value', ''); + + cy.get('input').should( + 'have.attr', + 'placeholder', + 'this is a placeholder' + ); + }); + + /** + * - Confirms selection cannot be cleared when clearable is disabled + */ + it('cannot clear the selected option when clearable is disabled', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={options[0]} + /> + ); + + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + cy.findByLabelText('Clear').should('not.exist'); + }); + + /** + * - Confirms selection cannot be cleared if nothing was chosen + */ + it('cannot clear selection when nothing is selected', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={undefined} + /> + ); + + cy.get('input').should('have.attr', 'value', ''); + cy.get('input').should( + 'have.attr', + 'placeholder', + 'this is a placeholder' + ); + + cy.findByLabelText('Clear').should('not.exist'); + }); + + describe('onChange', () => { + /** + * - Confirms onChange is called when option is selected + */ + it('calls `onChange` callback when initially selecting option', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByText('Autocomplete').should('be.visible').click(); + + cy.focused().type(options[0].label); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + + /** + * - Confirms `onChange` callback when option is cleared + */ + it('calls `onChange` callback when clearing selection', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + + /** + * - Confirms `onChange` callback when option is changed + */ + it('calls `onChange` callback changing selection', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByText('Autocomplete').should('be.visible').click(); + + cy.focused().type(options[0].label); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + }); + + /** + * - Confirms onBlur is called when focusing away from selection + */ + it('calls `onBlur` callback when focusing away from selection', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + <> + Other Element + {}} + options={options} + placeholder="this is a placeholder" + value={undefined} + /> + + ); + + cy.findByText('Autocomplete').should('be.visible').click(); + + cy.focused().type(options[0].label); + + ui.autocompletePopper + .findByTitle(options[0].label) + .scrollIntoView() + .should('be.visible') + .click(); + cy.get('#other-element').click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + }); + + describe('Multiselection', () => { + /** + * - Confirms multiple selections can be chosen + * - Confirms clear button clears all options + */ + it('can select multiple options and clears all selected options', () => { + // figure out how to confirm multi selections + // input value doesn't work anymore... (this feels hacky) + const MultiSelect = () => { + const [selectedOptions, setSelectedOptions] = React.useState< + Option[] + >([]); + return ( + <> +
Number of selected options: {selectedOptions.length}
+ setSelectedOptions(value)} + options={options} + value={selectedOptions} + /> + + ); + }; + + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + + ui.autocompletePopper + .findByTitle(options[0].label) + .should('be.visible') + .click(); + cy.findByText('Number of selected options: 1').should('be.visible'); + + ui.autocompletePopper + .findByTitle(options[1].label) + .should('be.visible') + .click(); + cy.findByText('Number of selected options: 2').should('be.visible'); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText('Number of selected options: 0').should('be.visible'); + }); + + /** + * - Confirms 'Select All' and 'Deselect All' work as expected + */ + it('can select all and deselect all', () => { + const MultiSelect = () => { + const [selectedOptions, setSelectedOptions] = React.useState< + Option[] + >([]); + return ( + setSelectedOptions(value)} + options={options} + value={selectedOptions} + /> + ); + }; + + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle('Select All') + .should('be.visible') + .click(); + + cy.findByLabelText('Clear').should('be.visible').should('be.enabled'); + cy.contains('Select All').should('not.exist'); + + // After selecting all elements, 'Deselect All' appears as an option + ui.autocompletePopper + .findByTitle('Deselect All') + .should('be.visible') + .click(); + + cy.findByLabelText('Clear').should('not.exist'); + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + }); + + /** + * - Confirms 'Deselect All' appears only when all options are selected (even if 'Select All' wasn't clicked) + * - Confirms 'Select All' appears if not all options have been selected + */ + it('shows Deselect All if all options are selected', () => { + const MultiSelect = () => { + const [selectedOptions, setSelectedOptions] = React.useState< + Option[] + >([]); + return ( + setSelectedOptions(value)} + options={options} + value={selectedOptions} + /> + ); + }; + + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + // select all options manually, confirm Select all is still visible if not all options selected yet + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + ui.autocompletePopper + .findByTitle('my-option-1') + .should('be.visible') + .click(); + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + ui.autocompletePopper + .findByTitle('my-option-2') + .should('be.visible') + .click(); + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + ui.autocompletePopper + .findByTitle('my-option-3') + .should('be.visible') + .click(); + + // Confirm Deselect All appears, and Select All doesn't exist anymore + ui.autocompletePopper.findByTitle('Deselect All').should('be.visible'); + cy.contains('Select All').should('not.exist'); + }); + + /** + * - Confirms popper remains open in multiselect after selecting an element + */ + it('keeps the popper open even after an element is selected', () => { + mount( + {}} + options={options} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${options[1].label}`) + .should('be.visible') + .click(); + + ui.autocompletePopper + .findByTitle(`${options[1].label}`) + .should('be.visible'); + cy.get('[data-qa-autocomplete-popper]').should('be.visible'); + }); + }); + + visualTests((mount) => { + describe('Accessibility checks', () => { + describe('Single select', () => { + it('passes aXe check when menu is closed without an item selected', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check when menu is closed with an item selected', () => { + mount( + + ); + + checkComponentA11y(); + }); + + it('passes aXe check when menu is open with an item selected', () => { + mount( + + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + checkComponentA11y(); + }); + }); + + describe('MultiSelect', () => { + it('passes aXe check when menu is closed without an item selected', () => { + mount( + + ); + + checkComponentA11y(); + }); + + it('passes aXe check when menu is closed with an item selected', () => { + mount( + + ); + + checkComponentA11y(); + }); + + it('passes aXe check when menu is open with an item selected', () => { + mount( + + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + checkComponentA11y(); + }); + }); + }); + }); + }); +}); diff --git a/packages/manager/cypress/component/poc/beta-chip.spec.tsx b/packages/manager/cypress/component/components/beta-chip.spec.tsx similarity index 99% rename from packages/manager/cypress/component/poc/beta-chip.spec.tsx rename to packages/manager/cypress/component/components/beta-chip.spec.tsx index 962e9ccf2b4..58641a6e63b 100644 --- a/packages/manager/cypress/component/poc/beta-chip.spec.tsx +++ b/packages/manager/cypress/component/components/beta-chip.spec.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { BetaChip } from 'src/components/BetaChip/BetaChip'; -import { componentTests, visualTests } from 'support/util/components'; import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests, visualTests } from 'support/util/components'; + +import { BetaChip } from 'src/components/BetaChip/BetaChip'; componentTests('BetaChip', () => { visualTests((mount) => { diff --git a/packages/manager/cypress/component/poc/region-select.spec.tsx b/packages/manager/cypress/component/components/region-select.spec.tsx similarity index 99% rename from packages/manager/cypress/component/poc/region-select.spec.tsx rename to packages/manager/cypress/component/components/region-select.spec.tsx index e15411dece2..656d5dacaf3 100644 --- a/packages/manager/cypress/component/poc/region-select.spec.tsx +++ b/packages/manager/cypress/component/components/region-select.spec.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { componentTests, visualTests } from 'support/util/components'; -import { checkComponentA11y } from 'support/util/accessibility'; -import { accountAvailabilityFactory, regionFactory } from 'src/factories'; -import { ui } from 'support/ui'; import { mockGetAccountAvailability } from 'support/intercepts/account'; +import { ui } from 'support/ui'; +import { checkComponentA11y } from 'support/util/accessibility'; 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(() => { @@ -24,10 +25,10 @@ componentTests('RegionSelect', (mount) => { mount( {}} + regions={[region]} + value={undefined} /> ); @@ -52,10 +53,10 @@ componentTests('RegionSelect', (mount) => { mount( {}} + regions={[region]} + value={undefined} /> ); @@ -81,10 +82,10 @@ componentTests('RegionSelect', (mount) => { mount( {}} + regions={[region]} + value={undefined} /> ); @@ -110,10 +111,10 @@ componentTests('RegionSelect', (mount) => { <> Other Element {}} + regions={[region]} + value={undefined} /> ); @@ -143,10 +144,10 @@ componentTests('RegionSelect', (mount) => { it('can select a region initially', () => { mount( {}} + regions={regions} + value={undefined} /> ); @@ -177,10 +178,10 @@ componentTests('RegionSelect', (mount) => { it('can change region selection', () => { mount( {}} + regions={regions} + value={regionToPreselect.id} /> ); @@ -212,10 +213,10 @@ componentTests('RegionSelect', (mount) => { it('can clear region selection', () => { mount( {}} + regions={regions} + value={regionToSelect.id} /> ); @@ -238,11 +239,11 @@ componentTests('RegionSelect', (mount) => { it('cannot clear region selection when clearable is disabled', () => { mount( {}} + regions={regions} + value={regionToSelect.id} /> ); @@ -258,10 +259,10 @@ componentTests('RegionSelect', (mount) => { it('cannot clear region selection when no region is selected', () => { mount( {}} + regions={regions} + value={undefined} /> ); @@ -275,10 +276,10 @@ componentTests('RegionSelect', (mount) => { const spyFn = createSpy(() => {}, 'changeSpy'); mount( ); @@ -299,10 +300,10 @@ componentTests('RegionSelect', (mount) => { const spyFn = createSpy(() => {}, 'changeSpy'); mount( ); @@ -343,10 +344,10 @@ componentTests('RegionSelect', (mount) => { // TODO Remove `dcGetWell` flag override when feature flag is removed from codebase. mount( {}} + regions={regions} + value={undefined} />, { dcGetWell: true, @@ -377,10 +378,10 @@ componentTests('RegionSelect', (mount) => { it('only lists regions with the specified capability', () => { mount( {}} + regions={regions} + value={undefined} /> ); @@ -406,10 +407,10 @@ componentTests('RegionSelect', (mount) => { it('lists all regions when no capability is specified', () => { mount( {}} + regions={regions} + value={undefined} /> ); @@ -436,10 +437,10 @@ componentTests('RegionSelect', (mount) => { it('passes aXe check when menu is closed without an item selected', () => { mount( {}} + regions={regions} + value={undefined} /> ); checkComponentA11y(); @@ -448,10 +449,10 @@ componentTests('RegionSelect', (mount) => { it('passes aXe check when menu is closed with an item selected', () => { mount( {}} + regions={regions} + value={selectedRegion.id} /> ); checkComponentA11y(); @@ -460,10 +461,10 @@ componentTests('RegionSelect', (mount) => { it('passes aXe check when menu is open', () => { mount( {}} + regions={regions} + value={selectedRegion.id} /> ); diff --git a/packages/manager/cypress/component/components/select.spec.tsx b/packages/manager/cypress/component/components/select.spec.tsx new file mode 100644 index 00000000000..bf49abb3415 --- /dev/null +++ b/packages/manager/cypress/component/components/select.spec.tsx @@ -0,0 +1,348 @@ +import { Box, Select, Typography } from '@linode/ui'; +import * as React from 'react'; +import { ui } from 'support/ui'; +import { createSpy } from 'support/util/components'; +import { componentTests } from 'support/util/components'; + +import type { SelectOptionType, SelectProps } from '@linode/ui'; + +const options = [ + { label: 'Option 1', value: 'option-1' }, + { label: 'Option 2', value: 'option-2' }, + { label: 'Option 3', value: 'option-3' }, +]; + +const openAutocompletePopper = () => { + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); +}; + +componentTests('Select', (mount) => { + describe('Basics', () => { + describe('Open menu', () => { + it('can open drop-down menu by clicking drop-down arrow', () => { + mount(); + + openAutocompletePopper(); + + ui.autocompletePopper + .find() + .should('be.visible') + .within(() => { + cy.contains('No options available').should('be.visible'); + }); + }); + + it('can close menu with ESC key', () => { + mount( + + ); + + openAutocompletePopper(); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .should('be.visible'); + + cy.get('#other-element').click(); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + }); + + describe('Selection', () => { + it('can select an option initially', () => { + mount(); + + cy.get('input').should('have.attr', 'placeholder', 'Select an option'); + cy.findByText('My Select').should('be.visible').click(); + cy.focused().type(options[0].label[0]); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .should('be.visible') + .click(); + + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + it('can change region selection', () => { + mount( + + ); + + cy.get('input').should('have.attr', 'value', `${options[1].label}`); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('input').should('have.attr', 'value', ''); + cy.get('input').should('have.attr', 'placeholder', 'Select an option'); + }); + + it('cannot clear region selection when clearable is disabled', () => { + mount( + ); + + cy.get('input').should('have.attr', 'value', ''); + cy.get('input').should('have.attr', 'placeholder', 'Select an option'); + + cy.findByLabelText('Clear').should('not.exist'); + }); + + it('calls `onChange` callback when region is initially selected', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + ); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + }); + }); + + describe('Creatable', () => { + it('can create a new option', () => { + mount( + setValue({ + label: newValue?.label ?? '', + value: newValue?.value.replace(' ', '-').toLowerCase() ?? '', + }) + } + textFieldProps={{ + onChange: (e) => + setValue({ + label: e.target.value, + value: e.target.value.replace(' ', '-').toLowerCase(), + }), + }} + value={value} + {...props} + /> + + + {JSON.stringify(value)} + + + + ); + }; + + it('renders the value for an existing option', () => { + mount(); + + cy.get('[data-qa-selected-value]').should('have.text', 'null'); + + options.forEach((option) => { + openAutocompletePopper(); + ui.autocompletePopper + .findByTitle(`${option.label}`) + .should('be.visible') + .click(); + + cy.get('[data-qa-selected-value]').should( + 'have.text', + `{"label":"${option.label}","value":"${option.value}"}` + ); + }); + }); + + it('renders the value for a new option', () => { + mount(); + const newOption = 'New Option'; + + cy.get('[data-qa-selected-value]').should('have.text', 'null'); + + openAutocompletePopper(); + cy.focused().type(newOption); + + ui.autocompletePopper + .find() + .within(() => { + cy.contains(`Create "${newOption}"`).should('be.visible'); + }) + .click(); + + cy.get('[data-qa-selected-value]').should( + 'have.text', + `{"label":"${newOption}","value":"${newOption + .replace(' ', '-') + .toLowerCase()}"}` + ); + }); + }); +}); diff --git a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx new file mode 100644 index 00000000000..f686299fcde --- /dev/null +++ b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx @@ -0,0 +1,604 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import '@4tw/cypress-drag-drop'; // Using this lib only for mouse drag-and-drop interactions +import * as React from 'react'; +import { ui } from 'support/ui'; +import { componentTests } from 'support/util/components'; +import { + randomItem, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; + +import { firewallRuleFactory } from 'src/factories'; +import { FirewallRulesLanding } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding'; + +import type { FirewallPolicyType, FirewallRuleType } from '@linode/api-v4'; + +interface MoveFocusedElementViaKeyboard { + direction: 'DOWN' | 'UP'; + times: number; +} + +const portPresetMap = { + '22': 'SSH', + '53': 'DNS', + '80': 'HTTP', + '443': 'HTTPS', + '3306': 'MySQL', +}; + +const mockInboundRules = Array.from({ length: 3 }, () => + firewallRuleFactory.build({ + action: 'ACCEPT', + description: randomString(), + label: randomLabel(), + ports: randomItem(Object.keys(portPresetMap)), + }) +); + +const mockOutboundRules = Array.from({ length: 3 }, () => + firewallRuleFactory.build({ + action: 'DROP', + description: randomString(), + label: randomLabel(), + ports: randomItem(Object.keys(portPresetMap)), + }) +); + +const inboundRule1 = mockInboundRules[0]; +const inboundRule2 = mockInboundRules[1]; +const inboundRule3 = mockInboundRules[2]; + +const outboundRule1 = mockOutboundRules[0]; +const outboundRule2 = mockOutboundRules[1]; +const outboundRule3 = mockOutboundRules[2]; + +const inboundAriaLabel = 'inbound Rules List'; +const outboundAriaLabel = 'outbound Rules List'; +const buttonText = 'Save Changes'; + +/** + * Returns the formatted label for the given firewall rule action. + * + * @param ruleAction + */ +const getRuleActionLabel = (ruleAction: FirewallPolicyType): string => { + return `${ruleAction.charAt(0).toUpperCase()}${ruleAction + .slice(1) + .toLowerCase()}`; +}; + +/** + * Move the focused element either up or down, N times via Keyboard. + * + * note: Cypress automatically focuses the element when you use .type() or .type(' '). + * + * @param options.direction - Direction to move the element (row) "UP" or "DOWN". + * @param options.times - Number of times to move the element. + */ +const moveFocusedElementViaKeyboard = ({ + direction, + times, +}: MoveFocusedElementViaKeyboard) => { + // `direction` is either "UP" or "DOWN" + const arrowKey = direction === 'DOWN' ? '{downarrow}' : '{uparrow}'; + + const repeatedArrowKey = arrowKey.repeat(times); + + // Focused element will receive the repeated arrow key presses + cy.focused().type(repeatedArrowKey); +}; + +/** + * Verifies that the firewall landing page correctly lists the specified inbound + * and outbound rules in the firewall table, based on the provided options. + * + * @param options.includeInbound - Boolean flag to specify whether inbound rules should be included. + * @param options.includeOutbound - Boolean flag to specify whether outbound rules should be included. + * @param options.isSmallViewport - Boolean flag to specify whether the viewport is considered small (default is false). + */ +const verifyFirewallWithRules = ({ + includeInbound, + includeOutbound, + isSmallViewport = false, +}: { + includeInbound: boolean; + includeOutbound: boolean; + isSmallViewport?: boolean; +}) => { + // Verify that the Firewall Landing page displays the "Inbound Rules" and "Outbound Rules" headers. + cy.findByText('Inbound Rules').should('be.visible'); + cy.findByText('Outbound Rules').should('be.visible'); + + const inboundRules = includeInbound ? mockInboundRules : []; + const outboundRules = includeOutbound ? mockOutboundRules : []; + + // Confirm the appropriate rules are listed with correct details. + [...inboundRules, ...outboundRules].forEach((rule) => { + cy.findByText(rule.label!) + .should('be.visible') + .closest('tr') + .within(() => { + if (isSmallViewport) { + // Column 'Protocol' is not visible for smaller screens. + cy.findByText(rule.protocol).should('not.exist'); + } else { + cy.findByText(rule.protocol).should('be.visible'); + } + + cy.findByText(rule.ports!).should('be.visible'); + cy.findByText(getRuleActionLabel(rule.action)).should('be.visible'); + }); + }); +}; + +/** + * Verifies that the rows in a table are in the expected order based on the + * provided list of rules and the specified aria-label. + * + * @param ariaLabel - The `aria-label` of the table (either inbound or outbound rule table). + * @param expectedOrder - The expected order of rules (Array of FirewallRuleType objects). + * + * @example + * // Verifies that the inbound rule table rows are in the expected order of rule1, rule2, rule3. + * verifyTableRowOrder('inbound Rules List', [rule1, rule2, rule3]); + */ +const verifyTableRowOrder = ( + ariaLabel: string, + expectedOrder: FirewallRuleType[] +) => { + cy.get(`[aria-label="${ariaLabel}"]`).within(() => { + cy.get('tbody tr').then((rows) => { + expectedOrder.forEach((rule, index) => { + expect(rows[index]).to.contain(rule.label); + }); + }); + }); +}; + +/** + * Drags a row from one position to another within a table using mouse interaction. + * + * Note: this utility uses '@4tw/cypress-drag-drop lib. + * + * @param ariaLabel - The `aria-label` of the table containing the rows. + * @param sourceRowPosition - The position (1-based index) of the row to be moved. + * @param targetRowPosition - The position (1-based index) to drop the moved row. + * + * @example + * dragRowToPositionViaMouse('inbound Rules List', 1, 2); // Moves the first row to second position + */ +const dragRowToPositionViaMouse = ( + ariaLabel: string, + sourceRowPosition: number, + targetRowPosition: number +) => { + const sourceRow = `div[aria-label="${ariaLabel}"] tbody tr:nth-child(${sourceRowPosition})`; + const targetRow = `div[aria-label="${ariaLabel}"] tbody tr:nth-child(${targetRowPosition})`; + cy.get(sourceRow).drag(targetRow); +}; + +/** + * Test scenario for moving inbound rule rows using keyboard interactions. + * + * This test verifies that the keyboard-based drag-and-drop functionality + * works as expected for inbound rules: + * - Ensuring the `Save Changes` button is initially disabled. + * - Activating the row drag mode via `Space/Enter` key. + * - Moving the rule rows up and down with arrow keys. + * - Dropping the row and verifying the updated row order. + * - Enabling the `Save Changes` button after the operation. + */ +const testMoveInboundRuleRowsViaKeyboard = () => { + // Verify 'Save Changes' button is initially disabled. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); + + // Activate keyboard drag mode using `Space/Enter` key on the first row - inboundRule1. + cy.findByText(inboundRule1.label!).should('be.visible'); + cy.findByText(inboundRule1.label!).closest('tr').type(' '); + cy.findByText(inboundRule1.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `inboundRule1` down two rows. + moveFocusedElementViaKeyboard({ direction: 'DOWN', times: 2 }); + + // Drop row with the keyboard `Space/Enter` key. + cy.focused().type(' '); + + // Verify that "inboundRule2" is in the 1st row, + // "inboundRule3" is in the 2nd row, and "inboundRule1" is in the 3rd row. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule2, + inboundRule3, + inboundRule1, + ]); + + // Activate keyboard drag mode using `Space/Enter` key on the 2nd row - inboundRule3. + cy.findByText(inboundRule3.label!).should('be.visible'); + cy.findByText(inboundRule3.label!).closest('tr').type(' '); + cy.findByText(inboundRule3.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `inboundRule3` up one row. + moveFocusedElementViaKeyboard({ direction: 'UP', times: 1 }); + + // Drop row with the keyboard `Space/Enter` key. + cy.focused().type(' '); + + // Verify that "inboundRule3" is in the 1st row, + // "inboundRule2" is in the 2nd row, and "inboundRule1" is in the 3rd row. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule3, + inboundRule2, + inboundRule1, + ]); + + // Verify 'Save Changes' button is enabled after row is moved. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'false'); +}; + +/** + * Test scenario for canceling the inbound rule drag-and-drop operation using the keyboard `Esc` key. + * + * This test checks that when the `Esc` key is pressed during a row drag operation, + * the row returns to its original position and the `Save Changes` button remains disabled. + */ +const testDiscardInboundRuleDragViaKeyboard = () => { + // Verify 'Save Changes' button is initially disabled. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); + + // Activate keyboard drag mode using `Space/Enter` key on the first row - inboundRule1. + cy.findByText(inboundRule1.label!).should('be.visible'); + cy.findByText(inboundRule1.label!).closest('tr').type(' '); + cy.findByText(inboundRule1.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `inboundRule1` down two rows. + moveFocusedElementViaKeyboard({ direction: 'DOWN', times: 2 }); + + // Cancel with the keyboard `Esc` key. + cy.focused().type('{esc}'); + + // Ensure row remains in its original position. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule1, + inboundRule2, + inboundRule3, + ]); + + // Verify 'Save Changes' button remains disabled after discarding with the keyboard `Esc` key. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); +}; + +/** + * Test scenario for moving outbound rule rows using keyboard interactions. + * + * This test verifies that the keyboard-based drag-and-drop functionality + * works as expected for outbound rules: + * - Ensuring the `Save Changes` button is initially disabled. + * - Activating the row drag mode via `Space/Enter` key. + * - Moving the rule rows up and down with arrow keys. + * - Dropping the row and verifying the updated row order. + * - Enabling the `Save Changes` button after the operation. + */ +const testMoveOutboundRulesViaKeyboard = () => { + // Verify 'Save Changes' button is initially disabled. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); + + // Activate keyboard drag mode using `Space/Enter` key on the first row - outboundRule1. + cy.findByText(outboundRule1.label!).should('be.visible'); + cy.findByText(outboundRule1.label!).closest('tr').type(' '); + cy.findByText(outboundRule1.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `outboundRule1` down two rows + moveFocusedElementViaKeyboard({ direction: 'DOWN', times: 2 }); + + // Drop row with the keyboard `Space/Enter` key + cy.focused().type(' '); + + // Verify that "outboundRule2" is in the 1st row, + // "outboundRule3" is in the 2nd row, and "outboundRule1" is in the 3rd row. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule2, + outboundRule3, + outboundRule1, + ]); + + // Activate keyboard drag mode using `Space/Enter` key on the 2nd row - outboundRule3. + cy.findByText(outboundRule3.label!).should('be.visible'); + cy.findByText(outboundRule3.label!).closest('tr').type(' '); + cy.findByText(outboundRule3.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `outboundRule3` up one row. + moveFocusedElementViaKeyboard({ direction: 'UP', times: 1 }); + + // Drop row with the keyboard `Space/Enter` key. + cy.focused().type(' '); + + // Verify that "outboundRule3" is in the 1st row, + // "outboundRule2" is in the 2nd row, and "outboundRule1" is in the 3rd row. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule3, + outboundRule2, + outboundRule1, + ]); + + // Verify 'Save Changes' button is enabled after row is moved. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'false'); +}; + +/** + * Test scenario for canceling the outbound rule drag-and-drop operation using the keyboard `Esc` key. + * + * This test checks that when the `Esc` key is pressed during a row drag operation, + * the row returns to its original position and the `Save Changes` button remains disabled. + */ +const testDiscardOutboundRuleDragViaKeyboard = () => { + // Verify 'Save Changes' button is initially disabled. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); + + // Activate keyboard drag mode using `Space/Enter` key on the first row - outboundRule1. + cy.findByText(outboundRule1.label!).should('be.visible'); + cy.findByText(outboundRule1.label!).closest('tr').type(' '); + cy.findByText(outboundRule1.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `outboundRule1` down two rows. + moveFocusedElementViaKeyboard({ direction: 'DOWN', times: 2 }); + + // Cancel with the keyboard `Esc` key. + cy.focused().type('{esc}'); + + // Ensure row remains in its original position. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule1, + outboundRule2, + outboundRule3, + ]); + + // Verify 'Save Changes' button remains disabled after discarding with the keyboard `Esc` key. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); +}; + +componentTests('Firewall Rules Table', (mount) => { + /** + * Keyboard keys used to perform interactions with rows in the Firewall Rules table: + * - Press `Space/Enter` key once to activate keyboard sensor on the selected row. + * - Use `Up/Down` arrow keys to move the row up or down. + * - Press `Space/Enter` key again to drop the focused row. + * - Press `Esc` key to discard drag and drop operation. + * + * Confirms: + * - All keyboard interactions on Firewall Rules table rows work as expected for + * both normal (no vertical scrollbar) and smaller window sizes (with vertical scrollbar). + * - `CustomKeyboardSensor` works as expected. + * - All Mouse interactions on Firewall Rules table rows work as expected. + */ + describe('Keyboard and Mouse Drag and Drop Interactions', () => { + describe('Normal window (no vertical scrollbar)', () => { + beforeEach(() => { + cy.viewport(1536, 960); + }); + + describe('Inbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: true, + includeOutbound: false, + }); + }); + + it('should move Inbound rule rows using keyboard interaction', () => { + testMoveInboundRuleRowsViaKeyboard(); + }); + + it('should cancel the Inbound rules drag operation with the keyboard `Esc` key', () => { + testDiscardInboundRuleDragViaKeyboard(); + }); + + it('should move Inbound rules rows using mouse interaction', () => { + // Drag the 1st row rule to 2nd row position. + dragRowToPositionViaMouse(inboundAriaLabel, 1, 2); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule2, + inboundRule1, + inboundRule3, + ]); + + // Drag the 3rd row rule to 2nd row position. + dragRowToPositionViaMouse(inboundAriaLabel, 3, 2); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule2, + inboundRule3, + inboundRule1, + ]); + + // Drag the 3rd row rule to 1st position. + dragRowToPositionViaMouse(inboundAriaLabel, 3, 1); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule1, + inboundRule2, + inboundRule3, + ]); + }); + }); + + describe('Outbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: false, + includeOutbound: true, + }); + }); + + it('should move Outbound rule rows using keyboard interaction', () => { + testMoveOutboundRulesViaKeyboard(); + }); + + it('should cancel the Outbound rules drag operation with the keyboard `Esc` key', () => { + testDiscardOutboundRuleDragViaKeyboard(); + }); + + it('should move Outbound rules rows using mouse interaction', () => { + // Drag the 1st row rule to 2nd row position. + dragRowToPositionViaMouse(outboundAriaLabel, 1, 2); + + // Verify the labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule2, + outboundRule1, + outboundRule3, + ]); + + // Drag the 3rd row rule to 2nd row position. + dragRowToPositionViaMouse(outboundAriaLabel, 3, 2); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule2, + outboundRule3, + outboundRule1, + ]); + + // Drag the 3rd row rule to 1st position. + dragRowToPositionViaMouse(outboundAriaLabel, 3, 1); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule1, + outboundRule2, + outboundRule3, + ]); + }); + }); + }); + + describe('Window with vertical scrollbar', () => { + beforeEach(() => { + // Browser window with vertical scroll bar enabled (smaller screens). + cy.viewport(800, 400); + cy.window().should('have.property', 'innerWidth', 800); + cy.window().should('have.property', 'innerHeight', 400); + }); + + describe('Inbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: true, + includeOutbound: false, + isSmallViewport: true, + }); + }); + + it('should move Inbound rule rows using keyboard interaction', () => { + testMoveInboundRuleRowsViaKeyboard(); + }); + + it('should cancel the Inbound rules drag operation with the keyboard `Esc` key', () => { + testDiscardInboundRuleDragViaKeyboard(); + }); + }); + + describe('Outbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: false, + includeOutbound: true, + isSmallViewport: true, + }); + }); + + it('should move Outbound rule rows using keyboard interaction', () => { + testMoveOutboundRulesViaKeyboard(); + }); + + it('should cancel the Outbound rules drag operation with the keyboard `Esc` key', () => { + testDiscardOutboundRuleDragViaKeyboard(); + }); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index 40a4b5761ef..c984bf30ec1 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -12,6 +12,7 @@ import { import { cancellationDataLossWarning, cancellationPaymentErrorMessage, + cancellationDialogTitle, } from 'support/constants/account'; import { CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, @@ -39,7 +40,7 @@ describe('Account cancellation', () => { it('users can cancel account', () => { const mockAccount = accountFactory.build(); const mockProfile = profileFactory.build({ - username: 'mock-user', + email: 'mock-user@linode.com', restricted: false, }); const mockCancellationResponse: CancelAccount = { @@ -74,9 +75,7 @@ describe('Account cancellation', () => { }); ui.dialog - .findByTitle( - 'Are you sure you want to close your cloud computing services account?' - ) + .findByTitle(cancellationDialogTitle) .should('be.visible') .within(() => { cy.findByText(cancellationDataLossWarning, { exact: false }).should( @@ -89,14 +88,30 @@ describe('Account cancellation', () => { .should('be.visible') .should('be.disabled'); - // Enter username, confirm that submit button becomes enabled, and click + // Verify checkboxes are present with correct labels + cy.get('[data-qa-checkbox="deleteAccountServices"]') + .should('be.visible') + .should('not.be.checked'); + + cy.get('[data-qa-checkbox="deleteAccountUsers"]') + .should('be.visible') + .should('not.be.checked'); + + // Check both boxes but verify submit remains disabled without email + cy.get('[data-qa-checkbox="deleteAccountServices"]').click(); + cy.get('[data-qa-checkbox="deleteAccountUsers"]').click(); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.disabled'); + + // Enter email, confirm that submit button becomes enabled, and click // the submit button. - cy.findByLabelText( - `Please enter your Username (${mockProfile.username}) to confirm.` - ) + cy.findByLabelText(`Enter your email address (${mockProfile.email})`) .should('be.visible') .should('be.enabled') - .type(mockProfile.username); + .type(mockProfile.email); ui.button .findByTitle('Close Account') @@ -151,7 +166,7 @@ describe('Account cancellation', () => { it('restricted users cannot cancel account', () => { const mockAccount = accountFactory.build(); const mockProfile = profileFactory.build({ - username: 'mock-restricted-user', + email: 'mock-user@linode.com', restricted: true, }); @@ -176,17 +191,22 @@ describe('Account cancellation', () => { // Fill out cancellation dialog and attempt submission. ui.dialog - .findByTitle( - 'Are you sure you want to close your cloud computing services account?' - ) + .findByTitle(cancellationDialogTitle) .should('be.visible') .within(() => { - cy.findByLabelText( - `Please enter your Username (${mockProfile.username}) to confirm.` - ) + // Check both boxes but verify submit remains disabled without email + cy.get('[data-qa-checkbox="deleteAccountServices"]').click(); + cy.get('[data-qa-checkbox="deleteAccountUsers"]').click(); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.disabled'); + + cy.findByLabelText(`Enter your email address (${mockProfile.email})`) .should('be.visible') .should('be.enabled') - .type(mockProfile.username); + .type(mockProfile.email); ui.button .findByTitle('Close Account') @@ -208,7 +228,7 @@ describe('Parent/Child account cancellation', () => { it('disables the "Close Account" button for a child user', () => { const mockAccount = accountFactory.build({}); const mockProfile = profileFactory.build({ - username: 'mock-child-user', + email: 'mock-user@linode.com', restricted: false, user_type: 'child', }); @@ -242,7 +262,7 @@ describe('Parent/Child account cancellation', () => { it('disables "Close Account" button for proxy users', () => { const mockAccount = accountFactory.build(); const mockProfile = profileFactory.build({ - username: 'proxy-user', + email: 'mock-user@linode.com', restricted: false, user_type: 'proxy', }); @@ -276,7 +296,7 @@ describe('Parent/Child account cancellation', () => { it('disables "Close Account" button for parent users', () => { const mockAccount = accountFactory.build(); const mockProfile = profileFactory.build({ - username: 'parent-user', + email: 'mock-user@linode.com', restricted: false, user_type: 'parent', }); @@ -310,7 +330,7 @@ describe('Parent/Child account cancellation', () => { it('allows a default account with no active child accounts to close the account', () => { const mockAccount = accountFactory.build(); const mockProfile = profileFactory.build({ - username: 'default-user', + email: 'mock-user@linode.com', restricted: false, user_type: 'default', }); @@ -346,9 +366,7 @@ describe('Parent/Child account cancellation', () => { }); ui.dialog - .findByTitle( - 'Are you sure you want to close your cloud computing services account?' - ) + .findByTitle(cancellationDialogTitle) .should('be.visible') .within(() => { cy.findByText(cancellationDataLossWarning, { exact: false }).should( @@ -361,14 +379,21 @@ describe('Parent/Child account cancellation', () => { .should('be.visible') .should('be.disabled'); - // Enter username, confirm that submit button becomes enabled, and click + // Check both boxes but verify submit remains disabled without email + cy.get('[data-qa-checkbox="deleteAccountServices"]').click(); + cy.get('[data-qa-checkbox="deleteAccountUsers"]').click(); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.disabled'); + + // Enter email, confirm that submit button becomes enabled, and click // the submit button. - cy.findByLabelText( - `Please enter your Username (${mockProfile.username}) to confirm.` - ) + cy.findByLabelText(`Enter your email address (${mockProfile.email})`) .should('be.visible') .should('be.enabled') - .type(mockProfile.username); + .type(mockProfile.email); ui.button .findByTitle('Close Account') diff --git a/packages/manager/cypress/e2e/core/account/account-logout.spec.ts b/packages/manager/cypress/e2e/core/account/account-logout.spec.ts index 550c4b39c98..8b3eac4866a 100644 --- a/packages/manager/cypress/e2e/core/account/account-logout.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-logout.spec.ts @@ -1,4 +1,4 @@ -import { LOGIN_ROOT } from 'src/constants'; +import { loginBaseUrl } from 'support/constants/login'; import { interceptGetAccount } from 'support/intercepts/account'; import { ui } from 'support/ui'; @@ -26,9 +26,9 @@ describe('Logout Test', () => { cy.findByText('Log Out').should('be.visible').click(); }); // Upon clicking "Log Out", the user is redirected to the login endpoint at /login - cy.url().should('equal', `${LOGIN_ROOT}/login`); + cy.url().should('equal', `${loginBaseUrl}/login`); // Using cy.visit to navigate back to Cloud results in another redirect to the login page cy.visit('/'); - cy.url().should('startWith', `${LOGIN_ROOT}/login`); + cy.url().should('startWith', `${loginBaseUrl}/login`); }); }); diff --git a/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts b/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts new file mode 100644 index 00000000000..148d8c270bd --- /dev/null +++ b/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts @@ -0,0 +1,36 @@ +import { accountFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + +const creditCardExpiredBannerNotice = + 'Your credit card has expired! Please update your payment details.'; + +describe('Credit Card Expired Banner', () => { + beforeEach(() => { + mockGetUserPreferences({ dismissed_notifications: {} }); + }); + + it('appears when the expiration date is in the past', () => { + mockGetAccount( + accountFactory.build({ credit_card: { expiry: '01/2000' } }) + ).as('getAccount'); + cy.visitWithLogin('/'); + cy.wait('@getAccount'); + cy.findByText(creditCardExpiredBannerNotice).should('be.visible'); + ui.button.findByTitle('Update Card').should('be.visible').click(); + + // clicking on the link navigates to /account/billing + cy.url().should('endWith', '/account/billing'); + }); + + it('does not appear when the expiration date is in the future', () => { + mockGetAccount( + accountFactory.build({ credit_card: { expiry: '01/2999' } }) + ).as('getAccount'); + cy.visitWithLogin('/account/billing'); + cy.wait('@getAccount'); + cy.findByText('Payment Methods').should('be.visible'); + cy.findByText(creditCardExpiredBannerNotice).should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 4780133b78f..4a1f57eeb9b 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -329,11 +329,14 @@ describe('Billing Activity Feed', () => { // Time zones against which to verify invoice and payment dates. const timeZonesList = [ { key: 'America/New_York', human: 'Eastern Time - New York' }, - { key: 'GMT', human: 'Coordinated Universal Time' }, + { key: 'UTC', human: 'Coordinated Universal Time' }, { key: 'Asia/Hong_Kong', human: 'Hong Kong Standard Time' }, ]; - const mockProfile = profileFactory.build(); + const mockProfile = profileFactory.build({ + timezone: 'Pacific/Honolulu', + }); + const mockInvoice = invoiceFactory.build({ date: DateTime.now().minus({ days: 2 }).toISO(), }); @@ -349,11 +352,16 @@ describe('Billing Activity Feed', () => { cy.visitWithLogin('/profile/display'); cy.wait('@getProfile'); + // Verify the user's initial timezone is selected by default + cy.findByLabelText('Timezone') + .should('be.visible') + .should('contain.value', 'Hawaii-Aleutian Standard Time'); + // Iterate through each timezone and confirm that payment and invoice dates // reflect each timezone. timeZonesList.forEach((timezone) => { const timezoneId = timezone.key; - const humanReadable = timezone.human; + const timezoneLabel = timezone.human; mockUpdateProfile({ ...mockProfile, @@ -367,7 +375,7 @@ describe('Billing Activity Feed', () => { cy.findByText('Timezone') .should('be.visible') .click() - .type(`${humanReadable}{enter}`); + .type(`${timezoneLabel}{enter}`); ui.button .findByTitle('Update Timezone') @@ -377,6 +385,11 @@ describe('Billing Activity Feed', () => { cy.wait('@updateProfile'); + // Verify the new timezone remains selected after clicking "Update Timezone" + cy.findByLabelText('Timezone') + .should('be.visible') + .should('contain.value', timezoneLabel); + // Navigate back to Billing & Contact Information page to confirm that // invoice and payment data correctly reflects updated timezone. navigateToBilling(); 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 f2fbcdc66a7..0b1fa8258d7 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 @@ -35,6 +35,7 @@ import { } from 'support/intercepts/databases'; import { Database } from '@linode/api-v4'; import { mockGetAccount } from 'support/intercepts/account'; +import { Flags } from 'src/featureFlags'; /** * Verifies the presence and values of specific properties within the aclpPreference object @@ -44,6 +45,24 @@ import { mockGetAccount } from 'support/intercepts/account'; * @param requestPayload - The payload received from the request, containing the aclpPreference object. * @param expectedValues - An object containing the expected values for properties to validate against the requestPayload. */ + +const flags: Partial = { + aclp: { enabled: true, beta: true }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + supportedRegionIds: 'us-ord', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + supportedRegionIds: 'us-ord', + }, + ], +}; const { metrics, id, @@ -67,21 +86,18 @@ const dashboard = dashboardFactory.build({ }), }); -const metricDefinitions = { - data: metrics.map(({ title, name, unit }) => - dashboardMetricFactory.build({ - label: title, - metric: name, - unit, - }) - ), -}; +const metricDefinitions = metrics.map(({ title, name, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); const mockRegion = regionFactory.build({ - capabilities: ['Linodes'], + capabilities: ['Managed Databases'], id: 'us-ord', label: 'Chicago, IL', - country: 'us', }); const databaseMock: Database = databaseFactory.build({ @@ -97,9 +113,7 @@ const mockAccount = accountFactory.build(); describe('Tests for API error handling', () => { beforeEach(() => { - mockAppendFeatureFlags({ - aclp: { beta: true, enabled: true }, - }); + mockAppendFeatureFlags(flags); mockGetAccount(mockAccount); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); 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 1c63c435993..5bed5edc465 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 @@ -27,7 +27,6 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; -import { extendRegion } from 'support/util/regions'; import { CloudPulseMetricsResponse, Database } from '@linode/api-v4'; import { Interception } from 'cypress/types/net-stubbing'; import { generateRandomMetricsData } from 'support/util/cloudpulse'; @@ -49,14 +48,29 @@ import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags: Partial = { aclp: { enabled: true, beta: true } }; +const flags: Partial = { + aclp: { enabled: true, beta: true }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + supportedRegionIds: '', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + supportedRegionIds: 'us-ord', + }, + ], +}; const { metrics, id, serviceType, dashboardName, - region, engine, clusterName, nodeType, @@ -75,15 +89,13 @@ const dashboard = dashboardFactory.build({ }), }); -const metricDefinitions = { - data: metrics.map(({ title, name, unit }) => - dashboardMetricFactory.build({ - label: title, - metric: name, - unit, - }) - ), -}; +const metricDefinitions = metrics.map(({ title, name, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); const mockLinode = linodeFactory.build({ label: clusterName, @@ -91,14 +103,18 @@ const mockLinode = linodeFactory.build({ }); const mockAccount = accountFactory.build(); -const mockRegion = extendRegion( - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-ord', - label: 'Chicago, IL', - country: 'us', - }) -); + +const mockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-ord', + label: 'Chicago, IL', +}); + +const extendedMockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-east', + label: 'Newark,NL', +}); const metricsAPIResponsePayload = cloudPulseMetricsResponseFactory.build({ data: generateRandomMetricsData(timeDurationToSelect, '5 min'), }); @@ -151,9 +167,9 @@ const getWidgetLegendRowValuesFromResponse = ( }; const databaseMock: Database = databaseFactory.build({ - label: widgetDetails.dbaas.clusterName, - type: widgetDetails.dbaas.engine, - region: widgetDetails.dbaas.region, + label: clusterName, + type: engine, + region: mockRegion.label, version: '1', status: 'provisioning', cluster_size: 1, @@ -177,7 +193,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { mockCreateCloudPulseMetrics(serviceType, metricsAPIResponsePayload).as( 'getMetrics' ); - mockGetRegions([mockRegion]); + mockGetRegions([mockRegion, extendedMockRegion]); mockGetUserPreferences({}); mockGetDatabases([databaseMock]).as('getDatabases'); @@ -191,35 +207,60 @@ describe('Integration Tests for DBaaS Dashboard ', () => { ui.autocomplete .findByLabel('Dashboard') .should('be.visible') - .type(`${dashboardName}{enter}`) - .should('be.visible'); + .type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('be.visible') + .click(); // Select a time duration from the autocomplete input. ui.autocomplete .findByLabel('Time Range') .should('be.visible') - .type(`${timeDurationToSelect}{enter}`) - .should('be.visible'); + .type(timeDurationToSelect); + + ui.autocompletePopper + .findByTitle(timeDurationToSelect) + .should('be.visible') + .click(); - //Select a Engine from the autocomplete input. + //Select a Database Engine from the autocomplete input. ui.autocomplete .findByLabel('Database Engine') .should('be.visible') - .type(`${engine}{enter}`) - .should('be.visible'); + .type(engine); + + ui.autocompletePopper.findByTitle(engine).should('be.visible').click(); + + // Select a region from the dropdown. + ui.regionSelect.find().click(); + + ui.regionSelect.find().type(extendedMockRegion.label); - // Select a region from the dropdown. - ui.regionSelect.find().click().type(`${region}{enter}`); + // Since DBaaS does not support this region, we expect it to not be in the dropdown. - // Select a resource from the autocomplete input. + ui.autocompletePopper.find().within(() => { + cy.findByText( + `${extendedMockRegion.label} (${extendedMockRegion.id})` + ).should('not.exist'); + }); + + ui.regionSelect.find().click().clear(); + ui.regionSelect + .findItemByRegionId(mockRegion.id, [mockRegion]) + .should('be.visible') + .click(); + + // Select a resource (Database Clusters) from the autocomplete input. ui.autocomplete .findByLabel('Database Clusters') .should('be.visible') - .type(`${clusterName}{enter}`) - .click(); - cy.findByText(clusterName).should('be.visible'); + .type(clusterName); + + ui.autocompletePopper.findByTitle(clusterName).should('be.visible').click(); - //Select a Node from the autocomplete input. + // Select a Node from the autocomplete input. ui.autocomplete .findByLabel('Node Type') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 303748e957a..70a6ef1c615 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 @@ -26,7 +26,6 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; -import { extendRegion } from 'support/util/regions'; import { CloudPulseMetricsResponse } from '@linode/api-v4'; import { generateRandomMetricsData } from 'support/util/cloudpulse'; import { Interception } from 'cypress/types/net-stubbing'; @@ -46,7 +45,23 @@ import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags: Partial = { aclp: { enabled: true, beta: true } }; +const flags: Partial = { + aclp: { enabled: true, beta: true }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + supportedRegionIds: 'us-ord', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + supportedRegionIds: '', + }, + ], +}; const { metrics, id, @@ -69,15 +84,13 @@ const dashboard = dashboardFactory.build({ }), }); -const metricDefinitions = { - data: metrics.map(({ title, name, unit }) => - dashboardMetricFactory.build({ - label: title, - metric: name, - unit, - }) - ), -}; +const metricDefinitions = metrics.map(({ title, name, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); const mockLinode = linodeFactory.build({ label: resource, @@ -85,14 +98,18 @@ const mockLinode = linodeFactory.build({ }); const mockAccount = accountFactory.build(); -const mockRegion = extendRegion( - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-ord', - label: 'Chicago, IL', - country: 'us', - }) -); + +const mockRegion = regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-ord', + label: 'Chicago, IL', +}); + +const extendedMockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-east', + label: 'Newark,NL', +}); const metricsAPIResponsePayload = cloudPulseMetricsResponseFactory.build({ data: generateRandomMetricsData(timeDurationToSelect, '5 min'), }); @@ -170,18 +187,41 @@ describe('Integration Tests for Linode Dashboard ', () => { ui.autocomplete .findByLabel('Dashboard') .should('be.visible') - .type(`${dashboardName}{enter}`) - .should('be.visible'); + .type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('be.visible') + .click(); // Select a time duration from the autocomplete input. ui.autocomplete .findByLabel('Time Range') .should('be.visible') - .type(`${timeDurationToSelect}{enter}`) - .should('be.visible'); + .type(timeDurationToSelect); + + ui.autocompletePopper + .findByTitle(timeDurationToSelect) + .should('be.visible') + .click(); + + ui.regionSelect.find().click(); + + // Select a region from the dropdown. + ui.regionSelect.find().click(); + + ui.regionSelect.find().type(extendedMockRegion.label); + + // Since Linode does not support this region, we expect it to not be in the dropdown. + + ui.autocompletePopper.find().within(() => { + cy.findByText( + `${extendedMockRegion.label} (${extendedMockRegion.id})` + ).should('not.exist'); + }); // Select a region from the dropdown. - ui.regionSelect.find().click().type(`${region}{enter}`); + ui.regionSelect.find().click().clear().type(`${region}{enter}`); // Select a resource from the autocomplete input. ui.autocomplete @@ -191,6 +231,7 @@ describe('Integration Tests for Linode Dashboard ', () => { .click(); cy.findByText(resource).should('be.visible'); + // Wait for all metrics query requests to resolve. cy.wait(['@getMetrics', '@getMetrics', '@getMetrics', '@getMetrics']); }); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts index 4e9b28fb0b9..f379f8c35a5 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts @@ -60,7 +60,7 @@ describe('Clone a Domain', () => { .closest('tr') .within(() => { ui.actionMenu - .findByTitle(`Action menu for Domain ${domain}`) + .findByTitle(`Action menu for Domain ${domain.domain}`) .should('be.visible') .click(); }); @@ -84,7 +84,7 @@ describe('Clone a Domain', () => { .closest('tr') .within(() => { ui.actionMenu - .findByTitle(`Action menu for Domain ${domain}`) + .findByTitle(`Action menu for Domain ${domain.domain}`) .should('be.visible') .click(); }); @@ -118,7 +118,7 @@ describe('Clone a Domain', () => { .click(); }); // After cloning a Domain, the user is redirected to the new Domain's details page - cy.url().should('endWith', 'domains'); + cy.url().should('match', /\/domains\/\d+$/); // Confirm that domain is cloned and cloned domains contain the same records as the original Domain. cy.visitWithLogin('/domains'); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts index 950719ce399..22fd28b798a 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts @@ -2,22 +2,83 @@ import { authenticate } from 'support/api/authentication'; import { createDomain } from 'support/api/domains'; import { interceptCreateDomainRecord } from 'support/intercepts/domains'; import { createDomainRecords } from 'support/constants/domains'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; + +const createCaaRecord = ( + name: string, + tag: string, + value: string, + ttl: string +) => { + cy.findByText('Add a CAA Record').click(); + + // Fill in the form fields + cy.findByLabelText('Name').type(name); + + ui.autocomplete.findByLabel('Tag').click(); + ui.autocompletePopper.findByTitle(tag).click(); + + cy.findByLabelText('Value').type(value); + + ui.autocomplete.findByLabel('TTL').click(); + ui.autocompletePopper.findByTitle(ttl).click(); + + // Save the record + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); +}; + +// Reusable function to edit a CAA record +const editCaaRecord = (name: string, newValue: string) => { + ui.actionMenu + .findByTitle(`Action menu for Record ${name}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + + // Edit the value field + cy.findByLabelText('Value').clear().type(newValue); + ui.button.findByTitle('Save').click(); +}; + +// Reusable function to verify record details in the table +const verifyRecordInTable = ( + name: string, + tag: string, + value: string, + ttl: string +) => { + cy.get('[aria-label="List of Domains CAA Record"]') // Target table by aria-label + .should('contain', name) + .and('contain', tag) + .and('contain', value) + .and('contain', ttl); +}; authenticate(); + +before(() => { + cleanUp('domains'); +}); + beforeEach(() => { cy.tag('method:e2e'); + createDomain().then((domain) => { + // intercept create API record request + interceptCreateDomainRecord().as('apiCreateRecord'); + const url = `/domains/${domain.id}`; + cy.visitWithLogin(url); + cy.url().should('contain', url); + }); }); describe('Creates Domains records with Form', () => { it('Adds domain records to a newly created Domain', () => { - createDomain().then((domain) => { - // intercept create api record request - interceptCreateDomainRecord().as('apiCreateRecord'); - const url = `/domains/${domain.id}`; - cy.visitWithLogin(url); - cy.url().should('contain', url); - }); - createDomainRecords().forEach((rec) => { cy.findByText(rec.name).click(); rec.fields.forEach((field) => { @@ -36,3 +97,37 @@ describe('Creates Domains records with Form', () => { }); }); }); + +describe('Tests for Editable Domain CAA Records', () => { + beforeEach(() => { + // Create the initial record with a valid email + createCaaRecord( + 'securitytest', + 'iodef', + 'mailto:security@example.com', + '5 minutes' + ); + + // Verify the initial record is in the table + verifyRecordInTable( + 'securitytest', + 'iodef', + 'mailto:security@example.com', + '5 minutes' + ); + }); + + it('Validates that "iodef" domain records can be edited with valid record', () => { + // Edit the record with a valid email and verify the updated record + editCaaRecord('securitytest', 'mailto:secdef@example.com'); + cy.get('table').should('contain', 'mailto:secdef@example.com'); + }); + + it('Validates that "iodef" domain records returns error with invalid record', () => { + // Edit the record with invalid email and validate form validation + editCaaRecord('securitytest', 'invalid-email-format'); + cy.get('p[role="alert"][data-qa-textfield-error-text="Value"]') + .should('exist') + .and('have.text', 'You have entered an invalid target'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts index 4e9223cca14..0e4710ec621 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts @@ -33,7 +33,7 @@ describe('Delete a Domain', () => { .closest('tr') .within(() => { ui.actionMenu - .findByTitle(`Action menu for Domain ${domain}`) + .findByTitle(`Action menu for Domain ${domain.domain}`) .should('be.visible') .click(); }); @@ -57,7 +57,7 @@ describe('Delete a Domain', () => { .closest('tr') .within(() => { ui.actionMenu - .findByTitle(`Action menu for Domain ${domain}`) + .findByTitle(`Action menu for Domain ${domain.domain}`) .should('be.visible') .click(); }); diff --git a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts index 79c7ad08d48..a7c3d3a465a 100644 --- a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts +++ b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts @@ -1,5 +1,5 @@ import { mockApiRequestWithError } from 'support/intercepts/general'; -import { LOGIN_ROOT } from 'src/constants'; +import { loginBaseUrl } from 'support/constants/login'; describe('account login redirect', () => { /** @@ -14,7 +14,7 @@ describe('account login redirect', () => { cy.visitWithLogin('/linodes/create'); - cy.url().should('contain', `${LOGIN_ROOT}/login?`, { exact: false }); + cy.url().should('contain', `${loginBaseUrl}/login?`, { exact: false }); cy.findByText('Please log in to continue.').should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index 2b9c9d017d4..06cf3fa0e47 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -1,46 +1,9 @@ -import type { Linode, Region } from '@linode/api-v4'; -import { accountFactory, linodeFactory, regionFactory } from 'src/factories'; +import type { Linode } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomPhrase } from 'support/util/random'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { - mockGetLinodeDetails, - mockGetLinodes, -} from 'support/intercepts/linodes'; - -const mockRegions: Region[] = [ - regionFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - id: 'us-east', - label: 'Newark, NJ', - site_type: 'core', - }), - regionFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - id: 'us-den-1', - label: 'Distributed - Denver, CO', - site_type: 'distributed', - }), -]; - -const mockLinodes: Linode[] = [ - linodeFactory.build({ - label: 'core-region-linode', - region: mockRegions[0].id, - }), - linodeFactory.build({ - label: 'distributed-region-linode', - region: mockRegions[1].id, - }), -]; - -const DISK_ENCRYPTION_IMAGES_CAVEAT_COPY = - 'Virtual Machine Images are not encrypted.'; authenticate(); describe('create image (e2e)', () => { @@ -53,9 +16,9 @@ describe('create image (e2e)', () => { const label = randomLabel(); const description = randomPhrase(); - // When Alpine 3.19 becomes deprecated, we will have to update these values for the test to pass. - const image = 'linode/alpine3.19'; - const disk = 'Alpine 3.19 Disk'; + // When Alpine 3.20 becomes deprecated, we will have to update these values for the test to pass. + const image = 'linode/alpine3.20'; + const disk = 'Alpine 3.20 Disk'; cy.defer( () => createTestLinode({ image }, { waitForDisks: true }), @@ -123,116 +86,4 @@ describe('create image (e2e)', () => { }); }); }); - - it('displays notice informing user that Images are not encrypted, provided the LDE feature is enabled and the selected linode is not in a distributed region', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: true, - }).as('getFeatureFlags'); - - // Mock responses - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions).as('getRegions'); - mockGetLinodes(mockLinodes).as('getLinodes'); - - // intercept request - cy.visitWithLogin('/images/create'); - cy.wait(['@getFeatureFlags', '@getAccount', '@getLinodes', '@getRegions']); - - // Find the Linode select and open it - cy.findByLabelText('Linode') - .should('be.visible') - .should('be.enabled') - .should('have.attr', 'placeholder', 'Select a Linode') - .click(); - - // Select the Linode - ui.autocompletePopper - .findByTitle(mockLinodes[0].label) - .should('be.visible') - .should('be.enabled') - .click(); - - // Check if notice is visible - cy.findByText(DISK_ENCRYPTION_IMAGES_CAVEAT_COPY).should('be.visible'); - }); - - it('does not display a notice informing user that Images are not encrypted if the LDE feature is disabled', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: false, - }).as('getFeatureFlags'); - - // Mock responses - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions).as('getRegions'); - mockGetLinodes(mockLinodes).as('getLinodes'); - - // intercept request - cy.visitWithLogin('/images/create'); - cy.wait(['@getFeatureFlags', '@getAccount', '@getLinodes', '@getRegions']); - - // Find the Linode select and open it - cy.findByLabelText('Linode') - .should('be.visible') - .should('be.enabled') - .should('have.attr', 'placeholder', 'Select a Linode') - .click(); - - // Select the Linode - ui.autocompletePopper - .findByTitle(mockLinodes[0].label) - .should('be.visible') - .should('be.enabled') - .click(); - - // Check if notice is visible - cy.findByText(DISK_ENCRYPTION_IMAGES_CAVEAT_COPY).should('not.exist'); - }); - - it('does not display a notice informing user that Images are not encrypted if the selected linode is in a distributed region', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: true, - }).as('getFeatureFlags'); - - // Mock responses - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions).as('getRegions'); - mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetLinodeDetails(mockLinodes[1].id, mockLinodes[1]); - - // intercept request - cy.visitWithLogin('/images/create'); - cy.wait(['@getFeatureFlags', '@getAccount', '@getRegions', '@getLinodes']); - - // Find the Linode select and open it - cy.findByLabelText('Linode') - .should('be.visible') - .should('be.enabled') - .should('have.attr', 'placeholder', 'Select a Linode') - .click(); - - // Select the Linode - ui.autocompletePopper - .findByTitle(mockLinodes[1].label) - .should('be.visible') - .should('be.enabled') - .click(); - - // Check if notice is visible - cy.findByText(DISK_ENCRYPTION_IMAGES_CAVEAT_COPY).should('not.exist'); - }); }); diff --git a/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts new file mode 100644 index 00000000000..5154138d6f8 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts @@ -0,0 +1,136 @@ +import { imageFactory } from 'src/factories'; +import { ui } from 'support/ui'; +import { mockGetAllImages } from 'support/intercepts/images'; +import { profileFactory } from 'src/factories'; +import { randomLabel } from 'support/util/random'; +import { grantsFactory } from 'src/factories'; +import { accountUserFactory } from 'src/factories'; +import { mockGetUser } from 'support/intercepts/account'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; +import { Image } from '@linode/api-v4'; + +function checkActionMenu(tableAlias: string, mockImages: any[]) { + mockImages.forEach((image) => { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', image.label) + .then(($row) => { + // If the row contains the label, proceed with clicking the action menu + const actionButton = $row.find( + `button[aria-label="Action menu for Image ${image.label}"]` + ); + if (actionButton) { + cy.wrap(actionButton).click(); + + // Check that the item with text 'Deploy to New Linode' is active + cy.get('ul[role="menu"]') + .contains('Deploy to New Linode') + .should('be.visible') + .and('be.enabled'); + + // Check that all other items are disabled + cy.get('ul[role="menu"]') + .find('li') + .not(':contains("Deploy to New Linode")') + .each(($li) => { + cy.wrap($li).should('be.visible').and('be.disabled'); + }); + + // Close the action menu by clicking on Custom Image Title of the screen + cy.get('body').click(0, 0); + } + }); + }); +} + +describe('image landing checks for non-empty state with restricted user', () => { + beforeEach(() => { + const mockImages: Image[] = new Array(3).fill(null).map( + (_item: null, index: number): Image => { + return imageFactory.build({ + label: `Image ${index}`, + tags: [index % 2 == 0 ? 'even' : 'odd', 'nums'], + }); + } + ); + + // Mock setup to display the Image landing page in an non-empty state + mockGetAllImages(mockImages).as('getImages'); + + // Alias the mockImages array + cy.wrap(mockImages).as('mockImages'); + }); + + it('checks restricted user with read access has no access to create image and can see existing images', () => { + // Mock setup for user profile, account user, and user grants with restricted permissions, + const mockProfile = profileFactory.build({ + username: randomLabel(), + restricted: true, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + restricted: true, + user_type: 'default', + }); + + const mockGrants = grantsFactory.build({ + global: { + add_images: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + + // Login and wait for application to load + cy.visitWithLogin('/images'); + cy.wait('@getImages'); + cy.url().should('endWith', '/images'); + + cy.contains('h3', 'Custom Images') + .closest('div[data-qa-paper="true"]') + .find('[role="table"]') + .should('exist') + .as('customImageTable'); + + cy.contains('h3', 'Recovery Images') + .closest('div[data-qa-paper="true"]') + .find('[role="table"]') + .should('exist') + .as('recoveryImageTable'); + + // Assert that Create Image button is visible and disabled + ui.button + .findByTitle('Create Image') + .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 Images. Please contact your account administrator to request the necessary permissions." + ) + .should('be.visible'); + + cy.get('@mockImages').then((mockImages) => { + // Assert that the correct number of Image entries are present in the customImageTable + cy.get('@customImageTable') + .find('tbody tr') + .should('have.length', mockImages.length); + + // Assert that the correct number of Image entries are present in the recoveryImageTable + cy.get('@recoveryImageTable') + .find('tbody tr') + .should('have.length', mockImages.length); + + checkActionMenu('@customImageTable', mockImages); // For the custom image table + checkActionMenu('@recoveryImageTable', mockImages); // For the recovery image table + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 929ed932684..582af41ebc1 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -320,12 +320,11 @@ describe('machine image', () => { const label = randomLabel(); const status = 'failed'; const message = 'Upload window expired'; - const expiredDate = DateTime.local().minus({ days: 1 }).toISO(); uploadImage(label); cy.wait('@imageUpload').then((xhr) => { const imageId = xhr.response?.body.image.id; assertProcessing(label, imageId); - eventIntercept(label, imageId, status, message, expiredDate); + eventIntercept(label, imageId, status, message); cy.wait('@getEvent'); assertFailed(label, imageId, message); }); diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts index 8da716b8d33..69a637ff321 100644 --- a/packages/manager/cypress/e2e/core/images/search-images.spec.ts +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -24,7 +24,7 @@ describe('Search Images', () => { cy.defer( () => createTestLinode( - { image: 'linode/debian10', region: 'us-east' }, + { image: 'linode/debian12', region: 'us-east' }, { waitForDisks: true } ), 'create linode' 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 1ee83565388..4c981b2f3ce 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 @@ -10,7 +10,7 @@ import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; describe('create image (using mocks)', () => { it('create image from a linode', () => { const mockDisks = [ - linodeDiskFactory.build({ label: 'Debian 10 Disk', filesystem: 'ext4' }), + linodeDiskFactory.build({ label: 'Debian 12 Disk', filesystem: 'ext4' }), linodeDiskFactory.build({ label: '512 MB Swap Image', filesystem: 'swap', 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 6c93301d30b..1f0e42c010a 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -3,32 +3,38 @@ */ import { accountFactory, + dedicatedTypeFactory, kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, linodeTypeFactory, regionFactory, + nodePoolFactory, + kubeLinodeFactory, + lkeHighAvailabilityTypeFactory, } from 'src/factories'; import { mockCreateCluster, mockGetCluster, mockCreateClusterError, mockGetControlPlaneACL, + mockGetClusterPools, + mockGetDashboardUrl, + mockGetApiEndpoints, + mockGetClusters, + mockGetLKEClusterTypes, + mockGetTieredKubernetesVersions, + mockGetKubernetesVersions, } from 'support/intercepts/lke'; +import { mockGetAccountBeta } from 'support/intercepts/betas'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetRegions, mockGetRegionAvailability, } from 'support/intercepts/regions'; -import { KubernetesCluster } from '@linode/api-v4'; -import { LkePlanDescription } from 'support/api/lke'; -import { lkeClusterPlans } from 'support/constants/lke'; -import { chooseRegion, getRegionById } from 'support/util/regions'; -import { interceptCreateCluster } from 'support/intercepts/lke'; +import { getRegionById } from 'support/util/regions'; import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomItem } from 'support/util/random'; -import { cleanUp } from 'support/util/cleanup'; -import { authenticate } from 'support/api/authentication'; import { dcPricingLkeCheckoutSummaryPlaceholder, dcPricingLkeHAPlaceholder, @@ -40,77 +46,151 @@ import { } from 'support/constants/dc-specific-pricing'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { chooseRegion } from 'support/util/regions'; +import { getTotalClusterMemoryCPUAndStorage } from 'src/features/Kubernetes/kubeUtils'; +import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; -/** - * Gets the label for an LKE plan as shown in creation plan table. - * - * @param clusterPlan - Cluster plan from which to determine Cloud Manager LKE plan name. - * - * @returns LKE plan name for plan. - */ -const getLkePlanName = (clusterPlan: LkePlanDescription) => { - return `${clusterPlan.type} ${clusterPlan.size} GB`; -}; +import type { ExtendedType } from 'src/utilities/extendType'; +import type { LkePlanDescription } from 'support/api/lke'; +import { PriceType } from '@linode/api-v4/lib/types'; +import { + latestEnterpriseTierKubernetesVersion, + latestKubernetesVersion, +} from 'support/constants/lke'; +import { lkeEnterpriseTypeFactory } from 'src/factories'; -/** - * Gets the label for an LKE plan as shown in the creation checkout bar. - * - * @param clusterPlan - Cluster plan from which to determine Cloud Manager LKE checkout name. - * - * @returns LKE checkout plan name for plan. - */ -const getLkePlanCheckoutName = (clusterPlan: LkePlanDescription) => { - return `${clusterPlan.type} ${clusterPlan.size} GB Plan`; -}; +const dedicatedNodeCount = 4; +const nanodeNodeCount = 3; -/** - * Returns each plan in an array which is similar to the given plan. - * - * Plans are considered similar if they have identical type and size. - * - * @param clusterPlan - Cluster plan with which to compare similarity. - * @param clusterPlans - Array from which to find similar cluster plans. - * - * @returns Array of similar cluster plans. - */ -const getSimilarPlans = ( - clusterPlan: LkePlanDescription, - clusterPlans: LkePlanDescription[] -) => { - return clusterPlans.filter((otherClusterPlan: any) => { - return ( - clusterPlan.type === otherClusterPlan.type && - clusterPlan.size === otherClusterPlan.size - ); - }); -}; +const clusterRegion = chooseRegion({ + capabilities: ['Kubernetes'], +}); +const dedicatedCpuPool = nodePoolFactory.build({ + count: dedicatedNodeCount, + nodes: kubeLinodeFactory.buildList(dedicatedNodeCount), + type: 'g6-dedicated-2', +}); +const nanodeMemoryPool = nodePoolFactory.build({ + count: nanodeNodeCount, + nodes: kubeLinodeFactory.buildList(nanodeNodeCount), + type: 'g6-nanode-1', +}); +const dedicatedType = dedicatedTypeFactory.build({ + disk: 81920, + id: 'g6-dedicated-2', + label: 'Dedicated 4 GB', + memory: 4096, + price: { + hourly: 0.054, + monthly: 36.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-2' + )?.region_prices, + vcpus: 2, +}) as ExtendedType; +const nanodeType = linodeTypeFactory.build({ + disk: 25600, + id: 'g6-nanode-1', + label: 'Linode 2 GB', + memory: 2048, + price: { + hourly: 0.0075, + monthly: 5.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-nanode-1' + )?.region_prices, + vcpus: 1, +}) as ExtendedType; +const mockedLKEClusterPrices: PriceType[] = [ + { + id: 'lke-sa', + label: 'LKE Standard Availability', + price: { + hourly: 0.0, + monthly: 0.0, + }, + region_prices: [], + transfer: 0, + }, +]; +const mockedLKEHAClusterPrices: PriceType[] = [ + { + id: 'lke-ha', + label: 'LKE High Availability', + price: { + hourly: 0.09, + monthly: 60.0, + }, + region_prices: [], + transfer: 0, + }, +]; +const mockedLKEEnterprisePrices = [ + lkeHighAvailabilityTypeFactory.build(), + lkeEnterpriseTypeFactory.build(), +]; +const clusterPlans: LkePlanDescription[] = [ + { + nodeCount: dedicatedNodeCount, + planName: 'Dedicated 4 GB', + size: 4, + tab: 'Dedicated CPU', + type: 'dedicated', + }, + { + nodeCount: nanodeNodeCount, + planName: 'Linode 2 GB', + size: 24, + tab: 'Shared CPU', + type: 'nanode', + }, +]; +const mockedLKEClusterTypes = [dedicatedType, nanodeType]; -authenticate(); describe('LKE Cluster Creation', () => { - before(() => { - cleanUp(['linodes', 'lke-clusters']); - }); - /* * - Confirms that users can create a cluster by completing the LKE create form. * - Confirms that LKE cluster is created. * - Confirms that user is redirected to new LKE cluster summary page. + * - Confirms that correct information is shown on the LKE cluster summary page * - Confirms that new LKE cluster summary page shows expected node pools. * - Confirms that new LKE cluster is shown on LKE clusters landing page. - * - Confirms that correct information is shown on the LKE cluster summary page */ - it('can create an LKE cluster', () => { - cy.tag('method:e2e', 'purpose:dcTesting'); - const clusterLabel = randomLabel(); - const clusterRegion = chooseRegion({ - capabilities: ['Kubernetes'], - }); - const clusterVersion = '1.27'; - const clusterPlans = new Array(2) - .fill(null) - .map(() => randomItem(lkeClusterPlans)); + const clusterLabel = randomLabel(); + const clusterVersion = '1.31'; + const mockedLKECluster = kubernetesClusterFactory.build({ + label: clusterLabel, + region: clusterRegion.id, + }); + const mockedLKEClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; + const mockedLKEClusterControlPlane = kubernetesControlPlaneACLFactory.build(); + const { + CPU: totalCpu, + RAM: totalMemory, + Storage: totalStorage, + } = getTotalClusterMemoryCPUAndStorage( + mockedLKEClusterPools, + mockedLKEClusterTypes + ); - interceptCreateCluster().as('createCluster'); + it('can create an LKE cluster', () => { + mockCreateCluster(mockedLKECluster).as('createCluster'); + mockGetCluster(mockedLKECluster).as('getCluster'); + mockGetClusterPools(mockedLKECluster.id, mockedLKEClusterPools).as( + 'getClusterPools' + ); + mockGetDashboardUrl(mockedLKECluster.id).as('getDashboardUrl'); + mockGetControlPlaneACL( + mockedLKECluster.id, + mockedLKEClusterControlPlane + ).as('getControlPlaneACL'); + mockGetApiEndpoints(mockedLKECluster.id).as('getApiEndpoints'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEClusterPrices).as('getLKEClusterTypes'); + mockGetClusters([mockedLKECluster]).as('getClusters'); + mockGetKubernetesVersions([clusterVersion]).as('getKubernetesVersions'); cy.visitWithLogin('/kubernetes/clusters'); @@ -123,44 +203,40 @@ describe('LKE Cluster Creation', () => { cy.url().should('endWith', '/kubernetes/create'); // Fill out LKE creation form label, region, and Kubernetes version fields. - cy.findByLabelText('Cluster Label') + cy.get('[data-qa-textfield-label="Cluster Label"]') .should('be.visible') - .click() - .type(`${clusterLabel}{enter}`); + .click(); + cy.focused().type(`${clusterLabel}{enter}`); ui.regionSelect.find().click().type(`${clusterRegion.label}{enter}`); - cy.findByText('Kubernetes Version') - .should('be.visible') + ui.autocomplete + .findByLabel('Kubernetes Version') .click() .type(`${clusterVersion}{enter}`); - cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); + cy.get('[data-testid="ha-radio-button-no"]').should('be.visible').click(); - let totalCpu = 0; - let totalMemory = 0; - let totalStorage = 0; let monthPrice = 0; - // Add a node pool for each randomly selected plan, and confirm that the + // Add a node pool for each selected plan, and confirm that the // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { - const nodeCount = randomNumber(1, 3); - const planName = getLkePlanName(clusterPlan); - const checkoutName = getLkePlanCheckoutName(clusterPlan); + const nodeCount = clusterPlan.nodeCount; + const planName = clusterPlan.planName; - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); + cy.log(`Adding ${nodeCount}x ${planName} node(s)`); // Click the right tab for the plan, and add a node pool with the desired // number of nodes. cy.findByText(clusterPlan.tab).should('be.visible').click(); + const quantityInput = '[name="Quantity"]'; cy.findByText(planName) .should('be.visible') .closest('tr') .within(() => { - cy.get('[name="Quantity"]') - .should('be.visible') - .click() - .type(`{selectall}${nodeCount}`); + cy.get(quantityInput).should('be.visible'); + cy.get(quantityInput).click(); + cy.get(quantityInput).type(`{selectall}${nodeCount}`); ui.button .findByTitle('Add') @@ -176,31 +252,16 @@ describe('LKE Cluster Creation', () => { // It's possible that multiple pools of the same type get added. // We're taking a naive approach here by confirming that at least one // instance of the pool appears in the checkout bar. - cy.findAllByText(checkoutName).first().should('be.visible'); + cy.findAllByText(`${planName} Plan`).first().should('be.visible'); }); - // Expected information on the LKE cluster summary page. - if (clusterPlan.size == 2 && clusterPlan.type == 'Linode') { - totalCpu = totalCpu + nodeCount * 1; - totalMemory = totalMemory + nodeCount * 2; - totalStorage = totalStorage + nodeCount * 50; - monthPrice = monthPrice + nodeCount * 12; - } - if (clusterPlan.size == 4 && clusterPlan.type == 'Linode') { - totalCpu = totalCpu + nodeCount * 2; - totalMemory = totalMemory + nodeCount * 4; - totalStorage = totalStorage + nodeCount * 80; - monthPrice = monthPrice + nodeCount * 24; - } - if (clusterPlan.size == 4 && clusterPlan.type == 'Dedicated') { - totalCpu = totalCpu + nodeCount * 2; - totalMemory = totalMemory + nodeCount * 4; - totalStorage = totalStorage + nodeCount * 80; - monthPrice = monthPrice + nodeCount * 36; - } + monthPrice = getTotalClusterPrice({ + highAvailabilityPrice: 0, + pools: [nanodeMemoryPool, dedicatedCpuPool], + region: clusterRegion.id, + types: mockedLKEClusterTypes, + }); }); - // $60.00/month for enabling HA control plane - const totalPrice = monthPrice + 60; // Create LKE cluster. cy.get('[data-testid="kube-checkout-bar"]') @@ -215,31 +276,40 @@ describe('LKE Cluster Creation', () => { // Wait for LKE cluster to be created and confirm that we are redirected // to the cluster summary page. - cy.wait('@createCluster').then(({ response }) => { - if (!response) { - throw new Error( - `Error creating LKE cluster ${clusterLabel}; API request failed` - ); - } - const cluster: KubernetesCluster = response.body; - cy.url().should('endWith', `/kubernetes/clusters/${cluster.id}/summary`); - }); + cy.wait([ + '@getCluster', + '@getClusterPools', + '@createCluster', + '@getLKEClusterTypes', + '@getLinodeTypes', + '@getDashboardUrl', + '@getControlPlaneACL', + '@getApiEndpoints', + ]); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockedLKECluster.id}/summary` + ); // Confirm that each node pool is shown. clusterPlans.forEach((clusterPlan) => { // Because multiple node pools may have identical labels, we figure out // how many identical labels for each plan will exist and confirm that // the expected number is present. - const nodePoolLabel = getLkePlanName(clusterPlan); + const nodePoolLabel = clusterPlan.planName; const similarNodePoolCount = getSimilarPlans(clusterPlan, clusterPlans) .length; - //Confirm that the cluster created with the expected parameters. + // Confirm that the cluster created with the expected parameters. cy.findAllByText(`${clusterRegion.label}`).should('be.visible'); cy.findAllByText(`${totalCpu} CPU Cores`).should('be.visible'); - cy.findAllByText(`${totalMemory} GB RAM`).should('be.visible'); - cy.findAllByText(`${totalStorage} GB Storage`).should('be.visible'); - cy.findAllByText(`$${totalPrice}.00/month`).should('be.visible'); + cy.findAllByText(`${Math.round(totalStorage / 1024)} GB Storage`).should( + 'be.visible' + ); + cy.findAllByText(`${Math.round(totalMemory / 1024)} GB RAM`).should( + 'be.visible' + ); + cy.findAllByText(`$${monthPrice.toFixed(2)}/month`).should('be.visible'); cy.contains('Kubernetes API Endpoint').should('be.visible'); cy.contains('linodelke.net:443').should('be.visible'); @@ -249,26 +319,169 @@ describe('LKE Cluster Creation', () => { .should('be.visible'); }); - // Navigate to the LKE landing page and confirm that new cluster is shown. ui.breadcrumb .find() .should('be.visible') .within(() => { cy.findByText(clusterLabel).should('be.visible'); + }); + }); +}); + +describe('LKE Cluster Creation with APL enabled', () => { + it('can create an LKE cluster with APL flag enabled', () => { + const clusterLabel = randomLabel(); + const mockedLKECluster = kubernetesClusterFactory.build({ + label: clusterLabel, + region: clusterRegion.id, + }); + const mockedLKEClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; + const mockedLKEClusterControlPlane = kubernetesControlPlaneACLFactory.build(); + const dedicated4Type = dedicatedTypeFactory.build({ + disk: 163840, + id: 'g6-dedicated-4', + label: 'Dedicated 8GB', + memory: 8192, + price: { + hourly: 0.108, + monthly: 72.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-8' + )?.region_prices, + vcpus: 4, + }); + const dedicated8Type = dedicatedTypeFactory.build({ + disk: 327680, + id: 'g6-dedicated-8', + label: 'Dedicated 16GB', + memory: 16384, + price: { + hourly: 0.216, + monthly: 144.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-8' + )?.region_prices, + vcpus: 8, + }); + const mockedLKEClusterTypes = [ + dedicatedType, + dedicated4Type, + dedicated8Type, + nanodeType, + ]; + mockAppendFeatureFlags({ + apl: { + enabled: true, + }, + }).as('getFeatureFlags'); + mockGetAccountBeta({ + id: 'apl', + label: 'Akamai App Platform Beta', + enrolled: '2024-11-04T21:39:41', + description: + 'Akamai App Platform is a platform that combines developer and operations-centric tools, automation and self-service to streamline the application lifecycle when using Kubernetes. This process will pre-register you for an upcoming beta.', + started: '2024-10-31T18:00:00', + ended: null, + }).as('getAccountBeta'); + mockCreateCluster(mockedLKECluster).as('createCluster'); + mockGetCluster(mockedLKECluster).as('getCluster'); + mockGetClusterPools(mockedLKECluster.id, mockedLKEClusterPools).as( + 'getClusterPools' + ); + mockGetDashboardUrl(mockedLKECluster.id).as('getDashboardUrl'); + mockGetControlPlaneACL( + mockedLKECluster.id, + mockedLKEClusterControlPlane + ).as('getControlPlaneACL'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEHAClusterPrices).as('getLKEClusterTypes'); + mockGetApiEndpoints(mockedLKECluster.id).as('getApiEndpoints'); + + cy.visitWithLogin('/kubernetes/create'); + + cy.wait([ + '@getFeatureFlags', + '@getAccountBeta', + '@getLinodeTypes', + '@getLKEClusterTypes', + ]); + + // Enter cluster details + cy.get('[data-qa-textfield-label="Cluster Label"]') + .should('be.visible') + .click(); + cy.focused().type(`${clusterLabel}{enter}`); + + ui.regionSelect.find().click().type(`${clusterRegion.label}{enter}`); + + cy.findByTestId('apl-label').should('have.text', 'Akamai App Platform'); + cy.findByTestId('apl-radio-button-yes').should('be.visible').click(); + cy.findByTestId('ha-radio-button-yes').should('be.disabled'); + cy.get( + '[aria-label="Enabled by default when Akamai App Platform is enabled."]' + ).should('be.visible'); + + // Check that Shared CPU plans are disabled + ui.tabList.findTabByTitle('Shared CPU').click(); + cy.findByText( + 'Shared CPU instances are currently not available for Akamai App Platform.' + ).should('be.visible'); + cy.get('[data-qa-plan-row="Linode 2 GB"]').should('have.attr', 'disabled'); + + // Check that Dedicated CPU plans are available if greater than 8GB + ui.tabList.findTabByTitle('Dedicated CPU').click(); + cy.get('[data-qa-plan-row="Dedicated 4 GB"]').should( + 'have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="Dedicated 8 GB"]').should( + 'not.have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="Dedicated 16 GB"]').within(() => { + cy.get('[name="Quantity"]').click(); + cy.get('[name="Quantity"]').type('{selectall}3'); - cy.findByText('kubernetes').should('be.visible').click(); + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Check that the checkout bar displays the correct information + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + cy.findByText(`Dedicated 16 GB Plan`).should('be.visible'); + cy.findByText('$432.00').should('be.visible'); + cy.findByText('High Availability (HA) Control Plane').should( + 'be.visible' + ); + cy.findByText('$60.00/month').should('be.visible'); + cy.findByText('$492.00').should('be.visible'); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); }); - cy.url().should('endWith', '/kubernetes/clusters'); - cy.findByText(clusterLabel).should('be.visible'); + cy.wait([ + '@createCluster', + '@getCluster', + '@getClusterPools', + '@getDashboardUrl', + '@getControlPlaneACL', + '@getApiEndpoints', + ]); }); }); describe('LKE Cluster Creation with DC-specific pricing', () => { - before(() => { - cleanUp('lke-clusters'); - }); - /* * - Confirms that DC-specific prices are present in the LKE create form. * - Confirms that pricing docs link is shown in "Region" section. @@ -277,9 +490,9 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { * - Confirms that HA helper text updates dynamically to display pricing when a region is selected. */ it('can dynamically update prices when creating an LKE cluster based on region', () => { - const dcSpecificPricingRegion = getRegionById('us-east'); + // In staging API, only the Dallas region is available for LKE creation + const dcSpecificPricingRegion = getRegionById('us-central'); const clusterLabel = randomLabel(); - const clusterVersion = '1.27'; const clusterPlans = new Array(2) .fill(null) .map(() => randomItem(dcPricingLkeClusterPlans)); @@ -322,18 +535,16 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { .click() .type(`${clusterLabel}{enter}`); - ui.regionSelect.find().type(`${dcSpecificPricingRegion.label}{enter}`); + ui.regionSelect + .find() + .click() + .type(`${dcSpecificPricingRegion.label}{enter}`); // Confirm that HA price updates dynamically once region selection is made. cy.contains(/\$.*\/month/).should('be.visible'); cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); - cy.findByText('Kubernetes Version') - .should('be.visible') - .click() - .type(`${clusterVersion}{enter}`); - // Confirm that with region and HA selections, create button is still disabled until plan selection is made. cy.get('[data-qa-deploy-linode]') .should('contain.text', 'Create Cluster') @@ -343,10 +554,9 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { const nodeCount = randomNumber(1, 3); - const planName = getLkePlanName(clusterPlan); - const checkoutName = getLkePlanCheckoutName(clusterPlan); + const planName = clusterPlan.planName; - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); + cy.log(`Adding ${nodeCount}x ${clusterPlan.planName} node(s)`); // Click the right tab for the plan, and add a node pool with the desired // number of nodes. cy.findByText(clusterPlan.tab).should('be.visible').click(); @@ -373,7 +583,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { // It's possible that multiple pools of the same type get added. // We're taking a naive approach here by confirming that at least one // instance of the pool appears in the checkout bar. - cy.findAllByText(checkoutName).first().should('be.visible'); + cy.findAllByText(`${planName} Plan`).first().should('be.visible'); }); }); @@ -512,7 +722,6 @@ describe('LKE Cluster Creation with ACL', () => { .should('be.visible'); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -647,7 +856,6 @@ describe('LKE Cluster Creation with ACL', () => { .click(); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -785,7 +993,6 @@ describe('LKE Cluster Creation with ACL', () => { cy.contains('Must be a valid IPv6 address.').should('not.exist'); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -847,7 +1054,7 @@ describe('LKE Cluster Creation with LKE-E', () => { cy.url().should('endWith', '/kubernetes/create'); - cy.contains('Cluster Type').should('not.exist'); + cy.contains('Cluster Tier').should('not.exist'); }); describe('shows the LKE-E flow with the feature flag on', () => { @@ -860,16 +1067,39 @@ describe('LKE Cluster Creation with LKE-E', () => { /** * - Mocks the LKE-E capability - * - Confirms the Cluster Type selection can be made + * - Confirms the Cluster Tier selection can be made * - Confirms that HA is enabled by default with LKE-E selection - * @todo LKE-E: Add onto this test as the LKE-E changes to the Create flow are built out + * - Confirms an LKE-E supported region can be selected + * - Confirms an LKE-E supported k8 version can be selected + * - Confirms the checkout bar displays the correct LKE-E info + * - Confirms an enterprise cluster can be created with the correct chip, version, and price */ it('creates an LKE-E cluster with the account capability', () => { + const clusterLabel = randomLabel(); + const mockedEnterpriseCluster = kubernetesClusterFactory.build({ + label: clusterLabel, + region: 'us-iad', + tier: 'enterprise', + k8s_version: latestEnterpriseTierKubernetesVersion.id, + }); + const mockedEnterpriseClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; + const mockedLKEClusterTypes = [dedicatedType, nanodeType]; + mockGetAccount( accountFactory.build({ capabilities: ['Kubernetes Enterprise'], }) ).as('getAccount'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetKubernetesVersions([latestKubernetesVersion]).as( + 'getKubernetesVersions' + ); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( + 'getLKEEnterpriseClusterTypes' + ); mockGetRegions([ regionFactory.build({ capabilities: ['Linodes', 'Kubernetes'], @@ -882,8 +1112,18 @@ describe('LKE Cluster Creation with LKE-E', () => { label: 'Washington, DC', }), ]).as('getRegions'); + mockGetCluster(mockedEnterpriseCluster).as('getCluster'); + mockCreateCluster(mockedEnterpriseCluster).as('createCluster'); + mockGetClusters([mockedEnterpriseCluster]).as('getClusters'); + mockGetClusterPools( + mockedEnterpriseCluster.id, + mockedEnterpriseClusterPools + ).as('getClusterPools'); + mockGetDashboardUrl(mockedEnterpriseCluster.id).as('getDashboardUrl'); + mockGetApiEndpoints(mockedEnterpriseCluster.id).as('getApiEndpoints'); cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getAccount']); ui.button .findByTitle('Create Cluster') @@ -892,10 +1132,16 @@ describe('LKE Cluster Creation with LKE-E', () => { .click(); cy.url().should('endWith', '/kubernetes/create'); + cy.wait(['@getKubernetesVersions', '@getTieredKubernetesVersions']); - cy.findByText('Cluster Type').should('be.visible'); + cy.findByLabelText('Cluster Label') + .should('be.visible') + .click() + .type(`${clusterLabel}{enter}`); + + cy.findByText('Cluster Tier').should('be.visible'); - // Confirm both cluster types exist and the LKE card is selected by default + // Confirm both Cluster Tiers exist and the LKE card is selected by default cy.get(`[data-qa-select-card-heading="LKE"]`) .closest('[data-qa-selection-card]') .should('be.visible') @@ -907,16 +1153,13 @@ describe('LKE Cluster Creation with LKE-E', () => { .should('have.attr', 'data-qa-selection-card-checked', 'false') .click(); - // Select LKE-E as the cluster type + // Select LKE-E as the Cluster Tier cy.get(`[data-qa-select-card-heading="LKE Enterprise"]`) .closest('[data-qa-selection-card]') .should('be.visible') .should('have.attr', 'data-qa-selection-card-checked', 'true'); - // Confirm HA section is hidden since LKE-E includes HA by default - cy.findByText('HA Control Plane').should('not.exist'); - - cy.wait(['@getRegions']); + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); // Confirm unsupported regions are not displayed ui.regionSelect.find().click().type('Newark, NJ'); @@ -934,11 +1177,107 @@ describe('LKE Cluster Creation with LKE-E', () => { ) .should('be.visible'); - // TODO: finish the rest of this test in subsequent PRs + // Selects an enterprise version + ui.autocomplete + .findByLabel('Kubernetes Version') + .should('be.visible') + .click(); + + ui.autocompletePopper + .findByTitle(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .should('be.enabled') + .click(); + + // Add a node pool for each selected plan, and confirm that the + // selected node pool plan is added to the checkout bar. + clusterPlans.forEach((clusterPlan) => { + const nodeCount = clusterPlan.nodeCount; + const planName = clusterPlan.planName; + + cy.log(`Adding ${nodeCount}x ${planName} node(s)`); + // Click the right tab for the plan, and add a node pool with the desired + // number of nodes. + cy.findByText(clusterPlan.tab).should('be.visible').click(); + const quantityInput = '[name="Quantity"]'; + cy.findByText(planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get(quantityInput).should('be.visible'); + cy.get(quantityInput).click(); + cy.get(quantityInput).type(`{selectall}${nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + // Check that the checkout bar displays the correct information + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + // Confirm HA section is hidden since LKE-E includes HA by default + cy.findByText('High Availability (HA) Control Plane').should( + 'not.exist' + ); + + // Confirm LKE-E section is shown + cy.findByText('LKE Enterprise').should('be.visible'); + cy.findByText('HA control plane, Dedicated control plane').should( + 'be.visible' + ); + cy.findByText('$300.00/month').should('be.visible'); + + cy.findByText(`Dedicated 4 GB Plan`).should('be.visible'); + cy.findByText('$144.00').should('be.visible'); + cy.findByText(`Linode 2 GB Plan`).should('be.visible'); + cy.findByText('$15.00').should('be.visible'); + cy.findByText('$459.00').should('be.visible'); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for LKE cluster to be created and confirm that we are redirected + // to the cluster summary page. + cy.wait([ + '@getCluster', + '@getClusterPools', + '@createCluster', + '@getLKEEnterpriseClusterTypes', + '@getLinodeTypes', + '@getDashboardUrl', + '@getApiEndpoints', + ]); + + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockedEnterpriseCluster.id}/summary` + ); + + // Confirm the LKE-E cluster has the correct enterprise chip, version, and pricing. + cy.findByText('ENTERPRISE').should('be.visible'); + cy.findByText( + `Version ${latestEnterpriseTierKubernetesVersion.id}` + ).should('be.visible'); + cy.findByText('$459.00/month').should('be.visible'); }); it('disables the Cluster Type selection without the LKE-E account capability', () => { + mockGetAccount( + accountFactory.build({ + capabilities: [], + }) + ).as('getAccount'); cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getAccount']); ui.button .findByTitle('Create Cluster') @@ -948,8 +1287,8 @@ describe('LKE Cluster Creation with LKE-E', () => { cy.url().should('endWith', '/kubernetes/create'); - // Confirm the Cluster Type selection can be made when the LKE-E feature is enabled - cy.findByText('Cluster Type').should('be.visible'); + // Confirm the Cluster Tier selection can be made when the LKE-E feature is enabled + cy.findByText('Cluster Tier').should('be.visible'); // Confirm both tiers exist and the LKE card is selected by default cy.get(`[data-qa-select-card-heading="LKE"]`) @@ -964,3 +1303,25 @@ describe('LKE Cluster Creation with LKE-E', () => { }); }); }); + +/** + * Returns each plan in an array which is similar to the given plan. + * + * Plans are considered similar if they have identical type and size. + * + * @param clusterPlan - Cluster plan with which to compare similarity. + * @param clusterPlans - Array from which to find similar cluster plans. + * + * @returns Array of similar cluster plans. + */ +const getSimilarPlans = ( + clusterPlan: LkePlanDescription, + clusterPlans: LkePlanDescription[] +) => { + return clusterPlans.filter((otherClusterPlan) => { + return ( + clusterPlan.type === otherClusterPlan.type && + clusterPlan.size === otherClusterPlan.size + ); + }); +}; diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index b97a4b77baf..e4eaa8c155b 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -3,6 +3,10 @@ import { mockGetClusters, mockGetClusterPools, mockGetKubeconfig, + mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, + mockRecycleAllNodes, + mockUpdateCluster, } from 'support/intercepts/lke'; import { accountFactory, @@ -165,4 +169,186 @@ describe('LKE landing page', () => { cy.wait('@getKubeconfig'); readDownload(mockKubeconfigFilename).should('eq', mockKubeconfigContents); }); + + it('does not show an Upgrade chip when there is no new kubernetes standard version', () => { + const oldVersion = '1.25'; + const newVersion = '1.26'; + + const cluster = kubernetesClusterFactory.build({ + k8s_version: newVersion, + }); + + mockGetClusters([cluster]).as('getClusters'); + mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + + cy.visitWithLogin(`/kubernetes/clusters`); + + cy.wait(['@getClusters', '@getVersions']); + + cy.findByText(newVersion).should('be.visible'); + + cy.findByText('UPGRADE').should('not.exist'); + }); + + it('does not show an Upgrade chip when there is no new kubernetes enterprise version', () => { + const oldVersion = '1.31.1+lke1'; + const newVersion = '1.32.1+lke2'; + + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true }, + }); + + const cluster = kubernetesClusterFactory.build({ + k8s_version: newVersion, + tier: 'enterprise', + }); + + mockGetClusters([cluster]).as('getClusters'); + mockGetTieredKubernetesVersions('enterprise', [ + { id: newVersion, tier: 'enterprise' }, + { id: oldVersion, tier: 'enterprise' }, + ]).as('getTieredVersions'); + + cy.visitWithLogin(`/kubernetes/clusters`); + + cy.wait(['@getAccount', '@getClusters', '@getTieredVersions']); + + cy.findByText(newVersion).should('be.visible'); + + cy.findByText('UPGRADE').should('not.exist'); + }); + + it('can upgrade the standard kubernetes version from the landing page', () => { + const oldVersion = '1.25'; + const newVersion = '1.26'; + + const cluster = kubernetesClusterFactory.build({ + k8s_version: oldVersion, + }); + + const updatedCluster = { ...cluster, k8s_version: newVersion }; + + mockGetClusters([cluster]).as('getClusters'); + mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); + mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); + + cy.visitWithLogin(`/kubernetes/clusters`); + + cy.wait(['@getClusters', '@getVersions']); + + cy.findByText(oldVersion).should('be.visible'); + + cy.findByText('UPGRADE').should('be.visible').should('be.enabled').click(); + + ui.dialog + .findByTitle( + `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` + ) + .should('be.visible'); + + mockGetClusters([updatedCluster]).as('getClusters'); + + ui.button + .findByTitle('Upgrade Version') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait(['@updateCluster', '@getClusters']); + + ui.dialog + .findByTitle('Step 2: Recycle All Cluster Nodes') + .should('be.visible'); + + ui.button + .findByTitle('Recycle All Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@recycleAllNodes'); + + ui.toast.assertMessage('Recycle started successfully.'); + + cy.findByText(newVersion).should('be.visible'); + }); + + it('can upgrade the enterprise kubernetes version from the landing page', () => { + const oldVersion = '1.31.1+lke1'; + const newVersion = '1.32.1+lke2'; + + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true }, + }); + + const cluster = kubernetesClusterFactory.build({ + k8s_version: oldVersion, + tier: 'enterprise', + }); + + const updatedCluster = { ...cluster, k8s_version: newVersion }; + + mockGetClusters([cluster]).as('getClusters'); + mockGetTieredKubernetesVersions('enterprise', [ + { id: newVersion, tier: 'enterprise' }, + { id: oldVersion, tier: 'enterprise' }, + ]).as('getTieredVersions'); + mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); + mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); + + cy.visitWithLogin(`/kubernetes/clusters`); + + cy.wait(['@getAccount', '@getClusters', '@getTieredVersions']); + + cy.findByText(oldVersion).should('be.visible'); + + cy.findByText('UPGRADE').should('be.visible').should('be.enabled').click(); + + ui.dialog + .findByTitle( + `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` + ) + .should('be.visible'); + + mockGetClusters([updatedCluster]).as('getClusters'); + + ui.button + .findByTitle('Upgrade Version') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait(['@updateCluster', '@getClusters']); + + ui.dialog + .findByTitle('Step 2: Recycle All Cluster Nodes') + .should('be.visible'); + + ui.button + .findByTitle('Recycle All Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@recycleAllNodes'); + + ui.toast.assertMessage('Recycle started successfully.'); + + cy.findByText(newVersion).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 4d4f89c60cb..788f19e2d31 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -6,6 +6,7 @@ import { linodeFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, + linodeTypeFactory, } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; import { mockGetAccount } from 'support/intercepts/account'; @@ -24,11 +25,12 @@ import { mockRecycleAllNodes, mockGetDashboardUrl, mockGetApiEndpoints, - mockGetClusters, mockUpdateControlPlaneACL, mockGetControlPlaneACL, mockUpdateControlPlaneACLError, mockGetControlPlaneACLError, + mockGetTieredKubernetesVersions, + mockUpdateClusterError, } from 'support/intercepts/lke'; import { mockGetLinodeType, @@ -42,6 +44,7 @@ import { getRegionById } from 'support/util/regions'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { randomString } from 'support/util/random'; +import { buildArray } from 'support/util/arrays'; const mockNodePools = nodePoolFactory.buildList(2); @@ -133,7 +136,7 @@ describe('LKE cluster updates', () => { * - Confirms that Kubernetes upgrade prompt is shown when not up-to-date. * - Confirms that Kubernetes upgrade prompt is hidden when up-to-date. */ - it('can upgrade kubernetes version from the details page', () => { + it('can upgrade standard kubernetes version from the details page', () => { const oldVersion = '1.25'; const newVersion = '1.26'; @@ -235,63 +238,132 @@ describe('LKE cluster updates', () => { ui.toast.findByMessage('Recycle started successfully.'); }); - it('can upgrade the kubernetes version from the landing page', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; + /* + * - Confirms UI flow of upgrading Kubernetes enterprise version using mocked API requests. + * - Confirms that Kubernetes upgrade prompt is shown when not up-to-date. + * - Confirms that Kubernetes upgrade prompt is hidden when up-to-date. + */ + it('can upgrade enterprise kubernetes version from the details page', () => { + const oldVersion = '1.31.1+lke1'; + const newVersion = '1.31.1+lke2'; + + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); - const cluster = kubernetesClusterFactory.build({ + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true }, + }); + + const mockCluster = kubernetesClusterFactory.build({ k8s_version: oldVersion, + tier: 'enterprise', }); - const updatedCluster = { ...cluster, k8s_version: newVersion }; + const mockClusterUpdated = { + ...mockCluster, + k8s_version: newVersion, + }; - mockGetClusters([cluster]).as('getClusters'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); - mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); - mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); + const upgradePrompt = + 'A new version of Kubernetes is available (1.31.1+lke2).'; - cy.visitWithLogin(`/kubernetes/clusters`); + const upgradeNotes = [ + 'Once the upgrade is complete you will need to recycle all nodes in your cluster', + // Confirm that the old version and new version are both shown. + oldVersion, + newVersion, + ]; - cy.wait(['@getClusters', '@getVersions']); + mockGetCluster(mockCluster).as('getCluster'); + mockGetTieredKubernetesVersions('enterprise', [ + { id: newVersion, tier: 'enterprise' }, + { id: oldVersion, tier: 'enterprise' }, + ]).as('getTieredVersions'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); - cy.findByText(oldVersion).should('be.visible'); + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getAccount', + '@getCluster', + '@getNodePools', + '@getTieredVersions', + ]); - cy.findByText('UPGRADE') + // Confirm that upgrade prompt is shown. + cy.findByText(upgradePrompt).should('be.visible'); + ui.button + .findByTitle('Upgrade Version') .should('be.visible') .should('be.enabled') .click(); ui.dialog .findByTitle( - `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` + `Step 1: Upgrade ${mockCluster.label} to Kubernetes ${newVersion}` ) - .should('be.visible'); + .should('be.visible') + .within(() => { + upgradeNotes.forEach((note: string) => { + cy.findAllByText(note, { exact: false }).should('be.visible'); + }); - mockGetClusters([updatedCluster]).as('getClusters'); + ui.button + .findByTitle('Upgrade Version') + .should('be.visible') + .should('be.enabled') + .click(); + }); - ui.button - .findByTitle('Upgrade Version') - .should('be.visible') - .should('be.enabled') - .click(); + // Wait for API response and assert toast message is shown. + cy.wait('@updateCluster'); - cy.wait(['@updateCluster', '@getClusters']); + // Verify the banner goes away because the version update has happened + cy.findByText(upgradePrompt).should('not.exist'); - ui.dialog - .findByTitle('Step 2: Recycle All Cluster Nodes') - .should('be.visible'); + mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes'); - ui.button - .findByTitle('Recycle All Nodes') + const stepTwoDialogTitle = 'Step 2: Recycle All Cluster Nodes'; + + ui.dialog + .findByTitle(stepTwoDialogTitle) .should('be.visible') - .should('be.enabled') - .click(); + .within(() => { + cy.findByText('Kubernetes version has been updated successfully.', { + exact: false, + }).should('be.visible'); + + cy.findByText( + 'For the changes to take full effect you must recycle the nodes in your cluster.', + { exact: false } + ).should('be.visible'); + ui.button + .findByTitle('Recycle All Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Verify clicking the "Recycle All Nodes" makes an API call cy.wait('@recycleAllNodes'); - ui.toast.assertMessage('Recycle started successfully.'); + // Verify the upgrade dialog closed + cy.findByText(stepTwoDialogTitle).should('not.exist'); - cy.findByText(newVersion).should('be.visible'); + // Verify the banner is still gone after the flow + cy.findByText(upgradePrompt).should('not.exist'); + + // Verify the version is correct after the update + cy.findByText(`Version ${newVersion}`); + + ui.toast.findByMessage('Recycle started successfully.'); }); /* @@ -858,6 +930,179 @@ describe('LKE cluster updates', () => { .should('be.visible') .should('be.disabled'); }); + + /* + * - Confirms LKE summary page updates to reflect new cluster name. + */ + it('can rename cluster', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + const mockNewCluster = kubernetesClusterFactory.build({ + label: 'newClusterName', + }); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockUpdateCluster(mockCluster.id, mockNewCluster).as('updateCluster'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // LKE clusters can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. + cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); + cy.findByTestId('textfield-input') + .should('be.visible') + .should('have.value', mockCluster.label) + .clear() + .type(`${mockNewCluster.label}{enter}`); + + cy.wait('@updateCluster'); + + cy.findAllByText(mockNewCluster.label).should('be.visible'); + cy.findAllByText(mockCluster.label).should('not.exist'); + }); + + /* + * - Confirms error message shows when the API request fails. + */ + it('can handle API errors when renaming cluster', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + const mockErrorCluster = kubernetesClusterFactory.build({ + label: 'errorClusterName', + }); + const mockErrorMessage = 'API request fails'; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockUpdateClusterError(mockCluster.id, mockErrorMessage).as( + 'updateClusterError' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // LKE cluster can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. + cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); + cy.findByTestId('textfield-input') + .should('be.visible') + .should('have.value', mockCluster.label) + .clear() + .type(`${mockErrorCluster.label}{enter}`); + + // Error message shows when API request fails. + cy.wait('@updateClusterError'); + cy.findAllByText(mockErrorMessage).should('be.visible'); + }); + }); + + it('can add and delete node pool tags', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + + const mockType = linodeTypeFactory.build(); + + const mockNodePoolInstances = buildArray(3, () => + linodeFactory.build({ label: randomLabel() }) + ); + + const mockNodes = mockNodePoolInstances.map((linode, i) => + kubeLinodeFactory.build({ + id: `id-${i * 5000}`, + instance_id: linode.id, + status: 'ready', + }) + ); + + const mockNodePoolNoTags = nodePoolFactory.build({ + id: 1, + type: mockType.id, + nodes: mockNodes, + }); + + const mockNodePoolWithTags = { + ...mockNodePoolNoTags, + tags: ['test-tag'], + }; + + mockGetLinodes(mockNodePoolInstances); + mockGetLinodeType(linodeTypeFactory.build({ id: mockType.id })).as( + 'getType' + ); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolNoTags]).as( + 'getNodePoolsNoTags' + ); + mockGetKubernetesVersions().as('getVersions'); + mockGetControlPlaneACL(mockCluster.id, { acl: { enabled: false } }).as( + 'getControlPlaneAcl' + ); + mockUpdateNodePool(mockCluster.id, mockNodePoolWithTags).as('addTag'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePoolsNoTags', + '@getVersions', + '@getType', + '@getControlPlaneAcl', + ]); + + // Confirm that Linode instance info has finished loading before attempting + // to interact with the tag button. + mockNodePoolInstances.forEach((linode) => { + cy.findByText(linode.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Running').should('be.visible'); + }); + }); + + cy.get(`[data-qa-node-pool-id="${mockNodePoolNoTags.id}"]`).within(() => { + ui.button.findByTitle('Add a tag').should('be.visible').click(); + + cy.findByLabelText('Create or Select a Tag') + .should('be.visible') + .type(`${mockNodePoolWithTags.tags[0]}`); + + ui.autocompletePopper + .findByTitle(`Create "${mockNodePoolWithTags.tags[0]}"`) + .scrollIntoView() + .should('be.visible') + .click(); + }); + + mockGetClusterPools(mockCluster.id, [mockNodePoolWithTags]).as( + 'getNodePoolsWithTags' + ); + + cy.wait(['@addTag', '@getNodePoolsWithTags']); + + mockUpdateNodePool(mockCluster.id, mockNodePoolNoTags).as('deleteTag'); + mockGetClusterPools(mockCluster.id, [mockNodePoolNoTags]).as( + 'getNodePoolsNoTags' + ); + + // Delete the newly added node pool tag. + cy.get(`[data-qa-tag="${mockNodePoolWithTags.tags[0]}"]`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-delete-tag="true"]').should('be.visible').click(); + }); + + cy.wait(['@deleteTag', '@getNodePoolsNoTags']); + + cy.get(`[data-qa-tag="${mockNodePoolWithTags.tags[0]}"]`).should( + 'not.exist' + ); }); describe('LKE cluster updates for DC-specific prices', () => { 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 3f6518e2bec..a93a904c248 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -28,6 +28,7 @@ import { dcPricingMockLinodeTypesForBackups } from 'support/constants/dc-specifi import { chooseRegion } from 'support/util/regions'; import { expectManagedDisabled } from 'support/api/managed'; import { createTestLinode } from 'support/util/linodes'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; const BackupsCancellationNote = 'Once backups for this Linode have been canceled, you cannot re-enable them for 24 hours.'; @@ -76,7 +77,9 @@ describe('linode backups', () => { cy.wait('@getLinode'); // Wait for Linode to finish provisioning. - cy.findByText('OFFLINE').should('be.visible'); + cy.findByText('OFFLINE', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); // Confirm that enable backups prompt is shown. cy.contains( @@ -184,7 +187,9 @@ describe('linode backups', () => { cy.wait('@getLinode'); // Wait for the Linode to finish provisioning. - cy.findByText('OFFLINE').should('be.visible'); + cy.findByText('OFFLINE', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); cy.findByText('Manual Snapshot') .should('be.visible') 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 0ae05e81ecc..e15151274c0 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 @@ -20,7 +20,7 @@ describe('Create Linode flow to validate code snippet modal', () => { // Set Linode label, distribution, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById('us-east'); 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 c6d8befe082..2b6399144be 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 @@ -29,7 +29,7 @@ describe('Create Linode with Add-ons', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -83,7 +83,7 @@ describe('Create Linode with Add-ons', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); 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 70a759c140a..daf52a7b707 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 @@ -45,7 +45,7 @@ describe('Create Linode with Firewall', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -111,7 +111,7 @@ describe('Create Linode with Firewall', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -218,7 +218,7 @@ describe('Create Linode with Firewall', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); 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 b613f8cf384..dd9b1fa1153 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 @@ -41,7 +41,7 @@ describe('Create Linode with SSH Key', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -107,7 +107,7 @@ describe('Create Linode with SSH Key', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); 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 8951ed66e9b..009aa74305d 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 @@ -34,7 +34,7 @@ describe('Create Linode with user data', () => { // Fill out create form, selecting a region and image that both have // cloud-init capabilities. linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -88,7 +88,7 @@ describe('Create Linode with user data', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(mockLinodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); 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 d80d2a963b9..270b9de1072 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 @@ -42,7 +42,7 @@ describe('Create Linode with VLANs', () => { // Fill out necessary Linode create fields. linodeCreatePage.selectRegionById(mockLinodeRegion.id); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.setLabel(mockLinode.label); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -130,7 +130,7 @@ describe('Create Linode with VLANs', () => { // Fill out necessary Linode create fields. linodeCreatePage.selectRegionById(mockLinodeRegion.id); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.setLabel(mockLinode.label); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); 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 f0746146d43..08842e8f196 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 @@ -3,7 +3,10 @@ import { regionFactory, subnetFactory, vpcFactory, + linodeConfigFactory, + LinodeConfigInterfaceFactoryWithVPC, } from 'src/factories'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { mockCreateLinode, mockGetLinodeDetails, @@ -12,6 +15,7 @@ import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreateVPC, mockCreateVPCError, + mockGetSubnets, mockGetVPC, mockGetVPCs, } from 'support/intercepts/vpc'; @@ -25,12 +29,14 @@ import { randomString, } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { WARNING_ICON_UNRECOMMENDED_CONFIG } from 'src/features/VPCs/constants'; describe('Create Linode with VPCs', () => { /* * - Confirms UI flow to create a Linode with an existing VPC assigned using mock API data. * - Confirms that VPC assignment is reflected in create summary section. * - Confirms that outgoing API request contains expected VPC interface data. + * - Confirms newly assigned Linode does not have an unrecommended config notice inside VPC */ it('can assign existing VPCs during Linode Create flow', () => { const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); @@ -55,6 +61,27 @@ describe('Create Linode with VPCs', () => { region: linodeRegion.id, }); + const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + subnet_id: mockSubnet.id, + primary: true, + active: true, + }); + + const mockLinodeConfig = linodeConfigFactory.build({ + interfaces: [mockInterface], + }); + + const mockUpdatedSubnet = { + ...mockSubnet, + linodes: [ + { + id: mockLinode.id, + interfaces: [{ id: mockInterface.id, active: true }], + }, + ], + }; + mockGetVPCs([mockVPC]).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); mockCreateLinode(mockLinode).as('createLinode'); @@ -63,7 +90,7 @@ describe('Create Linode with VPCs', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -105,12 +132,27 @@ describe('Create Linode with VPCs', () => { expect(expectedVpcInterface['ipv4']).to.be.an('object').that.is.empty; expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); expect(expectedVpcInterface['purpose']).to.equal('vpc'); + // Confirm that VPC interfaces are always marked as the primary interface + expect(expectedVpcInterface['primary']).to.equal(true); }); // Confirm redirect to new Linode. cy.url().should('endWith', `/linodes/${mockLinode.id}`); // Confirm toast notification should appear on Linode create. ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + + // Confirm newly created Linode does not have unrecommended configuration notice + mockGetVPC(mockVPC).as('getVPC'); + mockGetSubnets(mockVPC.id, [mockUpdatedSubnet]).as('getSubnets'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( + 'getLinodeConfigs' + ); + + cy.visit(`/vpcs/${mockVPC.id}`); + cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); + cy.wait('@getLinodeConfigs'); + cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); }); /* @@ -118,6 +160,7 @@ describe('Create Linode with VPCs', () => { * - Creates a VPC and a subnet from within the Linode Create flow. * - Confirms that Cloud responds gracefully when VPC create API request fails. * - Confirms that outgoing API request contains correct VPC interface data. + * - Confirms newly assigned Linode does not have an unrecommended config notice inside VPC */ it('can assign new VPCs during Linode Create flow', () => { const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); @@ -145,12 +188,33 @@ describe('Create Linode with VPCs', () => { region: linodeRegion.id, }); + const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + subnet_id: mockSubnet.id, + primary: true, + active: true, + }); + + const mockLinodeConfig = linodeConfigFactory.build({ + interfaces: [mockInterface], + }); + + const mockUpdatedSubnet = { + ...mockSubnet, + linodes: [ + { + id: mockLinode.id, + interfaces: [{ id: mockInterface.id, active: true }], + }, + ], + }; + mockGetVPCs([]); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -232,11 +296,26 @@ describe('Create Linode with VPCs', () => { expect(expectedVpcInterface['ipv4']).to.deep.equal({ nat_1_1: 'any' }); expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); expect(expectedVpcInterface['purpose']).to.equal('vpc'); + // Confirm that VPC interfaces are always marked as the primary interface + expect(expectedVpcInterface['primary']).to.equal(true); }); cy.url().should('endWith', `/linodes/${mockLinode.id}`); // Confirm toast notification should appear on Linode create. ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + + // Confirm newly created Linode does not have unrecommended configuration notice + mockGetVPC(mockVPC).as('getVPC'); + mockGetSubnets(mockVPC.id, [mockUpdatedSubnet]).as('getSubnets'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( + 'getLinodeConfigs' + ); + + cy.visit(`/vpcs/${mockVPC.id}`); + cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); + cy.wait('@getLinodeConfigs'); + cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); }); /* 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 fbe81f6b9c6..37e5309715d 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -104,7 +104,7 @@ describe('Create Linode', () => { // Set Linode label, OS, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan( planConfig.planType, @@ -116,7 +116,7 @@ describe('Create Linode', () => { cy.get('[data-qa-linode-create-summary]') .scrollIntoView() .within(() => { - cy.findByText('Debian 11').should('be.visible'); + cy.findByText('Debian 12').should('be.visible'); cy.findByText(linodeRegion.label).should('be.visible'); cy.findByText(planConfig.planLabel).should('be.visible'); }); @@ -230,7 +230,7 @@ describe('Create Linode', () => { // Set Linode label, OS, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Accelerated', mockAcceleratedType[0].label); linodeCreatePage.setRootPassword(randomString(32)); @@ -239,7 +239,7 @@ describe('Create Linode', () => { cy.get('[data-qa-linode-create-summary]') .scrollIntoView() .within(() => { - cy.findByText('Debian 11').should('be.visible'); + cy.findByText('Debian 12').should('be.visible'); cy.findByText(`US, ${linodeRegion.label}`).should('be.visible'); cy.findByText(mockAcceleratedType[0].label).should('be.visible'); }); @@ -363,13 +363,17 @@ describe('Create Linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes', '@getVPCs']); + 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'); + fbtClick('Shared CPU'); getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); @@ -458,7 +462,7 @@ describe('Create Linode', () => { // Set Linode label, OS, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -487,4 +491,25 @@ describe('Create Linode', () => { // Confirm the createLinodeErrorMessage disappears. cy.findByText(`${createLinodeErrorMessage}`).should('not.exist'); }); + + it('shows correct validation errors if no backup or plan is selected', () => { + cy.visitWithLogin('/linodes/create'); + + // Navigate to Linode Create page "Backups" tab + cy.get('[role="tablist"]') + .should('be.visible') + .findByText('Backups') + .click(); + + // Submit without selecting any options + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm the correct validation errors show up on the page. + cy.findByText('You must select a Backup.').should('be.visible'); + cy.findByText('Plan is required.').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 d17336e1110..465afc41c42 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -1,5 +1,7 @@ import { linodeFactory, ipAddressFactory } from '@src/factories'; +import type { IPRange } from '@linode/api-v4'; + import { mockGetLinodeDetails, mockGetLinodeIPAddresses, @@ -9,19 +11,31 @@ import { mockUpdateIPAddress } from 'support/intercepts/networking'; import { ui } from 'support/ui'; describe('linode networking', () => { - /** - * - Confirms the success toast message after editing RDNS - */ - it('checks for the toast message upon editing an RDNS', () => { - const mockLinode = linodeFactory.build(); - const linodeIPv4 = mockLinode.ipv4[0]; - const mockRDNS = `${linodeIPv4}.ip.linodeusercontent.com`; - const ipAddress = ipAddressFactory.build({ - address: linodeIPv4, - linode_id: mockLinode.id, - rdns: mockRDNS, - }); + const mockLinode = linodeFactory.build(); + const linodeIPv4 = mockLinode.ipv4[0]; + const mockRDNS = `${linodeIPv4}.ip.linodeusercontent.com`; + const ipAddress = ipAddressFactory.build({ + address: linodeIPv4, + linode_id: mockLinode.id, + rdns: mockRDNS, + }); + const _ipv6Range: IPRange = { + prefix: 64, + range: '2fff:db08:e003:1::', + region: 'us-east', + route_target: '2600:3c02::f03c:92ff:fe9d:0f25', + }; + const ipv6Range = `${_ipv6Range.range}/${_ipv6Range.prefix}`; + const ipv6Address = ipAddressFactory.build({ + address: mockLinode.ipv6 ?? '2600:3c00::f03c:92ff:fee2:6c40/64', + gateway: 'fe80::1', + linode_id: mockLinode.id, + prefix: 64, + subnet_mask: 'ffff:ffff:ffff:ffff::', + type: 'ipv6', + }); + beforeEach(() => { mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); mockGetLinodeFirewalls(mockLinode.id, []).as('getLinodeFirewalls'); mockGetLinodeIPAddresses(mockLinode.id, { @@ -31,12 +45,22 @@ describe('linode networking', () => { shared: [], reserved: [], }, + ipv6: { + global: [_ipv6Range], + link_local: ipv6Address, + slaac: ipv6Address, + }, }).as('getLinodeIPAddresses'); mockUpdateIPAddress(linodeIPv4, mockRDNS).as('updateIPAddress'); cy.visitWithLogin(`linodes/${mockLinode.id}/networking`); cy.wait(['@getLinode', '@getLinodeFirewalls', '@getLinodeIPAddresses']); + }); + /** + * - Confirms the success toast message after editing RDNS + */ + it('checks for the toast message upon editing an RDNS', () => { cy.findByLabelText('IPv4 Addresses') .should('be.visible') .within(() => { @@ -80,4 +104,31 @@ describe('linode networking', () => { // confirm RDNS toast message ui.toast.assertMessage(`Successfully updated RDNS for ${linodeIPv4}`); }); + + it('validates the action menu title (aria-label) for the IP address in the table row', () => { + // Set the viewport to 1279px x 800px (width < 1280px) to ensure the Action menu is visible. + cy.viewport(1279, 800); + + // Ensure the action menu has the correct aria-label for the IP address. + cy.get(`[data-qa-ip="${linodeIPv4}"]`) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('IPv4 – Public').should('be.visible'); + ui.actionMenu + .findByTitle(`Action menu for IP Address ${linodeIPv4}`) + .should('be.visible'); + }); + + // Ensure the action menu has the correct aria-label for the IP Range. + cy.get(`[data-qa-ip="${ipv6Range}"]`) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('IPv6 – Range').should('be.visible'); + ui.actionMenu + .findByTitle(`Action menu for IP Address ${_ipv6Range.range}`) + .should('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 88ff834b419..79268f5d555 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -208,7 +208,7 @@ describe('linode storage tab', () => { * - Confirms that Cloud Manager UI automatically updates to reflect resize. */ it('resize disk', () => { - const diskName = 'Debian 10 Disk'; + const diskName = 'Debian 12 Disk'; cy.defer(() => createTestLinode({ image: null }, { securityMethod: 'powered_off' }) ).then((linode: Linode) => { 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 157e43dda05..296ac8ae6b3 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -13,6 +13,7 @@ import { mockRebuildLinodeError, } from 'support/intercepts/linodes'; import { createTestLinode } from 'support/util/linodes'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; /** * Creates a Linode and StackScript. @@ -126,7 +127,9 @@ describe('rebuild linode', () => { interceptRebuildLinode(linode.id).as('linodeRebuild'); cy.visitWithLogin(`/linodes/${linode.id}`); - cy.findByText('RUNNING').should('be.visible'); + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); openRebuildDialog(linode.label); findRebuildDialog(linode.label).within(() => { @@ -182,7 +185,9 @@ describe('rebuild linode', () => { interceptRebuildLinode(linode.id).as('linodeRebuild'); interceptGetStackScripts().as('getStackScripts'); cy.visitWithLogin(`/linodes/${linode.id}`); - cy.findByText('RUNNING').should('be.visible'); + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); openRebuildDialog(linode.label); findRebuildDialog(linode.label).within(() => { @@ -194,6 +199,7 @@ describe('rebuild linode', () => { cy.wait('@getStackScripts'); cy.findByLabelText('Search by Label, Username, or Description') + .scrollIntoView() .should('be.visible') .type(`${stackScriptName}`); @@ -255,7 +261,9 @@ describe('rebuild linode', () => { ).then(([stackScript, linode]) => { interceptRebuildLinode(linode.id).as('linodeRebuild'); cy.visitWithLogin(`/linodes/${linode.id}`); - cy.findByText('RUNNING').should('be.visible'); + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); openRebuildDialog(linode.label); findRebuildDialog(linode.label).within(() => { @@ -266,6 +274,7 @@ describe('rebuild linode', () => { .click(); cy.findByLabelText('Search by Label, Username, or Description') + .scrollIntoView() .should('be.visible') .type(`${stackScript.label}`); 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 07e1c262ff0..636dc15a9c9 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -11,6 +11,7 @@ import { } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -63,7 +64,9 @@ describe('Rescue Linodes', () => { cy.wait('@getLinode'); // Wait for Linode to boot. - cy.findByText('RUNNING').should('be.visible'); + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); // Open rescue dialog using action menu.. ui.actionMenu diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index 59137de9022..4242ba01f9d 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -1,9 +1,9 @@ import { createTestLinode } from 'support/util/linodes'; -import { containsVisible, fbtVisible, getClick } from 'support/helpers'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; import { interceptLinodeResize } from 'support/intercepts/linodes'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; authenticate(); describe('resize linode', () => { @@ -22,15 +22,34 @@ describe('resize linode', () => { ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 8 GB'); - getClick('[id="g6-standard-4"]'); - cy.get('[data-qa-radio="warm"]').find('input').should('be.checked'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); - // TODO: Unified Migration: [M3-7115] - Replace with copy from API '../notifications.py' + ui.dialog + .findByTitle(`Resize Linode ${linode.label}`) + .should('be.visible') + .within(() => { + // Click "Shared CPU" plan tab, and select 8 GB plan. + ui.tabList.findTabByTitle('Shared CPU').should('be.visible').click(); + + cy.contains('Linode 8 GB').should('be.visible').click(); + + // Select warm resize option, and enter Linode label in type-to-confirm field. + cy.findByText('Warm resize') + .scrollIntoView() + .should('be.visible') + .click(); + + cy.findByLabelText('Linode Label').type(linode.label); + + // Click "Resize Linode". + // The Resize Linode button remains disabled while the Linode is provisioning, + // so we have to wait for that to complete before the button becomes enabled. + ui.button + .findByTitle('Resize Linode') + .should('be.enabled', { timeout: LINODE_CREATE_TIMEOUT }) + .click(); + }); + + cy.wait('@linodeResize'); cy.contains( "Your linode will be warm resized and will automatically attempt to power off and restore to it's previous state." ).should('be.visible'); @@ -47,16 +66,32 @@ describe('resize linode', () => { ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 8 GB'); - getClick('[id="g6-standard-4"]'); - cy.get('[data-qa-radio="cold"]').click(); - cy.get('[data-qa-radio="cold"]').find('input').should('be.checked'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); - // TODO: Unified Migration: [M3-7115] - Replace with copy from API '../notifications.py' + ui.dialog + .findByTitle(`Resize Linode ${linode.label}`) + .should('be.visible') + .within(() => { + ui.tabList.findTabByTitle('Shared CPU').should('be.visible').click(); + + cy.contains('Linode 8 GB').should('be.visible').click(); + + cy.findByText('Cold resize') + .scrollIntoView() + .should('be.visible') + .click(); + + cy.findByLabelText('Linode Label').type(linode.label); + + // Click "Resize Linode". + // The Resize Linode button remains disabled while the Linode is provisioning, + // so we have to wait for that to complete before the button becomes enabled. + ui.button + .findByTitle('Resize Linode') + .should('be.enabled', { timeout: LINODE_CREATE_TIMEOUT }) + .click(); + }); + + cy.wait('@linodeResize'); cy.contains( 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' ).should('be.visible'); @@ -69,49 +104,58 @@ describe('resize linode', () => { // when attempting to interact with it shortly after booting up when the // Linode is attached to a Cloud Firewall. cy.defer(() => - createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + createTestLinode( + { booted: false }, + { securityMethod: 'vlan_no_internet' } + ) ).then((linode) => { + interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('OFFLINE', { timeout: LINODE_CREATE_TIMEOUT }); - // Turn off the linode to resize the disk - ui.button.findByTitle('Power Off').should('be.visible').click(); + ui.actionMenu + .findByTitle(`Action menu for Linode ${linode.label}`) + .click(); + + ui.actionMenuItem + .findByTitle('Resize') + .should('be.visible') + .should('be.enabled') + .click(); ui.dialog - .findByTitle(`Power Off Linode ${linode.label}?`) + .findByTitle(`Resize Linode ${linode.label}`) .should('be.visible') - .then(() => { - ui.button - .findByTitle(`Power Off Linode`) - .should('be.visible') - .click(); - }); + .within(() => { + ui.tabList.findTabByTitle('Shared CPU').should('be.visible').click(); - containsVisible('OFFLINE'); + cy.contains('Linode 8 GB').should('be.visible').click(); - interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 8 GB'); - getClick('[id="g6-standard-4"]'); - // We disable the options if the linode is offline, and proceed with a - // cold migration even though warm is selected by default. - cy.get('[data-qa-radio="warm"]').find('input').should('be.disabled'); - cy.get('[data-qa-radio="cold"]') - .find('input') - .should('be.checked') - .should('be.disabled'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); + // When a Linode is powered off, only cold resizes are available. + // Confirm that the UI reflects this by ensuring the cold resize + // option is checked and both radio buttons are disabled. + cy.findByLabelText('Warm resize', { exact: false }) + .should('be.disabled') + .should('not.be.checked'); + + cy.findByLabelText('Cold resize') + .should('be.disabled') + .should('be.checked'); + + // Enter Linode label in type-to-confirm field and proceed with resize. + cy.findByLabelText('Linode Label').type(linode.label); - // TODO: Unified Migration: [M3-7115] - Replace with copy from API '../notifications.py' + ui.button.findByTitle('Resize Linode').should('be.enabled').click(); + }); + + cy.wait('@linodeResize'); cy.contains( 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' ).should('be.visible'); }); }); - it('resizes a linode by decreasing size', () => { + it.only('resizes a linode by decreasing size', () => { // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the @@ -129,13 +173,28 @@ describe('resize linode', () => { // resizing the disk to the requested size first. interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 2 GB'); - getClick('[id="g6-standard-1"]'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); + + ui.dialog + .findByTitle(`Resize Linode ${linode.label}`) + .should('be.visible') + .within(() => { + ui.tabList.findTabByTitle('Shared CPU').should('be.visible').click(); + + cy.contains('Linode 2 GB').should('be.visible').click(); + cy.findByLabelText('Linode Label').type(linode.label); + + // Click "Resize Linode". + // The Resize Linode button remains disabled while the Linode is provisioning, + // so we have to wait for that to complete before the button becomes enabled. + ui.button + .findByTitle('Resize Linode') + .should('be.enabled', { timeout: LINODE_CREATE_TIMEOUT }) + .click(); + }); + + // Confirm that API responds with an error message when attempting to + // decrease the size of the Linode while its disk is too large. cy.wait('@linodeResize'); - // Failed to reduce the size of the linode cy.contains( 'The current disk size of your Linode is too large for the new service plan. Please resize your disk to accommodate the new plan. You can read our Resize Your Linode guide for more detailed instructions.' ) @@ -144,9 +203,9 @@ describe('resize linode', () => { // Normal flow when resizing a linode to a smaller size after first resizing // its disk. - cy.visitWithLogin(`/linodes/${linode.id}`); + cy.visitWithLogin(`/linodes/${linode.id}/storage`); - // Turn off the linode to resize the disk + // Power off the Linode to resize the disk ui.button.findByTitle('Power Off').should('be.visible').click(); ui.dialog @@ -159,20 +218,28 @@ describe('resize linode', () => { .click(); }); - containsVisible('OFFLINE'); - - cy.visitWithLogin(`linodes/${linode.id}/storage`); - fbtVisible(diskName); - - cy.get(`[data-qa-disk="${diskName}"]`).within(() => { - cy.contains('Resize').should('be.enabled').click(); - }); + // Wait for Linode to power off, then resize the disk to 50 GB. + cy.findByText('OFFLINE', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + cy.findByText(diskName) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Resize') + .should('be.visible') + .should('be.enabled') + .click(); + }); ui.drawer .findByTitle(`Resize ${diskName}`) .should('be.visible') .within(() => { - cy.get('[id="size"]').should('be.visible').click().clear().type(size); + cy.contains('Size (required)').should('be.visible').click(); + + cy.focused().clear().type(size); ui.buttonGroup .findButtonByTitle('Resize') @@ -181,18 +248,41 @@ describe('resize linode', () => { .click(); }); - // Wait until the disk resize is done. + // Wait until the disk resize is done, then initiate another resize attempt. ui.toast.assertMessage( `Disk ${diskName} on Linode ${linode.label} has been resized.` ); - interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 2 GB'); - getClick('[id="g6-standard-1"]'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); + ui.actionMenu + .findByTitle(`Action menu for Linode ${linode.label}`) + .click(); + + ui.actionMenuItem + .findByTitle('Resize') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Resize Linode ${linode.label}`) + .should('be.visible') + .within(() => { + ui.tabList.findTabByTitle('Shared CPU').should('be.visible').click(); + + cy.contains('Linode 2 GB').should('be.visible').click(); + cy.findByLabelText('Linode Label').type(linode.label); + + // Click "Resize Linode". + // The Resize Linode button remains disabled while the Linode is provisioning, + // so we have to wait for that to complete before the button becomes enabled. + ui.button + .findByTitle('Resize Linode') + .should('be.enabled', { timeout: LINODE_CREATE_TIMEOUT }) + .click(); + }); + + // Confirm that the resize API request succeeds now that the Linode's disk + // size has been decreased. cy.wait('@linodeResize'); cy.contains( 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' diff --git a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts index dcc0b7c133a..fff6fce7576 100644 --- a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts @@ -15,30 +15,30 @@ describe('Search Linodes', () => { * - Confirm that linodes are searchable and filtered in the UI. */ it('create a linode and make sure it shows up in the table and is searchable in main search tool', () => { - cy.defer(() => createTestLinode({ booted: true })).then( - (linode: Linode) => { - cy.visitWithLogin('/linodes'); - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.contains('Running').should('be.visible'); - }); + cy.defer(() => + createTestLinode({ booted: true }, { waitForBoot: true }) + ).then((linode: Linode) => { + cy.visitWithLogin('/linodes'); + cy.get(`[data-qa-linode="${linode.label}"]`) + .should('be.visible') + .within(() => { + cy.contains('Running').should('be.visible'); + }); - // Confirm that linode is listed on the landing page. - cy.findByText(linode.label).should('be.visible'); + // Confirm that linode is listed on the landing page. + cy.findByText(linode.label).should('be.visible'); - // Use the main search bar to search and filter linode by label - cy.get('[id="main-search"').type(linode.label); - ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); + // Use the main search bar to search and filter linode by label + cy.get('[id="main-search"').type(linode.label); + ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); - // Use the main search bar to search and filter linode by id value - cy.get('[id="main-search"').clear().type(`${linode.id}`); - ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); + // Use the main search bar to search and filter linode by id value + cy.get('[id="main-search"').clear().type(`${linode.id}`); + ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); - // Use the main search bar to search and filter linode by id: pattern - cy.get('[id="main-search"').clear().type(`id:${linode.id}`); - ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); - } - ); + // Use the main search bar to search and filter linode by id: pattern + cy.get('[id="main-search"').clear().type(`id:${linode.id}`); + ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts index c0e03a30ea6..173a1fe4ec3 100644 --- a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts @@ -1,12 +1,13 @@ import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import type { Linode } from '@linode/api-v4'; authenticate(); describe('switch linode state', () => { - beforeEach(() => { + before(() => { cleanUp(['linodes']); cy.tag('method:e2e'); }); @@ -29,7 +30,9 @@ describe('switch linode state', () => { cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - cy.contains('Running').should('be.visible'); + cy.contains('Running', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); }); ui.actionMenu @@ -73,7 +76,9 @@ describe('switch linode state', () => { createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); - cy.contains('RUNNING').should('be.visible'); + cy.contains('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); cy.findByText(linode.label).should('be.visible'); cy.findByText('Power Off').should('be.visible').click(); @@ -105,7 +110,9 @@ describe('switch linode state', () => { cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - cy.contains('Offline').should('be.visible'); + cy.contains('Offline', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); }); ui.actionMenu @@ -130,7 +137,9 @@ describe('switch linode state', () => { .should('be.visible') .within(() => { cy.contains('Booting').should('be.visible'); - cy.contains('Running', { timeout: 300000 }).should('be.visible'); + cy.contains('Running', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); }); } ); @@ -146,7 +155,9 @@ describe('switch linode state', () => { cy.defer(() => createTestLinode({ booted: false })).then( (linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); - cy.contains('OFFLINE').should('be.visible'); + cy.contains('OFFLINE', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); cy.findByText(linode.label).should('be.visible'); cy.findByText('Power On').should('be.visible').click(); @@ -184,7 +195,9 @@ describe('switch linode state', () => { cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - cy.contains('Running').should('be.visible'); + cy.contains('Running', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); }); ui.actionMenu @@ -228,7 +241,9 @@ describe('switch linode state', () => { createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); - cy.contains('RUNNING').should('be.visible'); + cy.contains('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); cy.findByText(linode.label).should('be.visible'); cy.findByText('Reboot').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts index 08c14da2a33..648b6516da4 100644 --- a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts @@ -1,6 +1,7 @@ import { createTestLinode } from 'support/util/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { authenticate } from 'support/api/authentication'; import { randomLabel } from 'support/util/random'; @@ -15,7 +16,9 @@ describe('update linode label', () => { cy.defer(() => createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); - cy.contains('RUNNING').should('be.visible'); + cy.contains('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); cy.get(`[aria-label="Edit ${linode.label}"]`).click(); cy.get(`[id="edit-${linode.label}-label"]`) @@ -32,7 +35,9 @@ describe('update linode label', () => { cy.defer(() => createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); - cy.contains('RUNNING').should('be.visible'); + cy.contains('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); cy.visitWithLogin(`/linodes/${linode.id}/settings`); cy.get('[id="label"]').click().clear().type(`${newLinodeLabel}{enter}`); diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts index 0c29d1aecd8..a8bf39db270 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 @@ -5,8 +5,6 @@ import { mockGetObjectStorageEndpoints, mockGetBucketAccess, } from 'support/intercepts/object-storage'; -import { checkRateLimitsTable } from 'support/util/object-storage-gen2'; -import { ui } from 'support/ui'; import { accountFactory, objectStorageBucketFactoryGen2, @@ -143,96 +141,4 @@ describe('Object Storage Gen 2 bucket details tabs', () => { }); }); }); - - describe('Properties tab', () => { - ['E0', 'E1'].forEach((endpoint: ObjectStorageEndpointTypes) => { - /** - * Parameterized test for buckets with endpoint types of E0 and E1 - * - Confirms the Properties tab is visible - * - Confirms there is no bucket rate limits table - */ - it(`confirms the Properties tab does not have a rate limits table for buckets with endpoint type ${endpoint}`, () => { - const { mockBucket, mockEndpoint } = createMocksBasedOnEndpointType( - endpoint - ); - const { cluster, label } = mockBucket; - - mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as( - 'getBucketsForRegion' - ); - mockGetObjectStorageEndpoints([mockEndpoint]).as( - 'getObjectStorageEndpoints' - ); - - cy.visitWithLogin( - `/object-storage/buckets/${cluster}/${label}/properties` - ); - cy.wait([ - '@getFeatureFlags', - '@getAccount', - '@getObjectStorageEndpoints', - '@getBucketsForRegion', - ]); - - cy.findByText('Bucket Rate Limits').should('be.visible'); - // confirms helper text - cy.contains( - 'This endpoint type supports up to 750 Requests Per Second (RPS). Understand bucket rate limits' - ).should('be.visible'); - - // confirm bucket rate limit table should not exist - cy.get('[data-testid="bucket-rate-limit-table"]').should('not.exist'); - - // confirm 'Save' button is disabled (for now) - ui.button - .findByAttribute('label', 'Save') - .should('be.visible') - .should('be.disabled'); - }); - }); - - ['E2', 'E3'].forEach((endpoint: ObjectStorageEndpointTypes) => { - /** - * Parameterized test for buckets with endpoint types of E2 and E3 - * - Confirms the Properties tab is visible - * - Confirms bucket rates limit table exists - */ - it(`confirms the Properties tab and rate limits table for buckets with endpoint type ${endpoint}`, () => { - const { mockBucket, mockEndpoint } = createMocksBasedOnEndpointType( - endpoint - ); - const { cluster, label } = mockBucket; - - mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as( - 'getBucketsForRegion' - ); - mockGetObjectStorageEndpoints([mockEndpoint]).as( - 'getObjectStorageEndpoints' - ); - - cy.visitWithLogin( - `/object-storage/buckets/${cluster}/${label}/properties` - ); - cy.wait([ - '@getFeatureFlags', - '@getAccount', - '@getObjectStorageEndpoints', - '@getBucketsForRegion', - ]); - - cy.findByText('Bucket Rate Limits'); - cy.contains( - 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. Understand bucket rate limits.' - ).should('be.visible'); - // Confirm bucket rate limits table exists with correct values - checkRateLimitsTable(endpoint); - - // confirm 'Save' button is disabled (for now) - ui.button - .findByAttribute('label', 'Save') - .should('be.visible') - .should('be.disabled'); - }); - }); - }); }); 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 5cfce4ac762..7d3b490d032 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -128,9 +128,9 @@ const fillOutLinodeForm = (label: string, regionName: string) => { * @returns Promise that resolves to the new Image. */ const createLinodeAndImage = async () => { - // 1.5GB - // Shout out to Debian for fitting on a 1.5GB disk. - const resizedDiskSize = 1536; + // 2GB + // Shout out to Debian for fitting on a 2GB disk. + const resizedDiskSize = 2048; const linode = await createTestLinode( createLinodeRequestFactory.build({ label: randomLabel(), diff --git a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts index e65a582243b..146233cae6c 100644 --- a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts @@ -1,14 +1,11 @@ -import type { VolumeRequestPayload } from '@linode/api-v4'; -import { createVolume } from '@linode/api-v4/lib/volumes'; import { Volume } from '@linode/api-v4'; import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; import { interceptCloneVolume } from 'support/intercepts/volumes'; -import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; -import { pollVolumeStatus } from 'support/util/polling'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { createActiveVolume } from 'support/api/volumes'; // 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. @@ -17,19 +14,6 @@ const pageSizeOverride = { PAGE_SIZE: 100, }; -/** - * Creates a Volume and waits for it to become active. - * - * @param volumeRequest - Volume create request payload. - * - * @returns Promise that resolves to created Volume. - */ -const createActiveVolume = async (volumeRequest: VolumeRequestPayload) => { - const volume = await createVolume(volumeRequest); - await pollVolumeStatus(volume.id, 'active', new SimpleBackoffMethod(10000)); - return volume; -}; - authenticate(); describe('volume clone flow', () => { before(() => { 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 3c645ace145..0ca5c8ce4e2 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 @@ -11,6 +11,7 @@ import { } from 'support/intercepts/linodes'; import { mockCreateVolume, + mockGetVolume, mockGetVolumes, mockDetachVolume, mockGetVolumeTypesError, @@ -85,6 +86,7 @@ describe('volumes', () => { mockGetVolumes([]).as('getVolumes'); mockCreateVolume(mockVolume).as('createVolume'); + mockGetVolume(mockVolume).as('getVolume'); mockGetVolumeTypes(mockVolumeTypes).as('getVolumeTypes'); cy.visitWithLogin('/volumes', { @@ -114,7 +116,7 @@ describe('volumes', () => { mockGetVolumes([mockVolume]).as('getVolumes'); ui.button.findByTitle('Create Volume').should('be.visible').click(); - cy.wait(['@createVolume', '@getVolumes']); + cy.wait(['@createVolume', '@getVolume', '@getVolumes']); validateBasicVolume(mockVolume.label); ui.actionMenu @@ -193,6 +195,7 @@ describe('volumes', () => { mockDetachVolume(mockAttachedVolume.id).as('detachVolume'); mockGetVolumes([mockAttachedVolume]).as('getAttachedVolumes'); + mockGetVolume(mockAttachedVolume).as('getVolume'); cy.visitWithLogin('/volumes', { preferenceOverrides, localStorageOverrides, @@ -209,6 +212,8 @@ describe('volumes', () => { ui.actionMenuItem.findByTitle('Detach').click(); + cy.wait('@getVolume'); + ui.dialog .findByTitle(`Detach Volume ${mockAttachedVolume.label}?`) .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts index 6828618fb70..be6d7f8dec1 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -1,4 +1,4 @@ -import { createVolume } from '@linode/api-v4/lib/volumes'; +import { createVolume, VolumeRequestPayload } from '@linode/api-v4/lib/volumes'; import { Volume } from '@linode/api-v4'; import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; @@ -7,6 +7,8 @@ import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; +import { SimpleBackoffMethod } from 'support/util/backoff'; +import { pollVolumeStatus } from 'support/util/polling'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -15,6 +17,19 @@ const pageSizeOverride = { PAGE_SIZE: 100, }; +/** + * Creates a Volume and waits for it to become active. + * + * @param volumeRequest - Volume create request payload. + * + * @returns Promise that resolves to created Volume. + */ +const createActiveVolume = async (volumeRequest: VolumeRequestPayload) => { + const volume = await createVolume(volumeRequest); + await pollVolumeStatus(volume.id, 'active', new SimpleBackoffMethod(10000)); + return volume; +}; + authenticate(); describe('volume delete flow', () => { before(() => { @@ -37,7 +52,7 @@ describe('volume delete flow', () => { region: chooseRegion().id, }); - cy.defer(() => createVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createActiveVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { interceptDeleteVolume(volume.id).as('deleteVolume'); cy.visitWithLogin('/volumes', { diff --git a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts index 70f4840bbad..25b5e128405 100644 --- a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts @@ -1,10 +1,12 @@ -import { createVolume } from '@linode/api-v4/lib/volumes'; import { Volume } from '@linode/api-v4'; + import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; +import { ui } from 'support/ui'; +import { createActiveVolume } from 'support/api/volumes'; authenticate(); describe('volume update flow', () => { @@ -16,18 +18,17 @@ describe('volume update flow', () => { }); /* - * - Confirms that volume label and tags can be changed from the Volumes landing page. + * - Confirms that volume label can be changed from the Volumes landing page. */ - it("updates a volume's label and tags", () => { + it("updates a volume's label", () => { const volumeRequest = volumeRequestPayloadFactory.build({ label: randomLabel(), region: chooseRegion().id, }); const newLabel = randomLabel(); - const newTags = [randomLabel(5), randomLabel(5), randomLabel(5)]; - cy.defer(() => createVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createActiveVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { cy.visitWithLogin('/volumes', { // Temporarily force volume table to show up to 100 results per page. @@ -43,10 +44,15 @@ describe('volume update flow', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('Edit').click(); + cy.findByText('active').should('be.visible'); }); + ui.actionMenu + .findByTitle(`Action menu for Volume ${volume.label}`) + .should('be.visible') + .click(); + cy.get('[data-testid="Edit"]').click(); - // Enter new label and add tags, click "Save Changes". + // Enter new label, click "Save Changes". cy.get('[data-qa-drawer="true"]').within(() => { cy.findByText('Edit Volume').should('be.visible'); cy.findByDisplayValue(volume.label) @@ -54,6 +60,67 @@ describe('volume update flow', () => { .click() .type(`{selectall}{backspace}${newLabel}`); + cy.findByText('Save Changes').should('be.visible').click(); + }); + + // Confirm new label is applied, click "Edit" to re-open drawer. + cy.findByText(newLabel).should('be.visible'); + ui.actionMenu + .findByTitle(`Action menu for Volume ${newLabel}`) + .should('be.visible') + .click(); + cy.get('[data-testid="Edit"]').click(); + + // Confirm new label is shown. + cy.get('[data-qa-drawer="true"]').within(() => { + cy.findByText('Edit Volume').should('be.visible'); + cy.findByDisplayValue(newLabel).should('be.visible'); + }); + } + ); + }); + + /* + * - Confirms that volume tags can be changed from the Volumes landing page. + */ + it("updates volume's tags", () => { + const volumeRequest = volumeRequestPayloadFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + const newTags = [randomLabel(5), randomLabel(5), randomLabel(5)]; + + cy.defer(() => createActiveVolume(volumeRequest), 'creating volume').then( + (volume: Volume) => { + cy.visitWithLogin('/volumes', { + // Temporarily force volume table to show up to 100 results per page. + // This is a workaround while we wait to get stuck volumes removed. + // @TODO Remove local storage override when stuck volumes are removed from test accounts. + localStorageOverrides: { + PAGE_SIZE: 100, + }, + }); + + // Confirm that volume is listed on landing page, click "Edit" to open drawer. + cy.findByText(volume.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('active').should('be.visible'); + }); + + ui.actionMenu + .findByTitle(`Action menu for Volume ${volume.label}`) + .should('be.visible') + .click(); + + cy.get('[data-testid="Manage Tags"]').click(); + + // Add tags, click "Save Changes". + cy.get('[data-qa-drawer="true"]').within(() => { + cy.findByText('Manage Volume Tags').should('be.visible'); + cy.findByPlaceholderText('Type to choose or create a tag.') .should('be.visible') .click() @@ -62,18 +129,18 @@ describe('volume update flow', () => { cy.findByText('Save Changes').should('be.visible').click(); }); - // Confirm new label is applied, click "Edit" to re-open drawer. - cy.findByText(newLabel) + // Confirm new tags are shown, click "Manage Volume Tags" to re-open drawer. + cy.findByText(volumeRequest.label).should('be.visible'); + + ui.actionMenu + .findByTitle(`Action menu for Volume ${volume.label}`) .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Edit').click(); - }); + .click(); + + cy.get('[data-testid="Manage Tags"]').click(); - // Confirm new label and tags are shown. cy.get('[data-qa-drawer="true"]').within(() => { - cy.findByText('Edit Volume').should('be.visible'); - cy.findByDisplayValue(newLabel).should('be.visible'); + cy.findByText('Manage Volume Tags').should('be.visible'); // Click the tags input field to see all the selected tags cy.findByRole('combobox').should('be.visible').click(); @@ -85,4 +152,8 @@ describe('volume update flow', () => { } ); }); + + after(() => { + cleanUp(['tags', 'volumes']); + }); }); 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 f6d807e4c15..27f5e379b84 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -10,7 +10,11 @@ import { mockGetLinodeDisks, mockGetLinodeVolumes, } from 'support/intercepts/linodes'; -import { mockMigrateVolumes, mockGetVolumes } from 'support/intercepts/volumes'; +import { + mockMigrateVolumes, + mockGetVolumes, + mockGetVolume, +} from 'support/intercepts/volumes'; import { ui } from 'support/ui'; describe('volume upgrade/migration', () => { @@ -23,6 +27,7 @@ describe('volume upgrade/migration', () => { }); mockGetVolumes([volume]).as('getVolumes'); + mockGetVolume(volume).as('getVolume'); mockMigrateVolumes().as('migrateVolumes'); mockGetNotifications([migrationScheduledNotification]).as( 'getNotifications' @@ -53,7 +58,7 @@ describe('volume upgrade/migration', () => { .click(); }); - cy.wait(['@migrateVolumes', '@getNotifications']); + cy.wait(['@migrateVolumes', '@getVolume', '@getNotifications']); cy.findByText('UPGRADE PENDING').should('be.visible'); 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 86601275d26..31c9522f730 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts @@ -71,7 +71,8 @@ describe('VPC create flow', () => { subnets: mockSubnets, }); - const ipValidationErrorMessage = 'The IPv4 range must be in CIDR format'; + const ipValidationErrorMessage1 = 'A subnet must have an IPv4 range.'; + const ipValidationErrorMessage2 = 'The IPv4 range must be in CIDR format.'; const vpcCreationErrorMessage = 'An unknown error has occurred.'; const totalSubnetUniqueLinodes = getUniqueLinodesFromSubnets(mockSubnets); @@ -111,7 +112,7 @@ describe('VPC create flow', () => { .should('be.enabled') .click(); - cy.findByText(ipValidationErrorMessage).should('be.visible'); + cy.findByText(ipValidationErrorMessage1).should('be.visible'); // Enter a random non-IP address string to further test client side validation. cy.findByText('Subnet IP Address Range') @@ -126,7 +127,7 @@ describe('VPC create flow', () => { .should('be.enabled') .click(); - cy.findByText(ipValidationErrorMessage).should('be.visible'); + cy.findByText(ipValidationErrorMessage2).should('be.visible'); // Enter a valid IP address with an invalid network prefix to further test client side validation. cy.findByText('Subnet IP Address Range') @@ -141,7 +142,7 @@ describe('VPC create flow', () => { .should('be.enabled') .click(); - cy.findByText(ipValidationErrorMessage).should('be.visible'); + cy.findByText(ipValidationErrorMessage2).should('be.visible'); // Replace invalid IP address range with valid range. cy.findByText('Subnet IP Address Range') @@ -180,10 +181,12 @@ describe('VPC create flow', () => { getSubnetNodeSection(1) .should('be.visible') .within(() => { - cy.findByText('Label is required').should('be.visible'); + cy.findByText('Label must be between 1 and 64 characters.').should( + 'be.visible' + ); // Delete subnet. - cy.findByLabelText('Remove Subnet') + cy.findByLabelText('Remove Subnet 1') .should('be.visible') .should('be.enabled') .click(); @@ -300,7 +303,7 @@ describe('VPC create flow', () => { getSubnetNodeSection(0) .should('be.visible') .within(() => { - cy.findByLabelText('Remove Subnet') + cy.findByLabelText('Remove Subnet 0') .should('be.visible') .should('be.enabled') .click(); 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 dd7bd443c96..f82c9d4650c 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 @@ -8,11 +8,21 @@ import { mockEditSubnet, mockGetSubnets, } from 'support/intercepts/vpc'; -import { subnetFactory, vpcFactory } from '@src/factories'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockGetLinodeDetails } from 'support/intercepts/linodes'; +import { + linodeFactory, + linodeConfigFactory, + LinodeConfigInterfaceFactoryWithVPC, + subnetFactory, + vpcFactory, +} from '@src/factories'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; import type { VPC } from '@linode/api-v4'; import { getRegionById } from 'support/util/regions'; import { ui } from 'support/ui'; +import { WARNING_ICON_UNRECOMMENDED_CONFIG } from 'src/features/VPCs/constants'; describe('VPC details page', () => { /** @@ -266,4 +276,117 @@ describe('VPC details page', () => { cy.findByText('No Subnets are assigned.'); cy.findByText(mockEditedSubnet.label).should('not.exist'); }); + + /** + * - Confirms UI for Linode with a recommended config (no notice displayed) + */ + it('does not display an unrecommended config notice for a Linode', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockInterfaceId = randomNumber(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [ + { + id: mockLinode.id, + interfaces: [{ id: mockInterfaceId, active: true }], + }, + ], + ipv4: '10.0.0.0/24', + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + subnet_id: mockSubnet.id, + primary: true, + active: true, + }); + + const mockLinodeConfig = linodeConfigFactory.build({ + interfaces: [mockInterface], + }); + + mockGetVPC(mockVPC).as('getVPC'); + mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( + 'getLinodeConfigs' + ); + + cy.visitWithLogin(`/vpcs/${mockVPC.id}`); + cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); + cy.wait('@getLinodeConfigs'); + cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); + }); + + /** + * - Confirms UI for Linode with an unrecommended config (notice displayed) + */ + it('displays an unrecommended config notice for a Linode', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockInterfaceId = randomNumber(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [ + { + id: mockLinode.id, + interfaces: [{ id: mockInterfaceId, active: true }], + }, + ], + ipv4: '10.0.0.0/24', + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + id: mockInterfaceId, + vpc_id: mockVPC.id, + subnet_id: mockSubnet.id, + primary: false, + active: true, + }); + + const mockLinodeConfig = linodeConfigFactory.build({ + interfaces: [mockInterface], + }); + + mockGetVPC(mockVPC).as('getVPC'); + mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( + 'getLinodeConfigs' + ); + + cy.visitWithLogin(`/vpcs/${mockVPC.id}`); + cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); + cy.wait('@getLinodeConfigs'); + cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('exist'); + }); }); diff --git a/packages/manager/cypress/support/api/lke.ts b/packages/manager/cypress/support/api/lke.ts index 14ed8339749..fa99f1eed71 100644 --- a/packages/manager/cypress/support/api/lke.ts +++ b/packages/manager/cypress/support/api/lke.ts @@ -1,29 +1,39 @@ import { - KubeNodePoolResponse, - KubernetesCluster, - PoolNodeResponse, deleteKubernetesCluster, getKubernetesClusters, getNodePools, } from '@linode/api-v4'; +import { DateTime } from 'luxon'; import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; -import { DateTime } from 'luxon'; import { isTestLabel } from './common'; +import type { + KubeNodePoolResponse, + KubernetesCluster, + PoolNodeResponse, +} from '@linode/api-v4'; +import type { LinodeTypeClass } from '@linode/api-v4/lib/linodes/types'; + /** * Describes an LKE plan as shown in Cloud Manager. */ export interface LkePlanDescription { - // / Plan size, GB. + /** Number of nodes in the plan. */ + nodeCount: number; + /** Name of the plan. */ + planName: string; + /** Plan size, GB. */ size: number; - - // / Label for tab containing the plan in creation screen. + /** Label for tab containing the plan in creation screen. */ tab: string; + /** Type of plan. */ + type: LinodeTypeClass; +} - // / Type of plan. - type: string; +export interface LkePlanDescriptionAPL extends LkePlanDescription { + disabled: boolean; } /* diff --git a/packages/manager/cypress/support/api/volumes.ts b/packages/manager/cypress/support/api/volumes.ts index a52e4784f13..0ef03c9d816 100644 --- a/packages/manager/cypress/support/api/volumes.ts +++ b/packages/manager/cypress/support/api/volumes.ts @@ -1,8 +1,17 @@ -import { Volume, deleteVolume, detachVolume, getVolumes } from '@linode/api-v4'; +import { + createVolume, + deleteVolume, + detachVolume, + getVolumes, +} from '@linode/api-v4'; import { pageSize } from 'support/constants/api'; +import { SimpleBackoffMethod, attemptWithBackoff } from 'support/util/backoff'; import { depaginate } from 'support/util/paginate'; +import { pollVolumeStatus } from 'support/util/polling'; + import { isTestLabel } from './common'; -import { attemptWithBackoff, SimpleBackoffMethod } from 'support/util/backoff'; + +import type { Volume, VolumeRequestPayload } from '@linode/api-v4'; /** * Delete all Volumes whose labels are prefixed "cy-test-". @@ -45,3 +54,18 @@ export const deleteAllTestVolumes = async (): Promise => { await Promise.all(detachDeletePromises); }; + +/** + * Creates a Volume and waits for it to become active. + * + * @param volumeRequest - Volume create request payload. + * + * @returns Promise that resolves to created Volume. + */ +export const createActiveVolume = async ( + volumeRequest: VolumeRequestPayload +) => { + const volume = await createVolume(volumeRequest); + await pollVolumeStatus(volume.id, 'active', new SimpleBackoffMethod(10000)); + return volume; +}; diff --git a/packages/manager/cypress/support/constants/account.ts b/packages/manager/cypress/support/constants/account.ts index 2ef3525eaf7..c8030ac4cbc 100644 --- a/packages/manager/cypress/support/constants/account.ts +++ b/packages/manager/cypress/support/constants/account.ts @@ -2,9 +2,15 @@ * Data loss warning which is displayed in the account cancellation dialog. */ export const cancellationDataLossWarning = - 'Please note this is an extremely destructive action. Closing your account \ -means that all services Linodes, Volumes, DNS Records, etc will be lost and \ -may not be able be restored.'; + 'This is an extremely destructive action. All services, Linodes, volumes, \ +DNS records, and user accounts will be permanently lost.'; + +/** + * Title text displayed in the account cancellation confirmation dialog. + */ +export const cancellationDialogTitle = + 'Are you sure you want to close your Akamai cloud \ +computing services account?'; /** * Error message that appears when a payment failure occurs upon cancellation attempt. diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 3843a35aceb..584fee1378f 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -3,7 +3,8 @@ */ import { linodeTypeFactory } from '@src/factories'; -import { LkePlanDescription } from 'support/api/lke'; + +import type { LkePlanDescription } from 'support/api/lke'; /** Notice shown to users when selecting a region with a different price structure. */ export const dcPricingRegionDifferenceNotice = @@ -127,9 +128,11 @@ export const dcPricingMockLinodeTypesForBackups = linodeTypeFactory.buildList( export const dcPricingLkeClusterPlans: LkePlanDescription[] = dcPricingMockLinodeTypes.map( (type) => { return { + nodeCount: 1, + planName: 'Linode 2 GB', size: parseInt(type.id.split('-')[2], 10), tab: 'Shared CPU', - type: 'Linode', + type: 'nanode', }; } ); diff --git a/packages/manager/cypress/support/constants/linodes.ts b/packages/manager/cypress/support/constants/linodes.ts index c1985c9036f..35d6762f810 100644 --- a/packages/manager/cypress/support/constants/linodes.ts +++ b/packages/manager/cypress/support/constants/linodes.ts @@ -5,6 +5,6 @@ /** * Length of time to wait for a Linode to be created. * - * Equals 4 minutes. + * Equals 5 minutes. */ -export const LINODE_CREATE_TIMEOUT = 240_000; +export const LINODE_CREATE_TIMEOUT = 300_000; diff --git a/packages/manager/cypress/support/constants/lke.ts b/packages/manager/cypress/support/constants/lke.ts index 1a4ca20aded..50e125e1159 100644 --- a/packages/manager/cypress/support/constants/lke.ts +++ b/packages/manager/cypress/support/constants/lke.ts @@ -1,20 +1,36 @@ -import { LkePlanDescription } from 'support/api/lke'; +import { getLatestKubernetesVersion } from 'support/util/lke'; + +import type { KubernetesTieredVersion } from '@linode/api-v4'; /** - * Subset of LKE cluster plans as shown on Cloud Manager. + * Kubernetes versions available for cluster creation via Cloud Manager. */ -export const lkeClusterPlans: LkePlanDescription[] = [ - { size: 4, tab: 'Dedicated CPU', type: 'Dedicated' }, - { size: 2, tab: 'Shared CPU', type: 'Linode' }, - { size: 4, tab: 'Shared CPU', type: 'Linode' }, -]; +export const kubernetesVersions = ['1.31', '1.30']; /** - * Kubernetes versions available for cluster creation via Cloud Manager. + * Enterprise kubernetes versions available for cluster creation via Cloud Manager. */ -export const kubernetesVersions = ['1.25', '1.24']; +export const enterpriseKubernetesVersions = ['v1.31.1+lke1']; /** * The latest Kubernetes version available for cluster creation via Cloud Manager. */ -export const latestKubernetesVersion = kubernetesVersions[0]; +export const latestKubernetesVersion = getLatestKubernetesVersion( + kubernetesVersions +); + +/** + * The latest standard tier Kubernetes version available for cluster creation via Cloud Manager. + */ +export const latestStandardTierKubernetesVersion: KubernetesTieredVersion = { + id: latestKubernetesVersion, + tier: 'standard', +}; + +/** + * The latest enterprise tier Kubernetes version available for cluster creation via Cloud Manager. + */ +export const latestEnterpriseTierKubernetesVersion: KubernetesTieredVersion = { + id: getLatestKubernetesVersion(enterpriseKubernetesVersions), + tier: 'enterprise', +}; diff --git a/packages/manager/cypress/support/constants/login.ts b/packages/manager/cypress/support/constants/login.ts new file mode 100644 index 00000000000..cd3b364f68f --- /dev/null +++ b/packages/manager/cypress/support/constants/login.ts @@ -0,0 +1,8 @@ +/** + * Constants related to Cloud Manager login/logout flows. + */ + +/** + * Login base URL for Cloud Manager. + */ +export const loginBaseUrl = Cypress.env('REACT_APP_LOGIN_ROOT'); diff --git a/packages/manager/cypress/support/intercepts/betas.ts b/packages/manager/cypress/support/intercepts/betas.ts index 384961da39e..4874620ec77 100644 --- a/packages/manager/cypress/support/intercepts/betas.ts +++ b/packages/manager/cypress/support/intercepts/betas.ts @@ -6,7 +6,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Beta } from '@linode/api-v4'; +import type { AccountBeta, Beta } from '@linode/api-v4'; /** * Intercepts GET request to fetch account betas (the ones the user has opted into) and mocks response. @@ -15,7 +15,9 @@ import type { Beta } from '@linode/api-v4'; * * @returns Cypress chainable. */ -export const mockGetAccountBetas = (betas: Beta[]): Cypress.Chainable => { +export const mockGetAccountBetas = ( + betas: AccountBeta[] +): Cypress.Chainable => { return cy.intercept( 'GET', apiMatcher('account/betas'), @@ -23,6 +25,21 @@ export const mockGetAccountBetas = (betas: Beta[]): Cypress.Chainable => { ); }; +/** + * Intercepts GET request to fetch a beta and mocks response. + * + * @param beta - Beta with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetAccountBeta = (beta: AccountBeta): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`account/betas/${beta.id}`), + makeResponse(beta) + ); +}; + /** * Intercepts GET request to fetch available betas (all betas available to the user). * diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts index e3d472a67c6..f17e884787a 100644 --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts @@ -13,7 +13,7 @@ import { makeResponse } from 'support/util/response'; import type { CloudPulseMetricsResponse, Dashboard, - MetricDefinitions, + MetricDefinition, } from '@linode/api-v4'; /** @@ -27,12 +27,12 @@ import type { export const mockGetCloudPulseMetricDefinitions = ( serviceType: string, - metricDefinitions: MetricDefinitions + metricDefinitions: MetricDefinition[] ): Cypress.Chainable => { return cy.intercept( 'GET', apiMatcher(`/monitor/services/${serviceType}/metric-definitions`), - makeResponse(metricDefinitions) + paginateResponse(metricDefinitions) ); }; diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 5f646730a96..88905b33b38 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -6,7 +6,11 @@ import { kubeEndpointFactory, kubernetesDashboardUrlFactory, } from '@src/factories'; -import { kubernetesVersions } from 'support/constants/lke'; +import { + kubernetesVersions, + latestEnterpriseTierKubernetesVersion, + latestStandardTierKubernetesVersion, +} from 'support/constants/lke'; import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; @@ -18,7 +22,10 @@ import type { KubeNodePoolResponse, KubernetesCluster, KubernetesControlPlaneACLPayload, + KubernetesTier, + KubernetesTieredVersion, KubernetesVersion, + PriceType, } from '@linode/api-v4'; /** @@ -42,6 +49,39 @@ export const mockGetKubernetesVersions = (versions?: string[] | undefined) => { ); }; +/** + * Intercepts GET request to retrieve tiered Kubernetes versions and mocks response. + * + * @param tier - Standard or enterprise Kubernetes tier. + * @param versions - Optional array of strings containing mocked tiered versions. + * + * @returns Cypress chainable. + */ +export const mockGetTieredKubernetesVersions = ( + tier: KubernetesTier, + versions?: KubernetesTieredVersion[] +) => { + const defaultTieredVersions = + tier === 'enterprise' + ? [latestEnterpriseTierKubernetesVersion] + : [latestStandardTierKubernetesVersion]; + + const versionObjects = (versions ? versions : defaultTieredVersions).map( + (kubernetesTieredVersion): KubernetesTieredVersion => { + return { + id: kubernetesTieredVersion.id, + tier: kubernetesTieredVersion.tier, + }; + } + ); + + return cy.intercept( + 'GET', + apiMatcher(`lke/versions/${tier}*`), + paginateResponse(versionObjects) + ); +}; + /** * Intercepts GET request to retrieve LKE clusters and mocks response. * @@ -455,3 +495,37 @@ export const mockUpdateControlPlaneACLError = ( makeErrorResponse(errorMessage, statusCode) ); }; + +/** + * Intercepts GET request for LKE cluster types and mocks the response + * + * @param types - LKE cluster types with which to mock response + * + * @returns Cypress chainable + */ +export const mockGetLKEClusterTypes = ( + types: PriceType[] +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('lke/types*'), paginateResponse(types)); +}; + +/** + * Intercepts PUT request to update an LKE cluster and mocks an error response. + * + * @param clusterId - ID of cluster for which to intercept PUT request. + * @param errorMessage - Optional error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateClusterError = ( + clusterId: number, + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`lke/clusters/${clusterId}`), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/cypress/support/setup/defer-command.ts b/packages/manager/cypress/support/setup/defer-command.ts index a667d505030..bb695ef47a0 100644 --- a/packages/manager/cypress/support/setup/defer-command.ts +++ b/packages/manager/cypress/support/setup/defer-command.ts @@ -1,6 +1,7 @@ import type { APIError } from '@linode/api-v4'; import type { AxiosError } from 'axios'; import { timeout } from 'support/util/backoff'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; type LinodeApiV4Error = { errors: APIError[]; @@ -188,9 +189,9 @@ Cypress.Commands.add( const timeoutLength = (() => { if (typeof labelOrOptions !== 'string') { - return labelOrOptions?.timeout; + return labelOrOptions?.timeout ?? LINODE_CREATE_TIMEOUT; } - return undefined; + return LINODE_CREATE_TIMEOUT; })(); const commandLog = Cypress.log({ diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 2c6bca8da83..da303ff0bb0 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -158,15 +158,7 @@ export const createTestLinode = async ( // Wait for Linode status to be 'running' if `waitForBoot` is true. if (resolvedOptions.waitForBoot) { - // Wait 15 seconds before initial check, then poll again every 5 seconds. - await pollLinodeStatus( - linode.id, - 'running', - new SimpleBackoffMethod(5000, { - initialDelay: 15000, - maxAttempts: 25, - }) - ); + await pollLinodeStatus(linode.id, 'running'); } Cypress.log({ diff --git a/packages/manager/cypress/support/util/lke.ts b/packages/manager/cypress/support/util/lke.ts new file mode 100644 index 00000000000..e1e65848532 --- /dev/null +++ b/packages/manager/cypress/support/util/lke.ts @@ -0,0 +1,18 @@ +import { sortByVersion } from 'src/utilities/sort-by'; + +/** + * Returns the string of the highest semantic version. + */ +export const getLatestKubernetesVersion = (versions: string[]) => { + const sortedVersions = versions.sort((a, b) => { + return sortByVersion(a, b, 'asc'); + }); + + const latestVersion = sortedVersions.pop(); + + if (!latestVersion) { + // Return an empty string if sorting does not yield latest version + return ''; + } + return latestVersion; +}; diff --git a/packages/manager/cypress/support/util/polling.ts b/packages/manager/cypress/support/util/polling.ts index d188b2b1ba1..e10bc0467af 100644 --- a/packages/manager/cypress/support/util/polling.ts +++ b/packages/manager/cypress/support/util/polling.ts @@ -22,9 +22,11 @@ import { BackoffMethod, BackoffOptions, FibonacciBackoffMethod, + SimpleBackoffMethod, attemptWithBackoff, } from './backoff'; import { depaginate } from './paginate'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; /** * Describes a backoff configuration for a poll. This may be a partial BackoffOptions object, @@ -103,9 +105,13 @@ export const poll = async ( /** * Polls a Linode with the given ID until it has the given status. * + * By default, polling will occur after 15 seconds have passed, and reattempts + * occur on a 5-second interval until the default Linode create timeout is reached. + * This behavior can be customized by passing an alternative `backoffMethod`. + * * @param linodeId - ID of Linode to poll. * @param desiredStatus - Desired status of Linode that is being polled. - * @param backoffMethod - Backoff method implementation to manage re-attempts. + * @param backoffMethod - Optional backoff method for reattempts. * @param label - Optional label to assign to poll for logging and troubleshooting. * * @returns A Promise that resolves to the polled Linode's status or rejects on timeout. @@ -121,10 +127,24 @@ export const pollLinodeStatus = async ( return linode.status; }; + // By default, wait 15 seconds before initial check then poll again every 5 + // seconds until default Linode create timeout is reached. + const initialDelay = 15_000; + const interval = 5_000; + const maxAttempts = Math.ceil( + (LINODE_CREATE_TIMEOUT - initialDelay) / interval + ); + const defaultBackoffMethod = new SimpleBackoffMethod(interval, { + initialDelay, + maxAttempts, + }); + + const backoff = backoffOptions ? backoffOptions : defaultBackoffMethod; + const checkLinodeStatus = (status: LinodeStatus): boolean => status === desiredStatus; - return poll(getLinodeStatus, checkLinodeStatus, backoffOptions, label); + return poll(getLinodeStatus, checkLinodeStatus, backoff, label); }; /** diff --git a/packages/manager/cypress/tsconfig.json b/packages/manager/cypress/tsconfig.json index 0331e9038b2..bedcfa42811 100644 --- a/packages/manager/cypress/tsconfig.json +++ b/packages/manager/cypress/tsconfig.json @@ -12,7 +12,8 @@ "cypress-file-upload", "@testing-library/cypress", "cypress-real-events", - "vite/client" + "vite/client", + "@4tw/cypress-drag-drop" ] }, "include": [ diff --git a/packages/manager/index.html b/packages/manager/index.html index 3ce95341cf1..926711c776d 100644 --- a/packages/manager/index.html +++ b/packages/manager/index.html @@ -11,4 +11,4 @@
- \ No newline at end of file + diff --git a/packages/manager/package.json b/packages/manager/package.json index 9331fc23cee..b84877cb298 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.133.2", + "version": "1.134.0", "private": true, "type": "module", "bugs": { @@ -19,7 +19,7 @@ "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@hookform/resolvers": "2.9.11", + "@hookform/resolvers": "3.9.1", "@linode/api-v4": "*", "@linode/design-language-system": "^2.6.1", "@linode/search": "*", @@ -73,13 +73,12 @@ "react-router-dom": "~5.3.4", "react-router-hash-link": "^2.3.1", "react-select": "~3.1.0", - "react-vnc": "^0.5.3", + "react-vnc": "^2.0.2", "react-waypoint": "^10.3.0", "recharts": "^2.14.1", "recompose": "^0.30.0", "redux": "^4.0.4", "redux-thunk": "^2.3.0", - "reselect": "^4.0.0", "search-string": "^3.1.0", "throttle-debounce": "^2.0.0", "tss-react": "^4.8.2", @@ -97,6 +96,7 @@ "build:analyze": "bunx vite-bundle-visualizer", "precommit": "lint-staged && yarn typecheck", "test": "vitest run", + "test:ui": "vitest --ui", "test:debug": "node --inspect-brk scripts/test.js --runInBand", "storybook": "NODE_OPTIONS='--max-old-space-size=4096' storybook dev -p 6006", "storybook-static": "storybook build -c .storybook -o .out", @@ -169,12 +169,11 @@ "@types/zxcvbn": "^4.4.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", + "@4tw/cypress-drag-drop": "^2.2.5", "@vitejs/plugin-react-swc": "^3.7.0", "@vitest/coverage-v8": "^2.1.1", "@vitest/ui": "^2.1.1", "chai-string": "^1.5.0", - "chalk": "^5.2.0", - "commander": "^6.2.1", "css-mediaquery": "^0.1.2", "cypress": "13.11.0", "cypress-axe": "^1.5.0", @@ -199,16 +198,13 @@ "factory.ts": "^0.5.1", "glob": "^10.3.1", "jsdom": "^24.1.1", - "junit2json": "^3.1.4", "lint-staged": "^15.2.9", "mocha-junit-reporter": "^2.2.1", "msw": "^2.2.3", "prettier": "~2.2.1", "redux-mock-store": "^1.5.3", - "simple-git": "^3.19.0", "storybook": "^8.3.0", "storybook-dark-mode": "4.0.1", - "tsx": "^4.19.1", "vite": "^5.4.6", "vite-plugin-svgr": "^3.2.0" }, diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index e0bfc2f2af3..dcdae2ec607 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,5 +1,6 @@ import { Box } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; +import { useQueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; @@ -30,6 +31,7 @@ import { sessionExpirationContext } from './context/sessionExpirationContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; import { useIsACLPEnabled } from './features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; +import { useIsIAMEnabled } from './features/IAM/Shared/utilities'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { useAccountSettings } from './queries/account/settings'; @@ -38,7 +40,6 @@ import { migrationRouter } from './routes'; import type { Theme } from '@mui/material/styles'; import type { AnyRouter } from '@tanstack/react-router'; -import { useIsIAMEnabled } from './features/IAM/Shared/utilities'; const useStyles = makeStyles()((theme: Theme) => ({ activationWrapper: { @@ -130,12 +131,6 @@ const LinodesRoutes = React.lazy(() => default: module.LinodesRoutes, })) ); -const Volumes = React.lazy(() => import('src/features/Volumes')); -const Domains = React.lazy(() => - import('src/features/Domains').then((module) => ({ - default: module.DomainsRoutes, - })) -); const Images = React.lazy(() => import('src/features/Images')); const Kubernetes = React.lazy(() => import('src/features/Kubernetes').then((module) => ({ @@ -205,8 +200,11 @@ const IAM = React.lazy(() => export const MainContent = () => { const { classes, cx } = useStyles(); - const { data: preferences } = usePreferences(); + const { data: isDesktopSidebarOpenPreference } = usePreferences( + (preferences) => preferences?.desktop_sidebar_open + ); const { mutateAsync: updatePreferences } = useMutatePreferences(); + const queryClient = useQueryClient(); const globalErrors = useGlobalErrors(); @@ -286,11 +284,11 @@ export const MainContent = () => { return ; } - const desktopMenuIsOpen = preferences?.desktop_sidebar_open ?? false; + const desktopMenuIsOpen = isDesktopSidebarOpenPreference ?? false; const desktopMenuToggle = () => { updatePreferences({ - desktop_sidebar_open: !preferences?.desktop_sidebar_open, + desktop_sidebar_open: !isDesktopSidebarOpenPreference, }); }; @@ -336,13 +334,10 @@ export const MainContent = () => { path="/placement-groups" /> )} - - - @@ -382,6 +377,7 @@ export const MainContent = () => { */} diff --git a/packages/manager/src/Root.tsx b/packages/manager/src/Root.tsx index 3df94224ecb..64089d9c47e 100644 --- a/packages/manager/src/Root.tsx +++ b/packages/manager/src/Root.tsx @@ -31,7 +31,9 @@ import { useStyles } from './Root.styles'; export const Root = () => { const { classes, cx } = useStyles(); - const { data: preferences } = usePreferences(); + const { data: isDesktopSidebarOpenPreference } = usePreferences( + (preferences) => preferences?.desktop_sidebar_open + ); const { mutateAsync: updatePreferences } = useMutatePreferences(); const globalErrors = useGlobalErrors(); @@ -57,11 +59,11 @@ export const Root = () => { const { data: profile } = useProfile(); const username = profile?.username || ''; - const desktopMenuIsOpen = preferences?.desktop_sidebar_open ?? false; + const desktopMenuIsOpen = isDesktopSidebarOpenPreference ?? false; const desktopMenuToggle = () => { updatePreferences({ - desktop_sidebar_open: !preferences?.desktop_sidebar_open, + desktop_sidebar_open: !isDesktopSidebarOpenPreference, }); }; diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index 7d89f4fa297..a8e12fd54f3 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -1,3 +1,4 @@ +import { QueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; @@ -24,6 +25,7 @@ export const Router = () => { isACLPEnabled, isDatabasesEnabled, isPlacementGroupsEnabled, + queryClient: new QueryClient(), }, }); diff --git a/packages/manager/src/__data__/linodes.ts b/packages/manager/src/__data__/linodes.ts index cf156461fa9..8cf0cac9de2 100644 --- a/packages/manager/src/__data__/linodes.ts +++ b/packages/manager/src/__data__/linodes.ts @@ -16,6 +16,7 @@ export const linode1: Linode = { window: 'W2', }, }, + capabilities: [], created: '2017-12-07T19:12:58', group: 'active', hypervisor: 'kvm', @@ -65,6 +66,7 @@ export const linode2: Linode = { window: 'Scheduling', }, }, + capabilities: [], created: '2018-02-22T16:11:07', group: 'inactive', hypervisor: 'kvm', @@ -114,6 +116,7 @@ export const linode3: Linode = { window: 'Scheduling', }, }, + capabilities: [], created: '2018-02-22T16:11:07', group: 'inactive', hypervisor: 'kvm', @@ -163,6 +166,7 @@ export const linode4: Linode = { window: 'Scheduling', }, }, + capabilities: [], created: '2018-02-22T16:11:07', group: 'inactive', hypervisor: 'kvm', diff --git a/packages/manager/src/assets/icons/emptynotification.svg b/packages/manager/src/assets/icons/emptynotification.svg new file mode 100644 index 00000000000..5181cecf317 --- /dev/null +++ b/packages/manager/src/assets/icons/emptynotification.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/entityIcons/alerts.svg b/packages/manager/src/assets/icons/entityIcons/alerts.svg new file mode 100644 index 00000000000..69a49e63d18 --- /dev/null +++ b/packages/manager/src/assets/icons/entityIcons/alerts.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/uploadPending.svg b/packages/manager/src/assets/icons/uploadPending.svg deleted file mode 100644 index 07895a6311c..00000000000 --- a/packages/manager/src/assets/icons/uploadPending.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/packages/manager/src/components/ActionsPanel/ActionsPanel.tsx b/packages/manager/src/components/ActionsPanel/ActionsPanel.tsx index 457dc086e6f..eddee90c914 100644 --- a/packages/manager/src/components/ActionsPanel/ActionsPanel.tsx +++ b/packages/manager/src/components/ActionsPanel/ActionsPanel.tsx @@ -1,11 +1,11 @@ -import { Box, Button } from '@linode/ui'; +import { Box, Button, omittedProps } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { useStyles } from 'tss-react/mui'; import type { BoxProps, ButtonProps } from '@linode/ui'; -interface ActionButtonsProps extends ButtonProps { +export interface ActionButtonsProps extends ButtonProps { 'data-node-idx'?: number; 'data-qa-form-data-loading'?: boolean; 'data-testid'?: string; @@ -17,6 +17,10 @@ export interface ActionPanelProps extends BoxProps { * primary type actionable button custom aria descripton. */ primaryButtonProps?: ActionButtonsProps; + /** + * Determines the position of the primary button within the actions panel. + */ + reversePrimaryButtonPosition?: boolean; /** * secondary type actionable button custom aria descripton. */ @@ -31,6 +35,7 @@ export const ActionsPanel = (props: ActionPanelProps) => { const { className, primaryButtonProps, + reversePrimaryButtonPosition = false, secondaryButtonProps, ...rest } = props; @@ -44,6 +49,7 @@ export const ActionsPanel = (props: ActionPanelProps) => { {secondaryButtonProps ? ( @@ -69,14 +75,13 @@ export const ActionsPanel = (props: ActionPanelProps) => { ); }; -const StyledBox = styled(Box)(({ theme: { spacing } }) => ({ - '& > :first-of-type': { - marginLeft: 0, - marginRight: spacing(), - }, - '& > :only-child': { - marginRight: 0, - }, +const StyledBox = styled(Box, { + label: 'StyledActionsPanel', + shouldForwardProp: omittedProps(['reversePrimaryButtonPosition']), +})(({ theme: { spacing }, ...props }) => ({ + display: 'flex', + flexDirection: props.reversePrimaryButtonPosition ? 'row-reverse' : 'row', + gap: spacing(), justifyContent: 'flex-end', marginTop: spacing(1), paddingBottom: spacing(1), diff --git a/packages/manager/src/components/Avatar/Avatar.tsx b/packages/manager/src/components/Avatar/Avatar.tsx index d84a53f170b..097f03ef9d3 100644 --- a/packages/manager/src/components/Avatar/Avatar.tsx +++ b/packages/manager/src/components/Avatar/Avatar.tsx @@ -50,7 +50,9 @@ export const Avatar = (props: AvatarProps) => { const theme = useTheme(); - const { data: preferences } = usePreferences(); + const { data: avatarColorPreference } = usePreferences( + (preferences) => preferences?.avatarColor + ); const { data: profile } = useProfile(); const _username = username ?? profile?.username ?? ''; @@ -58,9 +60,10 @@ export const Avatar = (props: AvatarProps) => { _username === 'Akamai' || _username.startsWith('lke-service-account'); const savedAvatarColor = - isAkamai || !preferences?.avatarColor + isAkamai || !avatarColorPreference ? theme.palette.primary.dark - : preferences.avatarColor; + : avatarColorPreference; + const avatarLetter = _username[0]?.toUpperCase() ?? ''; return ( diff --git a/packages/manager/src/components/BarPercent/BarPercent.tsx b/packages/manager/src/components/BarPercent/BarPercent.tsx index 81a9d6e4ccf..58b76e006e2 100644 --- a/packages/manager/src/components/BarPercent/BarPercent.tsx +++ b/packages/manager/src/components/BarPercent/BarPercent.tsx @@ -73,11 +73,13 @@ const StyledLinearProgress = styled(LinearProgress, { shouldForwardProp: omittedProps(['rounded', 'narrow']), })>(({ theme, ...props }) => ({ '& .MuiLinearProgress-bar2Buffer': { - backgroundColor: '#5ad865', + backgroundColor: theme.tokens.color.Green[60], }, '& .MuiLinearProgress-barColorPrimary': { // Increase contrast if we have a buffer bar - backgroundColor: props.valueBuffer ? '#1CB35C' : '#5ad865', + backgroundColor: props.valueBuffer + ? theme.tokens.color.Green[70] + : theme.tokens.color.Green[60], }, '& .MuiLinearProgress-dashed': { display: 'none', diff --git a/packages/manager/src/components/Breadcrumb/FinalCrumb.styles.tsx b/packages/manager/src/components/Breadcrumb/FinalCrumb.styles.tsx index 57c84f428bb..af43289b40f 100644 --- a/packages/manager/src/components/Breadcrumb/FinalCrumb.styles.tsx +++ b/packages/manager/src/components/Breadcrumb/FinalCrumb.styles.tsx @@ -1,6 +1,8 @@ import { EditableText, H1Header } from '@linode/ui'; import { styled } from '@mui/material'; +import type { EditableTextProps } from '@linode/ui'; + export const StyledDiv = styled('div', { label: 'StyledDiv' })({ display: 'flex', flexDirection: 'column', @@ -8,7 +10,7 @@ export const StyledDiv = styled('div', { label: 'StyledDiv' })({ export const StyledEditableText = styled(EditableText, { label: 'StyledEditableText', -})(({ theme }) => ({ +})(({ theme }) => ({ '& > div': { width: 250, }, diff --git a/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx b/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx index 5cb20414d84..729f1222230 100644 --- a/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx +++ b/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; +import { Link } from '../Link'; import { StyledDiv, StyledEditableText, StyledH1Header, } from './FinalCrumb.styles'; -import { EditableProps, LabelProps } from './types'; + +import type { EditableProps, LabelProps } from './types'; interface Props { crumb: string; @@ -22,6 +24,13 @@ export const FinalCrumb = React.memo((props: Props) => { onEditHandlers, } = props; + const linkProps = labelOptions?.linkTo + ? { + LinkComponent: Link, + labelLink: labelOptions.linkTo, + } + : {}; + if (onEditHandlers) { return ( { disabledBreadcrumbEditButton={disabledBreadcrumbEditButton} errorText={onEditHandlers.errorText} handleAnalyticsEvent={onEditHandlers.handleAnalyticsEvent} - labelLink={labelOptions && labelOptions.linkTo} onCancel={onEditHandlers.onCancel} onEdit={onEditHandlers.onEdit} text={onEditHandlers.editableTextTitle} + {...linkProps} /> ); } diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx index d06759879f8..9ba6994f259 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx @@ -81,7 +81,7 @@ const StyledSummary = styled(Grid2)(({ theme }) => ({ '&:last-child': { borderRight: 'none', }, - borderRight: 'solid 1px #9DA4A6', + borderRight: `solid 1px ${theme.tokens.color.Neutrals[50]}`, }, }, })); diff --git a/packages/manager/src/components/CodeBlock/CodeBlock.styles.ts b/packages/manager/src/components/CodeBlock/CodeBlock.styles.ts index 2308b4c5abf..fd2a2a325de 100644 --- a/packages/manager/src/components/CodeBlock/CodeBlock.styles.ts +++ b/packages/manager/src/components/CodeBlock/CodeBlock.styles.ts @@ -18,20 +18,20 @@ export const StyledHighlightedMarkdown = styled(HighlightedMarkdown, { })(({ theme }) => ({ '& .hljs': { '& .hljs-literal, .hljs-built_in': { - color: '#f8f8f2', + color: theme.tokens.color.Yellow[5], }, '& .hljs-string': { - color: '#e6db74', + color: theme.tokens.color.Yellow[50], }, '& .hljs-symbol': { - color: '#f8f8f2', + color: theme.tokens.color.Yellow[5], }, '& .hljs-variable': { color: 'teal', }, - backgroundColor: '#32363b', - color: '#f8f8f2', + backgroundColor: theme.tokens.color.Neutrals[100], + color: theme.tokens.color.Yellow[5], padding: `${theme.spacing(4)} ${theme.spacing(2)}`, }, })); @@ -40,10 +40,10 @@ export const StyledCopyTooltip = styled(CopyTooltip, { label: 'StyledCopyTooltip', })(({ theme }) => ({ '& svg': { - color: '#17CF73', + color: theme.tokens.color.Green[60], }, '& svg:hover': { - color: '#00B159', + color: theme.tokens.color.Green[70], }, position: 'absolute', right: `${theme.spacing(1.5)}`, diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index 9805c9a7867..5d3501de3bf 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -13,22 +13,22 @@ interface Color { const useStyles = makeStyles()((theme: Theme) => ({ alias: { - color: '#32363c', + color: theme.tokens.color.Neutrals[90], fontFamily: '"UbuntuMono", monospace, sans-serif', fontSize: '0.875rem', }, color: { - color: '#888f91', + color: theme.tokens.color.Neutrals[60], fontFamily: '"UbuntuMono", monospace, sans-serif', fontSize: '0.875rem', }, root: { '& h2': { - color: '#32363c', + color: theme.tokens.color.Neutrals[90], }, }, swatch: { - border: '1px solid #888f91', + border: `1px solid ${theme.tokens.color.Neutrals[60]}`, borderRadius: 3, height: theme.spacing(4.5), margin: '0px 16px', diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000000..640fae75510 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,124 @@ +import { action } from '@storybook/addon-actions'; +import * as React from 'react'; + +import { DatePicker } from './DatePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; +import type { DateTime } from 'luxon'; + +type Story = StoryObj; + +export const Default: Story = { + argTypes: { + errorText: { + control: 'text', + description: 'Error text to display below the input', + }, + format: { + control: 'text', + description: 'Format of the date when rendered in the input field', + }, + helperText: { + control: 'text', + description: 'Helper text to display below the input', + }, + label: { + control: 'text', + description: 'Label to display for the date picker input', + }, + onChange: { + action: 'date-changed', + description: 'Callback function fired when the value changes', + }, + placeholder: { + control: 'text', + description: 'Placeholder text for the date picker input', + }, + textFieldProps: { + control: 'object', + description: + 'Additional props to pass to the underlying TextField component', + }, + value: { + control: 'date', + description: 'The currently selected date', + }, + }, + args: { + errorText: '', + format: 'yyyy-MM-dd', + label: 'Select a Date', + onChange: action('date-changed'), + placeholder: 'yyyy-MM-dd', + textFieldProps: { label: 'Select a Date' }, + value: null, + }, +}; + +export const ControlledExample: Story = { + args: { + errorText: '', + format: 'yyyy-MM-dd', + helperText: 'This is a controlled DatePicker', + label: 'Controlled Date Picker', + placeholder: 'yyyy-MM-dd', + value: null, + }, + render: (args) => { + const ControlledDatePicker = () => { + const [selectedDate, setSelectedDate] = React.useState(); + + const handleChange = (newDate: DateTime | null) => { + setSelectedDate(newDate); + action('Controlled date change')(newDate?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +const meta: Meta = { + argTypes: { + errorText: { + control: 'text', + }, + format: { + control: 'text', + }, + helperText: { + control: 'text', + }, + label: { + control: 'text', + }, + onChange: { + action: 'date-changed', + }, + placeholder: { + control: 'text', + }, + textFieldProps: { + control: 'object', + }, + value: { + control: 'date', + }, + }, + args: { + errorText: '', + format: 'yyyy-MM-dd', + helperText: '', + label: 'Select a Date', + placeholder: 'yyyy-MM-dd', + value: null, + }, + component: DatePicker, + title: 'Components/DatePicker/DatePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DatePicker.test.tsx b/packages/manager/src/components/DatePicker/DatePicker.test.tsx new file mode 100644 index 00000000000..e051d160ec5 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.test.tsx @@ -0,0 +1,80 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatePicker } from './DatePicker'; + +import type { DatePickerProps } from './DatePicker'; + +const props: DatePickerProps = { + onChange: vi.fn(), + placeholder: 'Pick a date', + textFieldProps: { errorText: 'Invalid date', label: 'Select a date' }, + value: null, +}; + +describe('DatePicker', () => { + it('should render the DatePicker component', () => { + renderWithTheme(); + const DatePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + + expect(DatePickerField).toBeVisible(); + }); + + it('should handle value changes', async () => { + renderWithTheme(); + + const calendarButton = screen.getByRole('button', { name: 'Choose date' }); + + // Click the calendar button to open the date picker + await userEvent.click(calendarButton); + + // Find a date button to click (e.g., the 15th of the month) + const dateToSelect = screen.getByRole('gridcell', { name: '15' }); + await userEvent.click(dateToSelect); + + // Check if onChange was called after selecting a date + expect(props.onChange).toHaveBeenCalled(); + }); + + it('should display the error text when provided', () => { + renderWithTheme(); + const errorMessage = screen.getByText('Invalid date'); + expect(errorMessage).toBeVisible(); + }); + + it('should display the helper text when provided', () => { + renderWithTheme(); + const helperText = screen.getByText('Choose a valid date'); + expect(helperText).toBeVisible(); + }); + + it('should use the default format when no format is specified', () => { + renderWithTheme( + + ); + const datePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + expect(datePickerField).toHaveValue('2024-10-25'); + }); + + it('should handle the custom format correctly', () => { + renderWithTheme( + + ); + const datePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + expect(datePickerField).toHaveValue('25/10/2024'); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000..25fb95ff048 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,91 @@ +import { TextField } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import React from 'react'; + +import type { TextFieldProps } from '@linode/ui'; +import type { DatePickerProps as MuiDatePickerProps } from '@mui/x-date-pickers/DatePicker'; +import type { DateTime } from 'luxon'; + +export interface DatePickerProps + extends Omit, 'onChange' | 'value'> { + /** Error text to display below the input */ + errorText?: string; + /** Format of the date when rendered in the input field. */ + format?: string; + /** Helper text to display below the input */ + helperText?: string; + /** Label to display for the date picker input */ + label?: string; + /** Callback function fired when the value changes */ + onChange: (newDate: DateTime | null) => void; + /** Placeholder text for the date picker input */ + placeholder?: string; + /** Additional props to pass to the underlying TextField component */ + textFieldProps?: Omit; + /** The currently selected date */ + value?: DateTime | null; +} + +export const DatePicker = ({ + format = 'yyyy-MM-dd', + helperText = '', + label = 'Select a date', + onChange, + placeholder = 'Pick a date', + textFieldProps, + value = null, + ...props +}: DatePickerProps) => { + const theme = useTheme(); + + const onChangeHandler = (newDate: DateTime | null) => { + onChange(newDate); + }; + + return ( + + + + ); +}; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx new file mode 100644 index 00000000000..7df04ed26cd --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx @@ -0,0 +1,145 @@ +import { action } from '@storybook/addon-actions'; +import * as React from 'react'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; +import type { DateTime } from 'luxon'; + +type Story = StoryObj; + +export const ControlledExample: Story = { + args: { + label: 'Controlled Date-Time Picker', + onApply: action('Apply clicked'), + onCancel: action('Cancel clicked'), + placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, + timeSelectProps: { + label: 'Select Time', + }, + timeZoneSelectProps: { + label: 'Timezone', + onChange: action('Timezone changed'), + }, + }, + render: (args) => { + const ControlledDateTimePicker = () => { + const [ + selectedDateTime, + setSelectedDateTime, + ] = React.useState(args.value || null); + + const handleChange = (newDateTime: DateTime | null) => { + setSelectedDateTime(newDateTime); + action('Controlled dateTime change')(newDateTime?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +export const DefaultExample: Story = { + args: { + label: 'Default Date-Time Picker', + onApply: action('Apply clicked'), + onCancel: action('Cancel clicked'), + onChange: action('Date-Time selected'), + placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, + }, +}; + +export const WithErrorText: Story = { + args: { + errorText: 'This field is required', + label: 'Date-Time Picker with Error', + onApply: action('Apply clicked with error'), + onCancel: action('Cancel clicked with error'), + onChange: action('Date-Time selected with error'), + placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, + }, +}; + +const meta: Meta = { + argTypes: { + dateCalendarProps: { + control: { type: 'object' }, + description: 'Additional props for the DateCalendar component.', + }, + errorText: { + control: { type: 'text' }, + description: 'Error text for the date picker field.', + }, + format: { + control: { type: 'text' }, + description: 'Format for displaying the date-time.', + }, + label: { + control: { type: 'text' }, + description: 'Label for the input field.', + }, + onApply: { + action: 'applyClicked', + description: 'Callback when the "Apply" button is clicked.', + }, + onCancel: { + action: 'cancelClicked', + description: 'Callback when the "Cancel" button is clicked.', + }, + onChange: { + action: 'dateTimeChanged', + description: 'Callback when the date-time changes.', + }, + placeholder: { + control: { type: 'text' }, + description: 'Placeholder text for the input field.', + }, + showTime: { + control: { type: 'boolean' }, + description: 'Whether to show the time selector.', + }, + showTimeZone: { + control: { type: 'boolean' }, + description: 'Whether to show the timezone selector.', + }, + sx: { + control: { type: 'object' }, + description: 'Styles to apply to the root element.', + }, + timeSelectProps: { + control: { type: 'object' }, + description: 'Props for customizing the TimePicker component.', + }, + timeZoneSelectProps: { + control: { type: 'object' }, + description: 'Props for customizing the TimeZoneSelect component.', + }, + value: { + control: { type: 'date' }, + description: 'Initial or controlled dateTime value.', + }, + }, + args: { + format: 'yyyy-MM-dd HH:mm', + label: 'Date-Time Picker', + placeholder: 'Select a date and time', + }, + component: DateTimePicker, + title: 'Components/DatePicker/DateTimePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx new file mode 100644 index 00000000000..12f1795a747 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx @@ -0,0 +1,143 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { DateTimePickerProps } from './DateTimePicker'; + +const defaultProps: DateTimePickerProps = { + label: 'Select Date and Time', + onApply: vi.fn(), + onCancel: vi.fn(), + onChange: vi.fn(), + placeholder: 'yyyy-MM-dd HH:mm', + value: DateTime.fromISO('2024-10-25T15:30:00'), +}; + +describe('DateTimePicker Component', () => { + it('should render the DateTimePicker component with the correct label and placeholder', () => { + renderWithTheme(); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + expect(textField).toBeVisible(); + expect(textField).toHaveAttribute('placeholder', 'yyyy-MM-dd HH:mm'); + }); + + it('should open the Popover when the TextField is clicked', async () => { + renderWithTheme(); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + await userEvent.click(textField); + expect(screen.getByRole('dialog')).toBeVisible(); // Verifying the Popover is open + }); + + it('should call onCancel when the Cancel button is clicked', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + await userEvent.click(cancelButton); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('should call onApply when the Apply button is clicked', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const applyButton = screen.getByRole('button', { name: /Apply/i }); + await userEvent.click(applyButton); + expect(defaultProps.onApply).toHaveBeenCalled(); + expect(defaultProps.onChange).toHaveBeenCalledWith(expect.any(DateTime)); // Ensuring onChange was called with a DateTime object + }); + + it('should handle date changes correctly', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate selecting a date (e.g., 15th of the month) + const dateButton = screen.getByRole('gridcell', { name: '15' }); + await userEvent.click(dateButton); + + // Check that the displayed value has been updated correctly (this assumes the date format) + expect(defaultProps.onChange).toHaveBeenCalled(); + }); + + it('should handle timezone changes correctly', async () => { + const timezoneChangeMock = vi.fn(); // Create a mock function + + const updatedProps = { + ...defaultProps, + timeZoneSelectProps: { onChange: timezoneChangeMock, value: 'UTC' }, + }; + + renderWithTheme(); + + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate selecting a timezone from the TimeZoneSelect + const timezoneInput = screen.getByPlaceholderText(/Choose a Timezone/i); + await userEvent.click(timezoneInput); + + // Select a timezone from the dropdown options + await userEvent.click( + screen.getByRole('option', { name: '(GMT -11:00) Niue Time' }) + ); + + // Click the Apply button to trigger the change + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Verify that the onChange function was called with the expected value + expect(timezoneChangeMock).toHaveBeenCalledWith('Pacific/Niue'); + }); + + it('should display the error text when provided', () => { + renderWithTheme( + + ); + expect(screen.getByText(/Invalid date-time/i)).toBeVisible(); + }); + + it('should format the date-time correctly when a custom format is provided', () => { + renderWithTheme( + + ); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + + expect(textField).toHaveValue('25/10/2024 15:30'); + }); + it('should not render the time selector when showTime is false', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const timePicker = screen.queryByLabelText(/Select Time/i); // Label from timeSelectProps + expect(timePicker).not.toBeInTheDocument(); + }); + + it('should not render the timezone selector when showTimeZone is false', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const timeZoneSelect = screen.queryByLabelText(/Timezone/i); // Label from timeZoneSelectProps + expect(timeZoneSelect).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx new file mode 100644 index 00000000000..86c66ee834a --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -0,0 +1,296 @@ +import { Divider } from '@linode/ui'; +import { InputAdornment, TextField } from '@linode/ui'; +import { Box } from '@linode/ui'; +import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import { Grid, Popover } from '@mui/material'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { TimePicker } from '@mui/x-date-pickers/TimePicker'; +import React, { useEffect, useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; + +import { TimeZoneSelect } from './TimeZoneSelect'; + +import type { TextFieldProps } from '@linode/ui'; +import type { SxProps, Theme } from '@mui/material/styles'; +import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; +import type { DateTime } from 'luxon'; + +export interface DateTimePickerProps { + /** Additional props for the DateCalendar */ + dateCalendarProps?: Partial>; + /** Error text for the date picker field */ + errorText?: string; + /** Format for displaying the date-time */ + format?: string; + /** Label for the input field */ + label?: string; + /** Callback when the "Apply" button is clicked */ + onApply?: () => void; + /** Callback when the "Cancel" button is clicked */ + onCancel?: () => void; + /** Callback when date-time changes */ + onChange: (dateTime: DateTime | null) => void; + /** Placeholder text for the input field */ + placeholder?: string; + /** Whether to show the time selector */ + showTime?: boolean; + /** Whether to show the timezone selector */ + showTimeZone?: boolean; + /** + * Any additional styles to apply to the root element. + */ + sx?: SxProps; + /** Props for customizing the TimePicker component */ + timeSelectProps?: { + label?: string; + onChange?: (time: null | string) => void; + value?: null | string; + }; + /** Props for customizing the TimeZoneSelect component */ + timeZoneSelectProps?: { + label?: string; + onChange?: (timezone: string) => void; + value?: null | string; + }; + /** Initial or controlled dateTime value */ + value?: DateTime | null; +} + +export const DateTimePicker = ({ + dateCalendarProps = {}, + errorText = '', + format = 'yyyy-MM-dd HH:mm', + label = 'Select Date and Time', + onApply, + onCancel, + onChange, + placeholder = 'Select Date', + showTime = true, + showTimeZone = true, + sx, + timeSelectProps = {}, + timeZoneSelectProps = {}, + value = null, +}: DateTimePickerProps) => { + const [anchorEl, setAnchorEl] = useState(null); + + // Current and original states + const [selectedDateTime, setSelectedDateTime] = useState( + value + ); + const [selectedTimeZone, setSelectedTimeZone] = useState( + timeZoneSelectProps.value || null + ); + + const [originalDateTime, setOriginalDateTime] = useState( + value + ); + const [originalTimeZone, setOriginalTimeZone] = useState( + timeZoneSelectProps.value || null + ); + + const TimePickerFieldProps: TextFieldProps = { + label: timeSelectProps?.label ?? 'Select Time', + noMarginTop: true, + }; + + const handleDateChange = (newDate: DateTime | null) => { + setSelectedDateTime((prev) => + newDate + ? newDate.set({ + hour: prev?.hour || 0, + minute: prev?.minute || 0, + }) + : null + ); + }; + + const handleTimeChange = (newTime: DateTime | null) => { + if (newTime) { + setSelectedDateTime((prev) => + prev ? prev.set({ hour: newTime.hour, minute: newTime.minute }) : prev + ); + } + }; + + const handleTimeZoneChange = (newTimeZone: string) => { + setSelectedTimeZone(newTimeZone); + if (timeZoneSelectProps.onChange) { + timeZoneSelectProps.onChange(newTimeZone); + } + }; + + const handleApply = () => { + setAnchorEl(null); + setOriginalDateTime(selectedDateTime); + setOriginalTimeZone(selectedTimeZone); + onChange(selectedDateTime); + + if (onApply) { + onApply(); + } + }; + + const handleClose = () => { + setAnchorEl(null); + setSelectedDateTime(originalDateTime); + setSelectedTimeZone(originalTimeZone); + + if (onCancel) { + onCancel(); + } + }; + + useEffect(() => { + if (timeZoneSelectProps.value) { + setSelectedTimeZone(timeZoneSelectProps.value); + } + }, [timeZoneSelectProps.value]); + + return ( + + + + + + ), + sx: { paddingLeft: '32px' }, + }} + value={ + selectedDateTime + ? `${selectedDateTime.toFormat(format)}${ + selectedTimeZone ? ` (${selectedTimeZone})` : '' + }` + : '' + } + errorText={errorText} + label={label} + noMarginTop + onClick={(event) => setAnchorEl(event.currentTarget)} + placeholder={placeholder} + /> + + + + ({ + '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { + justifyContent: 'space-between', + }, + '& .MuiDayCalendar-weekDayLabel': { + fontSize: '0.875rem', + }, + '& .MuiPickersCalendarHeader-label': { + fontFamily: theme.font.bold, + }, + '& .MuiPickersCalendarHeader-root': { + borderBottom: `1px solid ${theme.borderColors.divider}`, + fontSize: '0.875rem', + paddingBottom: theme.spacing(1), + }, + '& .MuiPickersDay-root': { + fontSize: '0.875rem', + margin: `${theme.spacing(0.5)}px`, + }, + borderRadius: `${theme.spacing(2)}`, + borderWidth: '0px', + })} + /> + + {showTime && ( + + ({ + justifyContent: 'center', + marginBottom: theme.spacing(1 / 2), + marginTop: theme.spacing(1 / 2), + padding: 0, + }), + }, + layout: { + sx: (theme: Theme) => ({ + '& .MuiPickersLayout-contentWrapper': { + borderBottom: `1px solid ${theme.borderColors.divider}`, + }, + border: `1px solid ${theme.borderColors.divider}`, + }), + }, + openPickerButton: { + sx: { padding: 0 }, + }, + popper: { + sx: (theme: Theme) => ({ + ul: { + borderColor: `${theme.borderColors.divider} !important`, + }, + }), + }, + textField: TimePickerFieldProps, + }} + onChange={handleTimeChange} + slots={{ textField: TextField }} + value={selectedDateTime || null} + /> + + )} + {showTimeZone && ( + + + + )} + + + + + ({ + marginBottom: theme.spacing(1), + marginRight: theme.spacing(2), + })} + primaryButtonProps={{ label: 'Apply', onClick: handleApply }} + /> + + + + ); +}; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx new file mode 100644 index 00000000000..aeaecd516d0 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx @@ -0,0 +1,193 @@ +import { action } from '@storybook/addon-actions'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DateTimeRangePicker } from './DateTimeRangePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const Default: Story = { + args: { + endDateProps: { + errorMessage: '', + label: 'End Date and Time', + placeholder: '', + showTimeZone: false, + value: null, + }, + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + presetsProps: { + defaultValue: { label: '', value: '' }, + enablePresets: true, + label: '', + placeholder: '', + }, + startDateProps: { + errorMessage: '', + label: 'Start Date and Time', + placeholder: '', + showTimeZone: true, + timeZoneValue: null, + value: null, + }, + sx: {}, + }, + render: (args) => , +}; + +export const WithInitialValues: Story = { + args: { + endDateProps: { + label: 'End Date and Time', + showTimeZone: true, + value: DateTime.now(), + }, + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + presetsProps: { + defaultValue: { label: 'Last 7 Days', value: '7days' }, + enablePresets: true, + label: 'Time Range', + placeholder: 'Select Range', + }, + startDateProps: { + label: 'Start Date and Time', + showTimeZone: true, + timeZoneValue: 'America/New_York', + value: DateTime.now().minus({ days: 1 }), + }, + sx: {}, + }, +}; + +export const WithCustomErrors: Story = { + args: { + endDateProps: { + errorMessage: 'End date must be after the start date.', + label: 'Custom End Label', + placeholder: '', + showTimeZone: false, + value: DateTime.now().minus({ days: 1 }), + }, + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + presetsProps: { + defaultValue: { label: '', value: '' }, + enablePresets: true, + label: '', + placeholder: '', + }, + startDateProps: { + errorMessage: 'Start date must be before the end date.', + label: 'Start Date and Time', + placeholder: '', + showTimeZone: true, + timeZoneValue: null, + value: DateTime.now().minus({ days: 2 }), + }, + }, +}; + +const meta: Meta = { + argTypes: { + endDateProps: { + errorMessage: { + control: 'text', + description: 'Custom error message for invalid end date', + }, + label: { + control: 'text', + description: 'Custom label for the end date-time picker', + }, + placeholder: { + control: 'text', + description: 'Placeholder for the end date-time', + }, + showTimeZone: { + control: 'boolean', + description: + 'Whether to show the timezone selector for the end date picker', + }, + value: { + control: 'date', + description: 'Initial or controlled value for the end date-time', + }, + }, + format: { + control: 'text', + description: 'Format for displaying the date-time', + }, + onChange: { + action: 'DateTime range changed', + description: 'Callback when the date-time range changes', + }, + presetsProps: { + defaultValue: { + label: { + control: 'text', + description: 'Default value label for the presets field', + }, + value: { + control: 'text', + description: 'Default value for the presets field', + }, + }, + enablePresets: { + control: 'boolean', + description: + 'If true, shows the date presets field instead of the date pickers', + }, + label: { + control: 'text', + description: 'Label for the presets dropdown', + }, + placeholder: { + control: 'text', + description: 'Placeholder for the presets dropdown', + }, + }, + startDateProps: { + errorMessage: { + control: 'text', + description: 'Custom error message for invalid start date', + }, + placeholder: { + control: 'text', + description: 'Placeholder for the start date-time', + }, + showTimeZone: { + control: 'boolean', + description: + 'Whether to show the timezone selector for the start date picker', + }, + startLabel: { + control: 'text', + description: 'Custom label for the start date-time picker', + }, + timeZoneValue: { + control: 'text', + description: 'Initial or controlled value for the start timezone', + }, + value: { + control: 'date', + description: 'Initial or controlled value for the start date-time', + }, + }, + sx: { + control: 'object', + description: 'Styles to apply to the root element', + }, + }, + args: { + endDateProps: { label: 'End Date and Time' }, + format: 'yyyy-MM-dd HH:mm', + startDateProps: { label: 'Start Date and Time' }, + }, + component: DateTimeRangePicker, + title: 'Components/DatePicker/DateTimeRangePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx new file mode 100644 index 00000000000..3dc542f4c36 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -0,0 +1,354 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DateTimeRangePicker } from './DateTimeRangePicker'; + +import type { DateTimeRangePickerProps } from './DateTimeRangePicker'; + +const onChangeMock = vi.fn(); + +const Props: DateTimeRangePickerProps = { + endDateProps: { + label: 'End Date and Time', + }, + onChange: onChangeMock, + presetsProps: { + enablePresets: true, + label: 'Date Presets', + }, + + startDateProps: { + label: 'Start Date and Time', + }, +}; + +describe('DateTimeRangePicker Component', () => { + beforeEach(() => { + // Mock DateTime.now to return a fixed datetime + const fixedNow = DateTime.fromISO( + '2024-12-18T00:28:27.071-06:00' + ).toUTC() as DateTime; + vi.setSystemTime(fixedNow.toJSDate()); + }); + + afterEach(() => { + // Restore the original DateTime.now implementation after each test + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('should render start and end DateTimePickers with correct labels', () => { + renderWithTheme(); + + expect(screen.getByLabelText('Start Date and Time')).toBeVisible(); + expect(screen.getByLabelText('End Date and Time')).toBeVisible(); + }); + + it('should call onChange when start date is changed', async () => { + vi.setSystemTime(vi.getRealSystemTime()); + + renderWithTheme(); + + // Open start date picker + await userEvent.click(screen.getByLabelText('Start Date and Time')); + + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + const expectedStartTime = DateTime.fromObject({ + day: 10, + month: DateTime.now().month, + year: DateTime.now().year, + }).toISO(); + + // Check if the onChange function is called with the expected value + expect(onChangeMock).toHaveBeenCalledWith({ + end: null, + preset: 'custom_range', + start: expectedStartTime, + timeZone: null, + }); + }); + + it('should show error when end date-time is before start date-time', async () => { + renderWithTheme(); + + // Set start date-time to the 15th + const startDateField = screen.getByLabelText('Start Date and Time'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Open the end date picker + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + + // Set start date-time to the 10th + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm error message is displayed + expect( + screen.getByText('End date/time cannot be before the start date/time.') + ).toBeInTheDocument(); + }); + + it('should show error when start date-time is after end date-time', async () => { + const updateProps = { + ...Props, + presetsProps: { ...Props.presetsProps, enablePresets: false }, + }; + renderWithTheme(); + + // Set the end date-time to the 15th + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Set the start date-time to the 10th (which is earlier than the end date-time) + const startDateField = screen.getByLabelText('Start Date and Time'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '20' })); // Invalid date + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm the error message is displayed + expect( + screen.getByText('Start date/time cannot be after the end date/time.') + ).toBeInTheDocument(); + }); + + it('should display custom error messages when start date-time is after end date-time', async () => { + const updatedProps = { + ...Props, + endDateProps: { + ...Props.endDateProps, + errorMessage: 'Custom end date error', + label: 'End Date and Time', + }, + presetsProps: {}, + startDateProps: { + ...Props.startDateProps, + errorMessage: 'Custom start date error', + label: 'Start Date and Time', + }, + }; + renderWithTheme(); + + // Set the end date-time to the 15th + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Set the start date-time to the 20th (which is earlier than the end date-time) + const startDateField = screen.getByLabelText('Start Date and Time'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '20' })); // Invalid date + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm the custom error message is displayed for the start date + expect(screen.getByText('Custom start date error')).toBeInTheDocument(); + }); + + it('should set the date range for the last 24 hours when the "Last 24 Hours" preset is selected', async () => { + renderWithTheme(); + + // Open the presets dropdown + const presetsDropdown = screen.getByLabelText('Date Presets'); + await userEvent.click(presetsDropdown); + + // Select the "Last 24 Hours" option + const last24HoursOption = screen.getByText('Last 24 Hours'); + await userEvent.click(last24HoursOption); + + // Expected start and end dates in ISO format + const expectedStartDateISO = DateTime.now().minus({ hours: 24 }).toISO(); // 2024-12-17T00:28:27.071-06:00 + const expectedEndDateISO = DateTime.now().toISO(); // 2024-12-18T00:28:27.071-06:00 + + // Verify onChangeMock was called with correct ISO strings + expect(onChangeMock).toHaveBeenCalledWith({ + end: expectedEndDateISO, + preset: '24hours', + start: expectedStartDateISO, + timeZone: null, + }); + expect( + screen.queryByRole('button', { name: 'Presets' }) + ).not.toBeInTheDocument(); + }); + + it('should set the date range for the last 7 days when the "Last 7 Days" preset is selected', async () => { + renderWithTheme(); + + // Open the presets dropdown + const presetsDropdown = screen.getByLabelText('Date Presets'); + await userEvent.click(presetsDropdown); + + // Select the "Last 7 Days" option + const last7DaysOption = screen.getByText('Last 7 Days'); + await userEvent.click(last7DaysOption); + + // Expected start and end dates in ISO format + const expectedStartDateISO = DateTime.now().minus({ days: 7 }).toISO(); + const expectedEndDateISO = DateTime.now().toISO(); + + // Verify that onChange is called with the correct date range + expect(onChangeMock).toHaveBeenCalledWith({ + end: expectedEndDateISO, + preset: '7days', + start: expectedStartDateISO, + timeZone: null, + }); + expect( + screen.queryByRole('button', { name: 'Presets' }) + ).not.toBeInTheDocument(); + }); + + it('should set the date range for the last 30 days when the "Last 30 Days" preset is selected', async () => { + renderWithTheme(); + + // Open the presets dropdown + const presetsDropdown = screen.getByLabelText('Date Presets'); + await userEvent.click(presetsDropdown); + + // Select the "Last 30 Days" option + const last30DaysOption = screen.getByText('Last 30 Days'); + await userEvent.click(last30DaysOption); + + // Expected start and end dates in ISO format + const expectedStartDateISO = DateTime.now().minus({ days: 30 }).toISO(); + const expectedEndDateISO = DateTime.now().toISO(); + + // Verify that onChange is called with the correct date range + expect(onChangeMock).toHaveBeenCalledWith({ + end: expectedEndDateISO, + preset: '30days', + start: expectedStartDateISO, + timeZone: null, + }); + expect( + screen.queryByRole('button', { name: 'Presets' }) + ).not.toBeInTheDocument(); + }); + + it('should set the date range for this month when the "This Month" preset is selected', async () => { + renderWithTheme(); + + // Open the presets dropdown + const presetsDropdown = screen.getByLabelText('Date Presets'); + await userEvent.click(presetsDropdown); + + // Select the "This Month" option + const thisMonthOption = screen.getByText('This Month'); + await userEvent.click(thisMonthOption); + + // Expected start and end dates in ISO format + const expectedStartDateISO = DateTime.now().startOf('month').toISO(); + const expectedEndDateISO = DateTime.now().endOf('month').toISO(); + + // Verify that onChange is called with the correct date range + expect(onChangeMock).toHaveBeenCalledWith({ + end: expectedEndDateISO, + preset: 'this_month', + start: expectedStartDateISO, + timeZone: null, + }); + expect( + screen.queryByRole('button', { name: 'Presets' }) + ).not.toBeInTheDocument(); + }); + + it('should set the date range for last month when the "Last Month" preset is selected', async () => { + renderWithTheme(); + + // Open the presets dropdown + const presetsDropdown = screen.getByLabelText('Date Presets'); + await userEvent.click(presetsDropdown); + + // Select the "Last Month" option + const lastMonthOption = screen.getByText('Last Month'); + await userEvent.click(lastMonthOption); + + const lastMonth = DateTime.now().minus({ months: 1 }); + + // Expected start and end dates in ISO format + const expectedStartDateISO = lastMonth.startOf('month').toISO(); + const expectedEndDateISO = lastMonth.endOf('month').toISO(); + + // Verify that onChange is called with the correct date range + expect(onChangeMock).toHaveBeenCalledWith({ + end: expectedEndDateISO, + preset: 'last_month', + start: expectedStartDateISO, + timeZone: null, + }); + expect( + screen.queryByRole('button', { name: 'Presets' }) + ).not.toBeInTheDocument(); + }); + + it('should display the date range fields with empty values when the "Custom Range" preset is selected', async () => { + renderWithTheme(); + + // Open the presets dropdown + const presetsDropdown = screen.getByLabelText('Date Presets'); + await userEvent.click(presetsDropdown); + + // Select the "Custom Range" option + const customRange = screen.getByText('Custom'); + await userEvent.click(customRange); + + // Verify the input fields display the correct values + expect( + screen.getByRole('textbox', { name: 'Start Date and Time' }) + ).toHaveValue(''); + expect( + screen.getByRole('textbox', { name: 'End Date and Time' }) + ).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Presets' })).toBeInTheDocument(); + + // Set start date-time to the 15th + const startDateField = screen.getByLabelText('Start Date and Time'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Open the end date picker + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + + // Set start date-time to the 12th + await userEvent.click(screen.getByRole('gridcell', { name: '12' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm error message is shown since the click was blocked + expect( + screen.getByText('End date/time cannot be before the start date/time.') + ).toBeInTheDocument(); + + // Set start date-time to the 11th + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '11' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm error message is not displayed + expect( + screen.queryByText('End date/time cannot be before the start date/time.') + ).not.toBeInTheDocument(); + + // Set start date-time to the 20th + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '20' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm error message is not displayed + expect( + screen.queryByText('Start date/time cannot be after the end date/time.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx new file mode 100644 index 00000000000..7083ba7bee8 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -0,0 +1,313 @@ +import { Autocomplete, Box, StyledActionButton } from '@linode/ui'; +import { useTheme } from '@mui/material'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { DateTime } from 'luxon'; +import React, { useState } from 'react'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { SxProps, Theme } from '@mui/material/styles'; + +export interface DateTimeRangePickerProps { + /** Properties for the end date field */ + endDateProps?: { + /** Custom error message for invalid end date */ + errorMessage?: string; + /** Label for the end date field */ + label?: string; + /** placeholder for the end date field */ + placeholder?: string; + /** Whether to show the timezone selector for the end date */ + showTimeZone?: boolean; + /** Initial or controlled value for the end date-time */ + value?: DateTime | null; + }; + + /** Format for displaying the date-time */ + format?: string; + + /** Callback when the date-time range changes, + * this returns start date, end date in ISO formate, + * preset value and timezone + * */ + onChange?: (params: { + end: null | string; + preset?: string; + start: null | string; + timeZone?: null | string; + }) => void; + + /** Additional settings for the presets dropdown */ + presetsProps?: { + /** Default value for the presets field */ + defaultValue?: { label: string; value: string }; + /** If true, shows the date presets field instead of the date pickers */ + enablePresets?: boolean; + /** Label for the presets field */ + label?: string; + /** placeholder for the presets field */ + placeholder?: string; + }; + + /** Properties for the start date field */ + startDateProps?: { + /** Custom error message for invalid start date */ + errorMessage?: string; + /** Label for the start date field */ + label?: string; + /** placeholder for the start date field */ + placeholder?: string; + /** Whether to show the timezone selector for the start date */ + showTimeZone?: boolean; + /** Initial or controlled value for the start timezone */ + timeZoneValue?: null | string; + /** Initial or controlled value for the start date-time */ + value?: DateTime | null; + }; + + /** Any additional styles to apply to the root element */ + sx?: SxProps; +} + +type DatePresetType = + | '7days' + | '24hours' + | '30days' + | 'custom_range' + | 'last_month' + | 'this_month'; + +const presetsOptions: { label: string; value: DatePresetType }[] = [ + { label: 'Last 24 Hours', value: '24hours' }, + { label: 'Last 7 Days', value: '7days' }, + { label: 'Last 30 Days', value: '30days' }, + { label: 'This Month', value: 'this_month' }, + { label: 'Last Month', value: 'last_month' }, + { label: 'Custom', value: 'custom_range' }, +]; + +export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { + const { + endDateProps: { + errorMessage: endDateErrorMessage = 'End date/time cannot be before the start date/time.', + label: endLabel = 'End Date and Time', + placeholder: endDatePlaceholder, + showTimeZone: showEndTimeZone = false, + value: endDateTimeValue = null, + } = {}, + + format = 'yyyy-MM-dd HH:mm', + + onChange, + + presetsProps: { + defaultValue: presetsDefaultValue = { label: '', value: '' }, + enablePresets = false, + label: presetsLabel = 'Time Range', + placeholder: presetsPlaceholder = 'Select a preset', + } = {}, + startDateProps: { + errorMessage: startDateErrorMessage = 'Start date/time cannot be after the end date/time.', + label: startLabel = 'Start Date and Time', + placeholder: startDatePlaceholder, + showTimeZone: showStartTimeZone = false, + timeZoneValue: startTimeZoneValue = null, + value: startDateTimeValue = null, + } = {}, + sx, + } = props; + + const [startDateTime, setStartDateTime] = useState( + startDateTimeValue + ); + const [endDateTime, setEndDateTime] = useState( + endDateTimeValue + ); + const [presetValue, setPresetValue] = useState<{ + label: string; + value: string; + }>(presetsDefaultValue); + const [startTimeZone, setStartTimeZone] = useState( + startTimeZoneValue + ); + const [startDateError, setStartDateError] = useState(null); + const [endDateError, setEndDateError] = useState(null); + const [showPresets, setShowPresets] = useState(enablePresets); + + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const validateDates = ( + start: DateTime | null, + end: DateTime | null, + source: 'end' | 'start' + ) => { + if (start && end) { + if (source === 'start' && start > end) { + setStartDateError(startDateErrorMessage); + return; + } + if (source === 'end' && end < start) { + setEndDateError(endDateErrorMessage); + return; + } + } + // Reset validation errors + setStartDateError(null); + setEndDateError(null); + }; + + const handlePresetSelection = (value: DatePresetType) => { + const now = DateTime.now(); + let newStartDateTime: DateTime | null = null; + let newEndDateTime: DateTime | null = null; + + switch (value) { + case '24hours': + newStartDateTime = now.minus({ hours: 24 }); + newEndDateTime = now; + break; + case '7days': + newStartDateTime = now.minus({ days: 7 }); + newEndDateTime = now; + break; + case '30days': + newStartDateTime = now.minus({ days: 30 }); + newEndDateTime = now; + break; + case 'this_month': + newStartDateTime = now.startOf('month'); + newEndDateTime = now.endOf('month'); + break; + case 'last_month': + const lastMonth = now.minus({ months: 1 }); + newStartDateTime = lastMonth.startOf('month'); + newEndDateTime = lastMonth.endOf('month'); + break; + case 'custom_range': + newStartDateTime = null; + newEndDateTime = null; + break; + default: + return; + } + + setStartDateTime(newStartDateTime); + setEndDateTime(newEndDateTime); + setPresetValue( + presetsOptions.find((option) => option.value === value) ?? + presetsDefaultValue + ); + + if (onChange) { + onChange({ + end: newEndDateTime?.toISO() ?? null, + preset: value, + start: newStartDateTime?.toISO() ?? null, + timeZone: startTimeZone, + }); + } + + setShowPresets(value !== 'custom_range'); + }; + + const handleStartDateTimeChange = (newStart: DateTime | null) => { + setStartDateTime(newStart); + validateDates(newStart, endDateTime, 'start'); + + if (onChange) { + onChange({ + end: endDateTime?.toISO() ?? null, + preset: 'custom_range', + start: newStart?.toISO() ?? null, + timeZone: startTimeZone, + }); + } + }; + + const handleEndDateTimeChange = (newEnd: DateTime | null) => { + setEndDateTime(newEnd); + validateDates(startDateTime, newEnd, 'end'); + + if (onChange) { + onChange({ + end: newEnd?.toISO() ?? null, + preset: 'custom_range', + start: startDateTime?.toISO() ?? null, + timeZone: startTimeZone, + }); + } + }; + + return ( + + {showPresets ? ( + { + if (selection) { + handlePresetSelection(selection.value as DatePresetType); + } + }} + defaultValue={presetsDefaultValue} + disableClearable + fullWidth + label={presetsLabel} + noMarginTop + options={presetsOptions} + placeholder={presetsPlaceholder} + value={presetValue} + /> + ) : ( + + setStartTimeZone(value), + value: startTimeZone, + }} + errorText={startDateError ?? undefined} + format={format} + label={startLabel} + onChange={handleStartDateTimeChange} + placeholder={startDatePlaceholder} + showTimeZone={showStartTimeZone} + timeSelectProps={{ label: 'Start Time' }} + value={startDateTime} + /> + + + { + setShowPresets(true); + setPresetValue(presetsDefaultValue); + }} + variant="text" + > + Presets + + + + )} + + ); +}; diff --git a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx new file mode 100644 index 00000000000..f4bd68c97a3 --- /dev/null +++ b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx @@ -0,0 +1,64 @@ +import { Autocomplete } from '@linode/ui'; +import { DateTime } from 'luxon'; +import React from 'react'; + +import { timezones } from 'src/assets/timezones/timezones'; + +type Timezone = typeof timezones[number]; + +interface TimeZoneSelectProps { + disabled?: boolean; + errorText?: string; + label?: string; + noMarginTop?: boolean; + onChange: (timezone: string) => void; + value: null | string; +} + +const getOptionLabel = ({ label, offset }: Timezone) => { + const minutes = (Math.abs(offset) % 60).toLocaleString(undefined, { + minimumIntegerDigits: 2, + useGrouping: false, + }); + const hours = Math.floor(Math.abs(offset) / 60); + const isPositive = Math.abs(offset) === offset ? '+' : '-'; + + return `(GMT ${isPositive}${hours}:${minutes}) ${label}`; +}; + +const getTimezoneOptions = () => { + return timezones + .map((tz) => { + const offset = DateTime.now().setZone(tz.name).offset; + const label = getOptionLabel({ ...tz, offset }); + return { label, offset, value: tz.name }; + }) + .sort((a, b) => a.offset - b.offset); +}; + +const timezoneOptions = getTimezoneOptions(); + +export const TimeZoneSelect = ({ + disabled = false, + errorText, + label = 'Timezone', + noMarginTop = false, + onChange, + value, +}: TimeZoneSelectProps) => { + return ( + option.value === value) ?? undefined + } + autoHighlight + disabled={disabled} + errorText={errorText} + label={label} + noMarginTop={noMarginTop} + onChange={(e, option) => onChange(option?.value || '')} + options={timezoneOptions} + placeholder="Choose a Timezone" + /> + ); +}; diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx index e9372ce1eea..278748ecc43 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx @@ -33,9 +33,6 @@ const meta: Meta = { open: { description: 'Is the modal open?', }, - typeToConfirm: { - description: `Whether or not a user is required to type the enity's label to delete.`, - }, }, args: { disableAutoFocus: true, @@ -52,7 +49,6 @@ const meta: Meta = { onDelete: action('onDelete'), open: true, style: { position: 'unset' }, - typeToConfirm: true, }, component: DeletionDialog, title: 'Components/Dialog/DeletionDialog', diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx index c1385003852..ad5747dda1d 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx @@ -6,6 +6,25 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { DeletionDialog } from './DeletionDialog'; import type { DeletionDialogProps } from './DeletionDialog'; +import type { ManagerPreferences } from 'src/types/ManagerPreferences'; + +const preference: ManagerPreferences['type_to_confirm'] = true; + +const queryMocks = vi.hoisted(() => ({ + usePreferences: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/profile/preferences', async () => { + const actual = await vi.importActual('src/queries/profile/preferences'); + return { + ...actual, + usePreferences: queryMocks.usePreferences, + }; +}); + +queryMocks.usePreferences.mockReturnValue({ + data: preference, +}); describe('DeletionDialog', () => { const defaultArgs: DeletionDialogProps = { @@ -72,12 +91,21 @@ describe('DeletionDialog', () => { }); it('should call onDelete when the DeletionDialog delete button is clicked', () => { + queryMocks.usePreferences.mockReturnValue({ + data: preference, + }); const { getByTestId } = renderWithTheme( ); const deleteButton = getByTestId('confirm'); - expect(deleteButton).not.toBeDisabled(); + expect(deleteButton).toBeDisabled(); + + const input = getByTestId('textfield-input'); + fireEvent.change(input, { target: { value: defaultArgs.label } }); + + expect(deleteButton).toBeEnabled(); + fireEvent.click(deleteButton); expect(defaultArgs.onDelete).toHaveBeenCalled(); @@ -128,12 +156,12 @@ describe('DeletionDialog', () => { ])( 'should %s input field with label when typeToConfirm is %s', (_, typeToConfirm) => { + queryMocks.usePreferences.mockReturnValue({ + data: typeToConfirm, + }); + const { queryByTestId } = renderWithTheme( - + ); if (typeToConfirm) { diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx index 93f1c2bcdf9..efcedc42a6f 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx @@ -19,15 +19,8 @@ export interface DeletionDialogProps extends Omit { onClose: () => void; onDelete: () => void; open: boolean; - typeToConfirm?: boolean; } -/** - * A Deletion Dialog is used for deleting entities such as Linodes, NodeBalancers, Volumes, or other entities. - * - * Require `typeToConfirm` when an action would have a significant negative impact if done in error, consider requiring the user to enter a unique identifier such as entity label before activating the action button. - * If a user has opted out of type-to-confirm this will be ignored - */ export const DeletionDialog = React.memo((props: DeletionDialogProps) => { const theme = useTheme(); const { @@ -38,18 +31,21 @@ export const DeletionDialog = React.memo((props: DeletionDialogProps) => { onClose, onDelete, open, - typeToConfirm, ...rest } = props; - const { data: preferences } = usePreferences(); + + const { data: typeToConfirmPreference } = usePreferences( + (preferences) => preferences?.type_to_confirm ?? true + ); + const [confirmationText, setConfirmationText] = React.useState(''); - const typeToConfirmRequired = - typeToConfirm && preferences?.type_to_confirm !== false; + const renderActions = () => ( { onChange={(input) => { setConfirmationText(input); }} + expand label={`${capitalize(entity)} Name:`} placeholder={label} value={confirmationText} - visible={typeToConfirmRequired} + visible={Boolean(typeToConfirmPreference)} /> ); diff --git a/packages/manager/src/components/Encryption/constants.tsx b/packages/manager/src/components/Encryption/constants.tsx index 0c41c74573c..7224e491364 100644 --- a/packages/manager/src/components/Encryption/constants.tsx +++ b/packages/manager/src/components/Encryption/constants.tsx @@ -49,9 +49,6 @@ export const DISK_ENCRYPTION_DESCRIPTION_NODE_POOL_REBUILD_CAVEAT = export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = 'Virtual Machine Backups are not encrypted.'; -export const DISK_ENCRYPTION_IMAGES_CAVEAT_COPY = - 'Virtual Machine Images are not encrypted.'; - export const ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON = 'The Encrypt Disk setting cannot be changed for a Linode attached to a node pool.'; diff --git a/packages/manager/src/components/EnhancedSelect/Select.styles.ts b/packages/manager/src/components/EnhancedSelect/Select.styles.ts index 3d1b2d4260f..99a45b34250 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.styles.ts +++ b/packages/manager/src/components/EnhancedSelect/Select.styles.ts @@ -90,7 +90,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ cursor: 'text', }, '&--is-focused, &--is-focused:hover': { - border: `1px dotted #999`, + border: `1px dotted ${theme.tokens.color.Neutrals[50]}`, }, backgroundColor: theme.bg.white, border: `1px solid transparent`, @@ -294,7 +294,7 @@ export const reactSelectStyles = (theme: Theme) => ({ cursor: 'text', }, '&--is-focused, &--is-focused:hover': { - border: `1px dotted #999`, + border: `1px dotted ${theme.tokens.color.Neutrals[50]}`, }, backgroundColor: theme.bg.white, border: `1px solid transparent`, diff --git a/packages/manager/src/components/IconTextLink/IconTextLink.tsx b/packages/manager/src/components/IconTextLink/IconTextLink.tsx index 8a2f79ebf7a..0a19f86edae 100644 --- a/packages/manager/src/components/IconTextLink/IconTextLink.tsx +++ b/packages/manager/src/components/IconTextLink/IconTextLink.tsx @@ -8,14 +8,14 @@ import type { SvgIcon } from 'src/components/SvgIcon'; const useStyles = makeStyles()((theme: Theme) => ({ active: { - color: '#1f64b6', + color: theme.tokens.color.Ultramarine[80], }, disabled: { '& $icon': { - borderColor: '#939598', - color: '#939598', + borderColor: theme.tokens.color.Neutrals[50], + color: theme.tokens.color.Neutrals[50], }, - color: '#939598', + color: theme.tokens.color.Neutrals[50], pointerEvents: 'none', }, icon: { @@ -43,7 +43,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ justifyContent: 'center', }, root: { - '&:focus': { outline: '1px dotted #999' }, + '&:focus': { outline: `1px dotted ${theme.tokens.color.Neutrals[50]}` }, '&:hover': { '& .border': { color: theme.palette.primary.light, diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index 1e09b698713..62127aa16c8 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -1,8 +1,10 @@ -import { Autocomplete } from '@linode/ui'; +import { Autocomplete, Box, Notice, Stack, Typography } from '@linode/ui'; +import { DateTime } from 'luxon'; import React, { useMemo } from 'react'; import { imageFactory } from 'src/factories/images'; import { useAllImagesQuery } from 'src/queries/images'; +import { formatDate } from 'src/utilities/formatDate'; import { OSIcon } from '../OSIcon'; import { ImageOption } from './ImageOption'; @@ -10,6 +12,7 @@ import { getAPIFilterForImageSelect, getDisabledImages, getFilteredImagesForImageSelect, + isImageDeprecated, } from './utilities'; import type { Image, RegionSite } from '@linode/api-v4'; @@ -131,69 +134,114 @@ export const ImageSelect = (props: Props) => { return options.find((option) => option.id === selected) ?? null; }, [multiple, options, selected]); + const selectedDeprecatedImages = useMemo(() => { + if (!value) { + return false; + } + if (Array.isArray(value)) { + return value.filter((img) => isImageDeprecated(img)); + } + return isImageDeprecated(value) && [value]; + }, [value]); + if (options.length === 1 && onChange && selectIfOnlyOneOption && !multiple) { onChange(options[0]); } return ( - { - if (option.id === 'any/all') { - return ''; + + { + if (option.id === 'any/all') { + return ''; + } + if (!option.is_public) { + return 'My Images'; + } + + return option.vendor ?? ''; + }} + renderOption={(props, option, state) => { + const { key, ...rest } = props; + + return ( + + ); + }} + textFieldProps={{ + InputProps: { + startAdornment: + !multiple && value && !Array.isArray(value) ? ( + + ) : null, + }, + }} + clearOnBlur + disableSelectAll + label={label || 'Images'} + loading={isLoading} + options={sortedOptions} + placeholder={placeholder || 'Choose an image'} + {...rest} + disableClearable={ + rest.disableClearable ?? + (selectIfOnlyOneOption && options.length === 1 && !multiple) } - if (!option.is_public) { - return 'My Images'; + onChange={(_, value) => + multiple && Array.isArray(value) + ? onChange(value) + : !multiple && !Array.isArray(value) && onChange(value) } - - return option.vendor ?? ''; - }} - renderOption={(props, option, state) => { - const { key, ...rest } = props; - - return ( - - ); - }} - textFieldProps={{ - InputProps: { - startAdornment: - !multiple && value && !Array.isArray(value) ? ( - - ) : null, - }, - }} - clearOnBlur - disableSelectAll - label={label || 'Images'} - loading={isLoading} - options={sortedOptions} - placeholder={placeholder || 'Choose an image'} - {...rest} - disableClearable={ - rest.disableClearable ?? - (selectIfOnlyOneOption && options.length === 1 && !multiple) - } - onChange={(_, value) => - multiple && Array.isArray(value) - ? onChange(value) - : !multiple && !Array.isArray(value) && onChange(value) - } - errorText={rest.errorText ?? error?.[0].reason} - getOptionDisabled={(option) => Boolean(disabledImages[option.id])} - multiple={multiple} - value={value} - /> + errorText={rest.errorText ?? error?.[0].reason} + getOptionDisabled={(option) => Boolean(disabledImages[option.id])} + multiple={multiple} + value={value} + /> + + + {selectedDeprecatedImages && + selectedDeprecatedImages.map((image) => ( + + {image.eol && DateTime.fromISO(image.eol) > DateTime.now() ? ( + theme.font.bold}> + {image.label} will reach its end-of-life on{' '} + {formatDate(image.eol ?? '', { format: 'MM/dd/yyyy' })}. After + this date, this OS distribution will no longer receive + security updates or technical support. We recommend selecting + a newer supported version to ensure continued security and + stability for your linodes. + + ) : ( + theme.font.bold}> + {image.label} reached its end-of-life on{' '} + {formatDate(image.eol ?? '', { format: 'MM/dd/yyyy' })}. This + OS distribution will no longer receive security updates or + technical support. We recommend selecting a newer supported + version to ensure continued security and stability for your + linodes. + + )} + + ))} + + ); }; diff --git a/packages/manager/src/components/LineGraph/LineGraph.tsx b/packages/manager/src/components/LineGraph/LineGraph.tsx index 3cf464e8199..5563f67b180 100644 --- a/packages/manager/src/components/LineGraph/LineGraph.tsx +++ b/packages/manager/src/components/LineGraph/LineGraph.tsx @@ -271,9 +271,9 @@ export const LineGraph = (props: LineGraphProps) => { ], }, tooltips: { - backgroundColor: '#fbfbfb', - bodyFontColor: '#32363C', - borderColor: '#999', + backgroundColor: theme.tokens.color.Neutrals[5], + bodyFontColor: theme.tokens.color.Neutrals[90], + borderColor: theme.tokens.color.Neutrals[50], borderWidth: 0.5, callbacks: { label: _formatTooltip(data, formatTooltip, _tooltipUnit), diff --git a/packages/manager/src/components/Link.tsx b/packages/manager/src/components/Link.tsx index 8242c791db1..ccdd26198a9 100644 --- a/packages/manager/src/components/Link.tsx +++ b/packages/manager/src/components/Link.tsx @@ -46,7 +46,7 @@ export interface LinkProps extends Omit<_LinkProps, 'to'> { * @example "/profile/display" * @example "https://linode.com" */ - to: TanStackLinkProps['to'] | (string & {}); + to: Exclude; } /** diff --git a/packages/manager/src/components/MainContentBanner.tsx b/packages/manager/src/components/MainContentBanner.tsx index 60678184e50..239389e033c 100644 --- a/packages/manager/src/components/MainContentBanner.tsx +++ b/packages/manager/src/components/MainContentBanner.tsx @@ -27,12 +27,14 @@ export const MainContentBanner = React.memo(() => { const flags = useFlags(); - const { data: preferences } = usePreferences(); + const { data: mainContentBannerPreferences } = usePreferences( + (preferences) => preferences?.main_content_banner_dismissal + ); const { mutateAsync: updatePreferences } = useMutatePreferences(); const handleDismiss = (key: string) => { const existingMainContentBannerDismissal = - preferences?.main_content_banner_dismissal ?? {}; + mainContentBannerPreferences ?? {}; updatePreferences({ main_content_banner_dismissal: { @@ -44,7 +46,7 @@ export const MainContentBanner = React.memo(() => { const hasDismissedBanner = flags.mainContentBanner?.key !== undefined && - preferences?.main_content_banner_dismissal?.[flags.mainContentBanner.key]; + mainContentBannerPreferences?.[flags.mainContentBanner.key]; if ( !flags.mainContentBanner || diff --git a/packages/manager/src/components/MaskableText/MaskableText.test.tsx b/packages/manager/src/components/MaskableText/MaskableText.test.tsx index 8c910d491bf..38c101a9230 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.test.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.test.tsx @@ -18,9 +18,7 @@ describe('MaskableText', () => { text: plainText, }; - const preferences: ManagerPreferences = { - maskSensitiveData: true, - }; + const preference: ManagerPreferences['maskSensitiveData'] = true; const queryMocks = vi.hoisted(() => ({ usePreferences: vi.fn().mockReturnValue({}), @@ -35,12 +33,12 @@ describe('MaskableText', () => { }); queryMocks.usePreferences.mockReturnValue({ - data: preferences, + data: preference, }); it('should render masked text if the maskSensitiveData preference is enabled', () => { queryMocks.usePreferences.mockReturnValue({ - data: preferences, + data: preference, }); const { getByText, queryByText } = renderWithTheme( @@ -54,9 +52,7 @@ describe('MaskableText', () => { it('should not render masked text if the maskSensitiveData preference is disabled', () => { queryMocks.usePreferences.mockReturnValue({ - data: { - maskSensitiveData: false, - }, + data: false, }); const { getByText, queryByText } = renderWithTheme( @@ -70,9 +66,7 @@ describe('MaskableText', () => { it("should render MaskableText's children if the maskSensitiveData preference is disabled and children are provided", () => { queryMocks.usePreferences.mockReturnValue({ - data: { - maskSensitiveData: false, - }, + data: false, }); const plainTextElement =
{plainText}
; @@ -87,7 +81,7 @@ describe('MaskableText', () => { it('should render a toggleable VisibilityTooltip if isToggleable is provided', async () => { queryMocks.usePreferences.mockReturnValue({ - data: preferences, + data: preference, }); const { getByTestId, getByText } = renderWithTheme( diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx index 7f9b395fc8e..dbeaedb1988 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.tsx @@ -5,6 +5,8 @@ import * as React from 'react'; import { usePreferences } from 'src/queries/profile/preferences'; import { createMaskedText } from 'src/utilities/createMaskedText'; +import type { SxProps, Theme } from '@mui/material'; + export type MaskableTextLength = 'ipv4' | 'ipv6' | 'plaintext'; export interface MaskableTextProps { @@ -12,14 +14,28 @@ export interface MaskableTextProps { * (Optional) original JSX element to render if the text is not masked. */ children?: JSX.Element | JSX.Element[]; + /** + * Optionally specifies the position of the VisibilityTooltip icon either before or after the text. + * @default end + */ + iconPosition?: 'end' | 'start'; /** * If true, displays a VisibilityTooltip icon to toggle the masked and unmasked text. + * @default false */ isToggleable?: boolean; /** * Optionally specifies the length of the masked text to depending on data type (e.g. 'ipv4', 'ipv6', 'plaintext'); if not provided, will use a default length. */ length?: MaskableTextLength; + /** + * Optional styling for the masked and unmasked Typography + */ + sxTypography?: SxProps; + /** + * Optional styling for VisibilityTooltip icon + */ + sxVisibilityTooltip?: SxProps; /** * The original, maskable text; if the text is not masked, render this text or the styled text via children. */ @@ -27,14 +43,27 @@ export interface MaskableTextProps { } export const MaskableText = (props: MaskableTextProps) => { - const { children, isToggleable = false, text, length } = props; + const { + children, + iconPosition = 'end', + isToggleable = false, + length, + sxTypography, + sxVisibilityTooltip, + text, + } = props; - const { data: preferences } = usePreferences(); - const maskedPreferenceSetting = preferences?.maskSensitiveData; + const { data: maskedPreferenceSetting } = usePreferences( + (preferences) => preferences?.maskSensitiveData + ); const [isMasked, setIsMasked] = React.useState(maskedPreferenceSetting); - const unmaskedText = children ? children : {text}; + const unmaskedText = children ? ( + children + ) : ( + {text} + ); // Return early based on the preference setting and the original text. @@ -53,14 +82,25 @@ export const MaskableText = (props: MaskableTextProps) => { flexDirection="row" justifyContent="flex-start" > + {iconPosition === 'start' && isToggleable && ( + setIsMasked(!isMasked)} + isVisible={!isMasked} + /> + )} {isMasked ? ( - + {createMaskedText(text, length)} ) : ( unmaskedText )} - {isToggleable && ( + {iconPosition === 'end' && isToggleable && ( setIsMasked(!isMasked)} isVisible={!isMasked} diff --git a/packages/manager/src/components/MaskableText/MaskableTextArea.tsx b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx new file mode 100644 index 00000000000..abbeb85697a --- /dev/null +++ b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx @@ -0,0 +1,18 @@ +import { Typography } from '@linode/ui'; +import React from 'react'; + +import { Link } from '../Link'; + +/** + * This copy is intended to display where a larger area of data is masked. + * Example: Billing Contact info, rather than masking many individual fields + */ +export const MaskableTextAreaCopy = () => { + return ( + + This data is sensitive and hidden for privacy. To unmask all sensitive + data by default, go to{' '} + profile settings. + + ); +}; diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.stories.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.stories.tsx new file mode 100644 index 00000000000..7bf2157e428 --- /dev/null +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.stories.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; + +import { MultipleIPInput } from './MultipleIPInput'; + +import type { MultipeIPInputProps } from './MultipleIPInput'; +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +const mockTitle = 'IP Address'; + +const defaultArgs = { + buttonText: 'Add An IP', + ips: [{ address: '192.0.2.1/01' }, { address: '192.0.2.1/02' }], + title: mockTitle, +}; + +const meta: Meta = { + component: MultipleIPInput, + decorators: [ + (Story: StoryFn) => ( +
+ +
+ ), + ], + title: 'Components/MultipleIPInput', +}; + +export default meta; + +const MultipleIPInputWithState = ({ ...args }: MultipeIPInputProps) => { + const [ips, setIps] = useState(args.ips); + + const handleChange = (newIps: typeof ips) => { + setIps(newIps); + }; + + return ; +}; + +export const Default: Story = { + args: defaultArgs, + render: (args) => { + return ; + }, +}; + +export const Disabled: Story = { + args: { + ...defaultArgs, + disabled: true, + }, +}; + +export const HelperText: Story = { + args: { + ...defaultArgs, + helperText: 'helperText', + }, + render: (args) => { + return ; + }, +}; + +export const Placeholder: Story = { + args: { + ips: [{ address: '' }], + placeholder: 'placeholder', + title: mockTitle, + }, + render: (args) => { + return ; + }, +}; diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 2080d9a92ba..1f936b8ee8a 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -61,21 +61,89 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); export interface MultipeIPInputProps { + /** + * Text displayed on the button. + */ buttonText?: string; + + /** + * Custom CSS class for additional styling. + */ className?: string; + + /** + * Disables the component (non-interactive). + * @default false + */ disabled?: boolean; + + /** + * Error message for invalid input. + */ error?: string; + + /** + * Indicates if the input relates to database access controls. + * @default false + */ forDatabaseAccessControls?: boolean; + + /** + * Indicates if the input is for VPC IPv4 ranges. + * @default false + */ forVPCIPv4Ranges?: boolean; + + /** + * Helper text for additional guidance. + */ helperText?: string; + + /** + * Custom input properties passed to the underlying input component. + */ inputProps?: InputBaseProps; + + /** + * Array of `ExtendedIP` objects representing managed IPs. + */ ips: ExtendedIP[]; + + /** + * Styles the button as a link. + * @default false + */ isLinkStyled?: boolean; + + /** + * Callback triggered when the input loses focus, passing updated `ips`. + */ onBlur?: (ips: ExtendedIP[]) => void; + + /** + * Callback triggered when IPs change, passing updated `ips`. + */ onChange: (ips: ExtendedIP[]) => void; + + /** + * Placeholder text for an empty input field. + */ placeholder?: string; + + /** + * Indicates if the input is required for form submission. + * @default false + */ required?: boolean; + + /** + * Title or label for the input field. + */ title: string; + + /** + * Tooltip text for extra info on hover. + */ tooltip?: string; } diff --git a/packages/manager/src/components/OrderBy.test.tsx b/packages/manager/src/components/OrderBy.test.tsx index 273c83e107a..4d3e189c65a 100644 --- a/packages/manager/src/components/OrderBy.test.tsx +++ b/packages/manager/src/components/OrderBy.test.tsx @@ -9,6 +9,8 @@ import { sortData, } from './OrderBy'; +import type { ManagerPreferences } from 'src/types/ManagerPreferences'; + const a = { age: 43, hobbies: ['this', 'that', 'the other'], @@ -61,14 +63,13 @@ describe('OrderBy', () => { }); describe('getInitialValuesFromUserPreferences', () => { - const preferences = { - sortKeys: { - ['listening-services']: { - order: 'desc' as any, - orderBy: 'test-order', - }, + const preferences: ManagerPreferences['sortKeys'] = { + ['listening-services']: { + order: 'desc', + orderBy: 'test-order', }, }; + it('should return values from query params if available', () => { expect( getInitialValuesFromUserPreferences( diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index 0d8f2bab051..9ffcba5454a 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -58,7 +58,7 @@ export type CombinedProps = Props; */ export const getInitialValuesFromUserPreferences = ( preferenceKey: string, - preferences: ManagerPreferences, + preferences: ManagerPreferences['sortKeys'], params: Record, defaultOrderBy?: string, defaultOrder?: Order, @@ -91,7 +91,7 @@ export const getInitialValuesFromUserPreferences = ( }; } return ( - preferences?.sortKeys?.[preferenceKey] ?? { + preferences?.[preferenceKey] ?? { order: defaultOrder, orderBy: defaultOrderBy, } @@ -156,7 +156,9 @@ export const sortData = (orderBy: string, order: Order) => { }; export const OrderBy = (props: CombinedProps) => { - const { data: preferences } = usePreferences(); + const { data: sortPreferences } = usePreferences( + (preferences) => preferences?.sortKeys + ); const { mutateAsync: updatePreferences } = useMutatePreferences(); const location = useLocation(); const history = useHistory(); @@ -164,7 +166,7 @@ export const OrderBy = (props: CombinedProps) => { const initialValues = getInitialValuesFromUserPreferences( props.preferenceKey ?? '', - preferences ?? {}, + sortPreferences ?? {}, params, props.orderBy, props.order @@ -210,7 +212,7 @@ export const OrderBy = (props: CombinedProps) => { if (props.preferenceKey) { updatePreferences({ sortKeys: { - ...(preferences?.sortKeys ?? {}), + ...(sortPreferences ?? {}), [props.preferenceKey]: { order, orderBy }, }, }); diff --git a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx index b4494ec9a26..64afe3886c3 100644 --- a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx +++ b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx @@ -3,28 +3,30 @@ import { usePreferences, } from 'src/queries/profile/preferences'; -export interface PreferenceToggleProps { - preference: T; - togglePreference: () => T; -} +import type { ManagerPreferences } from 'src/types/ManagerPreferences'; interface RenderChildrenProps { - preference: T; - togglePreference: () => T; + preference: NonNullable; + togglePreference: () => NonNullable; } type RenderChildren = (props: RenderChildrenProps) => JSX.Element; -interface Props { - children: RenderChildren; - initialSetCallbackFn?: (value: T) => void; - preferenceKey: string; - preferenceOptions: [T, T]; - toggleCallbackFn?: (value: T) => void; - value?: T; +interface Props { + children: RenderChildren; + initialSetCallbackFn?: (value: ManagerPreferences[Key]) => void; + preferenceKey: Key; + preferenceOptions: [ManagerPreferences[Key], ManagerPreferences[Key]]; + toggleCallbackFn?: (value: ManagerPreferences[Key]) => void; + value?: ManagerPreferences[Key]; } -export const PreferenceToggle = (props: Props) => { +/** + * @deprecated There are more simple ways to use preferences. Look into using `usePreferences` directly. + */ +export const PreferenceToggle = ( + props: Props +) => { const { children, preferenceKey, @@ -33,17 +35,19 @@ export const PreferenceToggle = (props: Props) => { value, } = props; - const { data: preferences } = usePreferences(); + const { data: preference } = usePreferences( + (preferences) => preferences?.[preferenceKey] + ); const { mutateAsync: updateUserPreferences } = useMutatePreferences(); const togglePreference = () => { - let newPreferenceToSet: T; + let newPreferenceToSet: ManagerPreferences[Key]; - if (preferences?.[preferenceKey] === undefined) { + if (preference === undefined) { // Because we default to preferenceOptions[0], toggling with no preference should pick preferenceOptions[1] newPreferenceToSet = preferenceOptions[1]; - } else if (preferences[preferenceKey] === preferenceOptions[0]) { + } else if (preference === preferenceOptions[0]) { newPreferenceToSet = preferenceOptions[1]; } else { newPreferenceToSet = preferenceOptions[0]; @@ -58,11 +62,11 @@ export const PreferenceToggle = (props: Props) => { toggleCallbackFn(newPreferenceToSet); } - return newPreferenceToSet; + return newPreferenceToSet!; }; return children({ - preference: value ?? preferences?.[preferenceKey] ?? preferenceOptions[0], + preference: value ?? preference ?? preferenceOptions[0]!, togglePreference, }); }; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts index 5de34fcc7c1..c551daa3c10 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts @@ -108,7 +108,7 @@ export const StyledAccordion = styled(Accordion, { ({ theme, ...props }) => ({ '& h3': { '& p': { - color: '#B8B8B8', + color: theme.tokens.color.Neutrals[50], transition: theme.transitions.create(['opacity']), ...(props.isCollapsed && { opacity: 0, @@ -116,7 +116,9 @@ export const StyledAccordion = styled(Accordion, { }, // product family icon '& svg': { - color: props.isActiveProductFamily ? '#00B159' : theme.color.grey4, + color: props.isActiveProductFamily + ? theme.tokens.color.Green[70] + : theme.color.grey4, height: 20, marginRight: 14, transition: theme.transitions.create(['color']), diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index f968da8fd05..6cac9b49d01 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -93,7 +93,12 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isIAMEnabled, isIAMBeta } = useIsIAMEnabled(); - const { data: preferences } = usePreferences(); + const { data: collapsedSideNavPreference } = usePreferences( + (preferences) => preferences?.collapsedSideNavProductFamilies + ); + + const collapsedAccordions = collapsedSideNavPreference ?? []; + const { mutateAsync: updatePreferences } = useMutatePreferences(); const productFamilyLinkGroups: ProductFamilyLinkGroup< @@ -253,10 +258,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { ] ); - const [collapsedAccordions, setCollapsedAccordions] = React.useState< - number[] - >(preferences?.collapsedSideNavProductFamilies ?? []); - const accordionClicked = (index: number) => { let updatedCollapsedAccordions; if (collapsedAccordions.includes(index)) { @@ -266,13 +267,11 @@ export const PrimaryNav = (props: PrimaryNavProps) => { updatePreferences({ collapsedSideNavProductFamilies: updatedCollapsedAccordions, }); - setCollapsedAccordions(updatedCollapsedAccordions); } else { updatedCollapsedAccordions = [...collapsedAccordions, index]; updatePreferences({ collapsedSideNavProductFamilies: updatedCollapsedAccordions, }); - setCollapsedAccordions(updatedCollapsedAccordions); } }; diff --git a/packages/manager/src/components/PromotionalOfferCard/PromotionalOfferCard.tsx b/packages/manager/src/components/PromotionalOfferCard/PromotionalOfferCard.tsx index 1bbb41870a3..27ce8c76b2c 100644 --- a/packages/manager/src/components/PromotionalOfferCard/PromotionalOfferCard.tsx +++ b/packages/manager/src/components/PromotionalOfferCard/PromotionalOfferCard.tsx @@ -17,10 +17,10 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, button: { '&:hover, &:focus': { - backgroundColor: '#3f8a4e', + backgroundColor: theme.tokens.color.Green[70], color: theme.tokens.color.Neutrals.White, }, - backgroundColor: '#4FAD62', + backgroundColor: theme.tokens.color.Green[60], color: theme.tokens.color.Neutrals.White, marginLeft: theme.spacing(2), marginRight: theme.spacing(2), @@ -29,13 +29,13 @@ const useStyles = makeStyles()((theme: Theme) => ({ buttonSecondary: { '&:hover, &:focus': { backgroundColor: 'inherit', - borderColor: '#72BD81', - color: '#72BD81', + borderColor: theme.tokens.color.Green[50], + color: theme.tokens.color.Green[50], }, backgroundColor: 'inherit', border: '1px solid transparent', - borderColor: '#4FAD62', - color: '#4FAD62', + borderColor: theme.tokens.color.Green[60], + color: theme.tokens.color.Green[60], transition: theme.transitions.create(['color', 'border-color']), }, buttonSection: { diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts index 1f274abd608..ee1cd833005 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts @@ -48,9 +48,9 @@ export const StyledChip = styled(Chip)(({ theme }) => ({ '& .MuiChip-deleteIcon.MuiSvgIcon-root': { '&:hover': { backgroundColor: theme.tokens.color.Neutrals.White, - color: '#3683dc', + color: theme.tokens.color.Ultramarine[70], }, - backgroundColor: '#3683dc', + backgroundColor: theme.tokens.color.Ultramarine[70], color: theme.tokens.color.Neutrals.White, }, })); diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx index ae81fb10c9f..33beaff933a 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx @@ -63,10 +63,7 @@ export const SelectFirewallPanel = (props: Props) => { : null; return ( - ({ marginTop: theme.spacing(3) })} - > + ({ marginBottom: theme.spacing(2) })} variant="h2" diff --git a/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx b/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx index b677cba9286..a2e273543d3 100644 --- a/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx +++ b/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx @@ -11,11 +11,15 @@ interface RegionHelperTextProps extends BoxProps { } export const RegionHelperText = (props: RegionHelperTextProps) => { - const { onClick, showCoreHelperText, ...rest } = props; + const { onClick, showCoreHelperText, sx, ...rest } = props; return ( - - + + {showCoreHelperText && `Data centers in central locations support a robust set of cloud computing services. `} You can use diff --git a/packages/manager/src/components/SelectionCard/CardBase.styles.ts b/packages/manager/src/components/SelectionCard/CardBase.styles.ts index 8c06a76beee..4e7f6ebe07a 100644 --- a/packages/manager/src/components/SelectionCard/CardBase.styles.ts +++ b/packages/manager/src/components/SelectionCard/CardBase.styles.ts @@ -51,13 +51,13 @@ export const CardBaseGrid = styled(Grid, { export const CardBaseIcon = styled(Grid, { label: 'CardBaseIcon', -})(() => ({ +})(({ theme }) => ({ '& img': { maxHeight: 32, maxWidth: 32, }, '& svg, & span': { - color: '#939598', + color: theme.tokens.color.Neutrals[50], fontSize: 32, }, alignItems: 'flex-end', diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.tsx index 4991bf1e32b..511119cb740 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.tsx @@ -191,12 +191,12 @@ export const SelectionCard = React.memo((props: SelectionCardProps) => { const StyledGrid = styled(Grid, { label: 'SelectionCardGrid', -})>(({ ...props }) => ({ +})>(({ theme, ...props }) => ({ '& [class^="fl-"]': { transition: 'color 225ms ease-in-out', }, '&:focus': { - outline: '1px dotted #999', + outline: `1px dotted ${theme.tokens.color.Neutrals[50]}`, }, ...(props.onClick && !props.disabled && { diff --git a/packages/manager/src/components/ShowMore/ShowMore.tsx b/packages/manager/src/components/ShowMore/ShowMore.tsx index d9a8640d420..1e3f11e2c91 100644 --- a/packages/manager/src/components/ShowMore/ShowMore.tsx +++ b/packages/manager/src/components/ShowMore/ShowMore.tsx @@ -69,7 +69,7 @@ const StyledChip = styled(Chip)(({ theme }) => ({ }, '&:focus': { backgroundColor: theme.bg.lightBlue1, - outline: '1px dotted #999', + outline: `1px dotted ${theme.tokens.color.Neutrals[50]}`, }, '&:hover': { backgroundColor: theme.palette.primary.main, diff --git a/packages/manager/src/components/TanstackLink.stories.tsx b/packages/manager/src/components/TanstackLink.stories.tsx new file mode 100644 index 00000000000..badc132c03f --- /dev/null +++ b/packages/manager/src/components/TanstackLink.stories.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { TanstackLink } from './TanstackLinks'; + +import type { TanstackLinkComponentProps } from './TanstackLinks'; +import type { Meta, StoryObj } from '@storybook/react'; + +export const AsButtonPrimary: StoryObj = { + render: () => ( + + Home + + ), +}; + +export const AsButtonSecondary: StoryObj = { + render: () => ( + + Home + + ), +}; + +export const AsLink: StoryObj = { + render: () => ( + + Home + + ), +}; + +const meta: Meta = { + parameters: { + tanStackRouter: true, + }, + title: 'Foundations/Link/Tanstack Link', +}; +export default meta; diff --git a/packages/manager/src/components/TanstackLinks.tsx b/packages/manager/src/components/TanstackLinks.tsx new file mode 100644 index 00000000000..bea4ddd332d --- /dev/null +++ b/packages/manager/src/components/TanstackLinks.tsx @@ -0,0 +1,83 @@ +import { Button } from '@linode/ui'; +import { omitProps } from '@linode/ui'; +import { LinkComponent } from '@tanstack/react-router'; +import { createLink } from '@tanstack/react-router'; +import * as React from 'react'; + +import { MenuItem } from 'src/components/MenuItem'; + +import type { ButtonProps, ButtonType } from '@linode/ui'; +import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router'; + +export interface TanstackLinkComponentProps + extends Omit { + linkType: 'link' | ButtonType; + tooltipAnalyticsEvent?: (() => void) | undefined; + tooltipText?: string; +} + +export interface TanStackLinkRoutingProps { + linkType: TanstackLinkComponentProps['linkType']; + params?: TanStackLinkProps['params']; + preload?: TanStackLinkProps['preload']; + search?: TanStackLinkProps['search']; + to: TanStackLinkProps['to']; +} + +const LinkComponent = React.forwardRef< + HTMLButtonElement, + TanstackLinkComponentProps +>((props, ref) => { + const _props = omitProps(props, ['linkType']); + + return - -
-
- -
+ Remove all seeds + +
+
+
+
-
-
Presets
-
-
- -
+
+
+
Presets
+
+
+
- +
+
+ )} +
+ {isEnabled && isEditingCustomAccount && ( + setIsEditingCustomAccount(false)} + open={isEditingCustomAccount} + title="Edit Custom Account" + > +
{ + e.preventDefault(); + setIsEditingCustomAccount(false); + }} + className="dev-tools__modal-form" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ )} + + ); +}; + +const FieldWrapper = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx index 9eedf8d709b..60dc6c8a22c 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx @@ -13,7 +13,6 @@ export const ExtraPresetOptionCheckbox = ( props: ExtraPresetOptionCheckboxProps ) => { const { - disabled, group, handlers, onPresetCountChange, @@ -32,18 +31,14 @@ export const ExtraPresetOptionCheckbox = ( style={{ display: 'flex', justifyContent: 'space-between' }} >
- onTogglePreset(e, extraMockPreset.id)} - type="checkbox" - /> - + +
{extraMockPreset.canUpdateCount && (
{ - const { disabled, group, handlers, onSelectChange } = props; + const { group, handlers, onSelectChange } = props; return (
@@ -28,7 +28,6 @@ export const ExtraPresetOptionSelect = ( ) || '' } className="dev-tools__select thin" - disabled={disabled} onChange={(e) => onSelectChange(e, group)} style={{ width: 125 }} > diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx index 6a56f0f650c..333fba7f4e5 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx @@ -3,12 +3,19 @@ import * as React from 'react'; import { getMockPresetGroups } from 'src/mocks/mockPreset'; import { extraMockPresets } from 'src/mocks/presets'; +import { ExtraPresetAccount } from './ExtraPresetAccount'; import { ExtraPresetOptionCheckbox } from './ExtraPresetOptionCheckbox'; import { ExtraPresetOptionSelect } from './ExtraPresetOptionSelect'; +import { ExtraPresetProfile } from './ExtraPresetProfile'; + +import type { Account, Profile } from '@linode/api-v4'; export interface ExtraPresetOptionsProps { - disabled: boolean; + customAccountData?: Account | null; + customProfileData?: Profile | null; handlers: string[]; + onCustomAccountChange?: (data: Account | null | undefined) => void; + onCustomProfileChange?: (data: Profile | null | undefined) => void; onPresetCountChange: (e: React.ChangeEvent, presetId: string) => void; onSelectChange: (e: React.ChangeEvent, presetId: string) => void; onTogglePreset: (e: React.ChangeEvent, presetId: string) => void; @@ -19,8 +26,11 @@ export interface ExtraPresetOptionsProps { * Renders a list of extra presets with an optional count. */ export const ExtraPresetOptions = ({ - disabled, + customAccountData, + customProfileData, handlers, + onCustomAccountChange, + onCustomProfileChange, onPresetCountChange, onSelectChange, onTogglePreset, @@ -46,7 +56,6 @@ export const ExtraPresetOptions = ({ {group}{' '} {currentGroupType === 'select' && ( {currentGroupType === 'checkbox' && ( )} + {currentGroupType === 'account' && ( + + )} + {currentGroupType === 'profile' && ( + + )}
); })} diff --git a/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx b/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx new file mode 100644 index 00000000000..6701c5f3d3e --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx @@ -0,0 +1,310 @@ +import * as React from 'react'; + +import { Dialog } from 'src/components/Dialog/Dialog'; +import { profileFactory } from 'src/factories'; +import { extraMockPresets } from 'src/mocks/presets'; +import { setCustomProfileData } from 'src/mocks/presets/extra/account/customProfile'; + +import { saveCustomProfileData } from '../utils'; +import { JsonTextArea } from './JsonTextArea'; + +import type { Profile } from '@linode/api-v4'; + +const profilePreset = extraMockPresets.find((p) => p.id === 'profile:custom'); + +interface ExtraPresetProfileProps { + customProfileData: Profile | null | undefined; + handlers: string[]; + onFormChange?: (data: Profile | null | undefined) => void; + onTogglePreset: ( + e: React.ChangeEvent, + presetId: string + ) => void; +} + +export const ExtraPresetProfile = ({ + customProfileData, + handlers, + onFormChange, + onTogglePreset, +}: ExtraPresetProfileProps) => { + const isEnabled = handlers.includes('profile:custom'); + const [formData, setFormData] = React.useState(() => ({ + ...profileFactory.build({ + restricted: false, + }), + ...customProfileData, + })); + const [isEditingCustomProfile, setIsEditingCustomProfile] = React.useState( + false + ); + + const handleInputChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => { + // radios + const { name, value } = e.target; + const isRadioToggleField = [ + 'email_notifications', + 'restricted', + 'two_factor_auth', + ].includes(name); + + const newValue = isRadioToggleField ? value === 'true' : value; + const newFormData = { + ...formData, + [name]: newValue, + }; + + setFormData(newFormData); + + if (isEnabled) { + onFormChange?.(newFormData); + } + }; + + const handleTogglePreset = (e: React.ChangeEvent) => { + if (!e.target.checked) { + saveCustomProfileData(null); + } else { + saveCustomProfileData(formData); + } + onTogglePreset(e, 'profile:custom'); + }; + + React.useEffect(() => { + if (!isEnabled) { + setFormData({ + ...profileFactory.build(), + }); + setCustomProfileData(null); + } else if (isEnabled && customProfileData) { + setFormData((prev) => ({ + ...prev, + ...customProfileData, + })); + setCustomProfileData(customProfileData); + } + }, [isEnabled, customProfileData]); + + if (!profilePreset) { + return null; + } + + return ( +
  • +
    +
    + +
    + {isEnabled && ( +
    + +
    + )} +
    + {isEnabled && isEditingCustomProfile && ( + setIsEditingCustomProfile(false)} + open={isEditingCustomProfile} + title="Edit Custom Profile" + > +
    setIsEditingCustomProfile(false)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + )} +
  • + ); +}; + +const FieldWrapper = ({ children }: { children: React.ReactNode }) => { + return
    {children}
    ; +}; diff --git a/packages/manager/src/dev-tools/components/JsonTextArea.tsx b/packages/manager/src/dev-tools/components/JsonTextArea.tsx new file mode 100644 index 00000000000..dbccbe44fce --- /dev/null +++ b/packages/manager/src/dev-tools/components/JsonTextArea.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; + +interface JsonTextAreaProps { + height?: number; + label?: string; + name: string; + onChange: ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => void; + value: unknown; +} + +export const JsonTextArea = ({ + height, + label, + name, + onChange, + value, +}: JsonTextAreaProps) => { + const [rawText, setRawText] = React.useState(JSON.stringify(value, null, 2)); + + const debouncedUpdate = React.useMemo( + () => + debounce((text: string) => { + try { + const parsedJson = JSON.parse(text); + const event = { + currentTarget: { + name, + value: parsedJson, + }, + target: { + name, + value: parsedJson, + }, + } as React.ChangeEvent; + + onChange(event); + } catch (err) { + // Only warn if the text isn't empty and isn't in the middle of editing + if (text.trim()) { + // eslint-disable-next-line no-console + console.warn(`Invalid JSON in ${name}, error: ${err}`); + } + } + }, 500), + [name, onChange] + ); + + React.useEffect(() => { + const newText = JSON.stringify(value, null, 2); + if (newText !== rawText) { + setRawText(newText); + } + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newText = e.target.value; + setRawText(newText); + debouncedUpdate(newText); + }; + + return ( +