diff --git a/docker-compose.yml b/docker-compose.yml index f37da3eaccd..5376d075f2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,11 +38,13 @@ x-e2e-env: CY_TEST_TAGS: ${CY_TEST_TAGS} CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} CY_TEST_RESET_PREFERENCES: ${CY_TEST_RESET_PREFERENCES} + CY_TEST_RESOURCE_PREFIX: ${CY_TEST_RESOURCE_PREFIX} # Cypress environment variables for alternative parallelization. CY_TEST_SPLIT_RUN: ${CY_TEST_SPLIT_RUN} CY_TEST_SPLIT_RUN_TOTAL: ${CY_TEST_SPLIT_RUN_TOTAL} CY_TEST_SPLIT_RUN_INDEX: ${CY_TEST_SPLIT_RUN_INDEX} + CY_TEST_SPLIT_RUN_WEIGHTS: ${CY_TEST_SPLIT_RUN_WEIGHTS} # Cypress performance. CY_TEST_ACCOUNT_CACHE_DIR: ${CY_TEST_ACCOUNT_CACHE_DIR} @@ -51,6 +53,7 @@ x-e2e-env: CY_TEST_JUNIT_REPORT: ${CY_TEST_JUNIT_REPORT} CY_TEST_HTML_REPORT: ${CY_TEST_HTML_REPORT} CY_TEST_USER_REPORT: ${CY_TEST_USER_REPORT} + CY_TEST_GENWEIGHTS: ${CY_TEST_GENWEIGHTS} # Cloud Manager build environment. HOME: /home/node diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index bc1b61f6703..7ce5c1d5d85 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,26 @@ +## [2025-09-23] - v0.149.0 + +### Added: + +- Images IAM RBAC types ([#12782](https://github.com/linode/manager/pull/12782)) +- LKE-E Phase 2 account capabilities (Kubernetes Enterprise BYO VPC, Kubernetes Enterprise Dual Stack) ([#12826](https://github.com/linode/manager/pull/12826)) +- VPC Dual Stack capability for regions ([#12826](https://github.com/linode/manager/pull/12826)) +- ACLP: `group_by` property in Dashboard interface ([#12843](https://github.com/linode/manager/pull/12843)) + +### Changed: + +- Update `UpdateVolumeRequest` to reflect optional `label` ([#12800](https://github.com/linode/manager/pull/12800)) +- Update `DiskDevice` and `VolumeDevice` to more closely align with the API's behavior ([#12809](https://github.com/linode/manager/pull/12809)) +- GET and PUT /account to /v4beta endpoint ([#12826](https://github.com/linode/manager/pull/12826)) + +### Upcoming Features: + +- Update LinodeInterfaceHistory type as per API type changes ([#12321](https://github.com/linode/manager/pull/12321)) +- Add audit_logs_enabled property to KubernetesCluster's ControlPlaneOptions interface ([#12696](https://github.com/linode/manager/pull/12696)) +- Make `address` an optional property on the IPv6SLAAC object ([#12778](https://github.com/linode/manager/pull/12778)) +- POST v4beta/monitor/streams/destinations/verify API endpoint for Destinations ([#12823](https://github.com/linode/manager/pull/12823)) +- Rename DataStream to Delivery ([#12852](https://github.com/linode/manager/pull/12852)) + ## [2025-09-09] - v0.148.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index fe6accebbe4..2f80884cfe8 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.148.0", + "version": "0.149.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -41,7 +41,7 @@ "unpkg": "./lib/index.global.js", "dependencies": { "@linode/validation": "workspace:*", - "axios": "~1.8.3", + "axios": "~1.12.0", "ipaddr.js": "^2.0.0", "yup": "^1.4.0" }, @@ -70,4 +70,4 @@ "tsc -p tsconfig.json --noEmit true --emitDeclarationOnly false" ] } -} \ No newline at end of file +} diff --git a/packages/api-v4/src/account/account.ts b/packages/api-v4/src/account/account.ts index b967d70595e..6a03633389d 100644 --- a/packages/api-v4/src/account/account.ts +++ b/packages/api-v4/src/account/account.ts @@ -34,7 +34,7 @@ import type { * */ export const getAccountInfo = () => { - return Request(setURL(`${API_ROOT}/account`), setMethod('GET')); + return Request(setURL(`${BETA_API_ROOT}/account`), setMethod('GET')); }; /** @@ -57,7 +57,7 @@ export const getNetworkUtilization = () => */ export const updateAccountInfo = (data: Partial) => Request( - setURL(`${API_ROOT}/account`), + setURL(`${BETA_API_ROOT}/account`), setMethod('PUT'), setData(data, updateAccountSchema), ); diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 31b1c186491..866067308b2 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -69,6 +69,8 @@ export const accountCapabilities = [ 'Disk Encryption', 'Kubernetes', 'Kubernetes Enterprise', + 'Kubernetes Enterprise BYO VPC', + 'Kubernetes Enterprise Dual Stack', 'Linodes', 'Linode Interfaces', 'LKE HA Control Planes', diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 72b3d72d9db..8e2974fb5a2 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -39,6 +39,7 @@ type AlertNotificationPagerDuty = 'pagerduty'; type AlertNotificationWebHook = 'webhook'; export interface Dashboard { created: string; + group_by?: string[]; id: number; label: string; service_type: CloudPulseServiceType; diff --git a/packages/api-v4/src/datastream/destinations.ts b/packages/api-v4/src/delivery/destinations.ts similarity index 86% rename from packages/api-v4/src/datastream/destinations.ts rename to packages/api-v4/src/delivery/destinations.ts index c3c8b00a231..7069a23288b 100644 --- a/packages/api-v4/src/datastream/destinations.ts +++ b/packages/api-v4/src/delivery/destinations.ts @@ -84,3 +84,15 @@ export const deleteDestination = (destinationId: number) => ), setMethod('DELETE'), ); + +/** + * Verifies if a provided Destination is valid. + * + * @param data { object } Data for type, label, etc. + */ +export const verifyDestination = (data: CreateDestinationPayload) => + Request( + setData(data, destinationSchema), + setURL(`${BETA_API_ROOT}/monitor/streams/destinations/verify`), + setMethod('POST'), + ); diff --git a/packages/api-v4/src/datastream/index.ts b/packages/api-v4/src/delivery/index.ts similarity index 100% rename from packages/api-v4/src/datastream/index.ts rename to packages/api-v4/src/delivery/index.ts diff --git a/packages/api-v4/src/datastream/streams.ts b/packages/api-v4/src/delivery/streams.ts similarity index 100% rename from packages/api-v4/src/datastream/streams.ts rename to packages/api-v4/src/delivery/streams.ts diff --git a/packages/api-v4/src/datastream/types.ts b/packages/api-v4/src/delivery/types.ts similarity index 100% rename from packages/api-v4/src/datastream/types.ts rename to packages/api-v4/src/delivery/types.ts diff --git a/packages/api-v4/src/iam/iam.ts b/packages/api-v4/src/iam/iam.ts index a2a34709f53..4bd4e889723 100644 --- a/packages/api-v4/src/iam/iam.ts +++ b/packages/api-v4/src/iam/iam.ts @@ -90,7 +90,7 @@ export const getUserAccountPermissions = (username: string) => export const getUserEntityPermissions = ( username: string, entityType: AccessType, - entityId: number, + entityId: number | string, ) => Request( setURL( diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index fdf1a6bba56..4da2b8a2fd1 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -101,10 +101,12 @@ export type AccountAdmin = | 'view_user_preferences' | AccountBillingAdmin | AccountFirewallAdmin + | AccountImageAdmin | AccountLinodeAdmin | AccountNodeBalancerAdmin | AccountOauthClientAdmin - | AccountVolumeAdmin; + | AccountVolumeAdmin + | AccountVPCAdmin; /** Permissions associated with the "account_billing_admin" role. */ export type AccountBillingAdmin = @@ -137,6 +139,12 @@ export type AccountFirewallAdmin = AccountFirewallCreator | FirewallAdmin; /** Permissions associated with the "account_firewall_creator" role. */ export type AccountFirewallCreator = 'create_firewall'; +/** Permissions associated with the "account_vpc_admin" role. */ +export type AccountVPCAdmin = AccountVPCCreator | VPCAdmin; + +/** Permissions associated with the "account_vpc_creator" role. */ +export type AccountVPCCreator = 'create_vpc'; + /** Permissions associated with the "account_linode_admin" role. */ export type AccountLinodeAdmin = AccountLinodeCreator | LinodeAdmin; @@ -157,6 +165,15 @@ export type AccountVolumeAdmin = AccountVolumeCreator | VolumeAdmin; /** Permissions associated with the "account_volume_creator" role. */ export type AccountVolumeCreator = 'create_volume'; +/** Permissions associated with the "account_image_admin" role. */ +export type AccountImageAdmin = AccountImageCreator | ImageAdmin; + +/** Permissions associated with the "account_image_creator" role. */ +export type AccountImageCreator = + | 'create_image' + | 'list_images' + | 'upload_image'; + /** Permissions associated with the "account_maintenance_viewer" role. */ export type AccountMaintenanceViewer = 'list_maintenances'; @@ -248,6 +265,22 @@ export type FirewallViewer = | 'view_firewall_device' | 'view_firewall_rule_version'; +/** Permissions associated with the "vpc_admin" role. */ +export type VPCAdmin = 'delete_vpc' | 'delete_vpc_subnet' | VPCContributor; + +/** Permissions associated with the "vpc_contributor role. */ +export type VPCContributor = + | 'create_vpc_subnet' + | 'update_vpc' + | 'update_vpc_subnet' + | VPCViewer; + +/** Permissions associated with the "vpc_viewer" role. */ +export type VPCViewer = + | 'list_vpc_ip_addresses' + | 'view_vpc' + | 'view_vpc_subnet'; + /** Permissions associated with the "linode_admin" role. */ export type LinodeAdmin = | 'cancel_linode_backups' @@ -349,6 +382,18 @@ export type VolumeContributor = /** Permissions associated with the "volume_viewer" role. */ export type VolumeViewer = 'view_volume'; +export type ImageAdmin = + | 'delete_image' + | 'replicate_image' + | ImageContributor + | ImageViewer; + +/** Permissions associated with the "image_contributor" role. */ +export type ImageContributor = 'update_image' | ImageViewer; + +/** Permissions associated with the "image_viewer" role. */ +export type ImageViewer = 'view_image'; + /** Union of all permissions */ export type PermissionType = AccountAdmin; diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 11bc7b95baf..00e09bce480 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -8,7 +8,7 @@ export * from './cloudpulse'; export * from './databases'; -export * from './datastream'; +export * from './delivery'; export * from './domains'; diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index f0181314853..e3fc6b12887 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -191,6 +191,10 @@ export interface ControlPlaneACLOptions { export interface ControlPlaneOptions { acl?: ControlPlaneACLOptions; + /** + * Upcoming Feature, Beta - Delivery logs + */ + audit_logs_enabled?: boolean; high_availability?: boolean; } diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 114a7dce89d..db79101e5bc 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -191,7 +191,7 @@ export interface ConfigInterfaceIPv4 { } export interface IPv6SLAAC { - address: string; + address?: string; range: string; } @@ -342,16 +342,35 @@ export interface PublicInterfaceData { } // Other Linode Interface types -export type LinodeInterfaceStatus = 'active' | 'deleted' | 'inactive'; export interface LinodeInterfaceHistory { + /** + * When this version was created. + * + * @example 2025-09-16T15:01:32 + */ created: string; - event_id: number; - interface_data: string; // will come in as JSON string object that we'll need to parse + /** + * The JSON body returned in response to a successful PUT, POST, or DELETE operation on the interface. + */ + interface_data: LinodeInterface; + /** + * The unique ID for this history version. + */ interface_history_id: number; + /** + * The network interface defined in the version. + */ interface_id: number; + /** + * The Linode the interface_id belongs to. + */ linode_id: number; - status: LinodeInterfaceStatus; + /** + * The network interface's version. + * + * The first version from a POST is 1. The version number is incremented when the network interface configuration is changed. + */ version: number; } @@ -382,10 +401,12 @@ export interface UpgradeInterfaceData { // ---------------------------------------------------------- export interface DiskDevice { - disk_id: null | number; + disk_id: number; + volume_id: null; } export interface VolumeDevice { - volume_id: null | number; + disk_id: null; + volume_id: number; } export type ConfigDevice = DiskDevice | null | VolumeDevice; diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 762ac3a8934..63f1fe5b4f6 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -25,6 +25,7 @@ export type Capabilities = | 'Premium Plans' | 'StackScripts' | 'Vlans' + | 'VPC Dual Stack' | 'VPCs'; export interface MonitoringCapabilities { diff --git a/packages/api-v4/src/volumes/volumes.ts b/packages/api-v4/src/volumes/volumes.ts index 68b4f768bdb..6aac6b7b2be 100644 --- a/packages/api-v4/src/volumes/volumes.ts +++ b/packages/api-v4/src/volumes/volumes.ts @@ -152,7 +152,7 @@ export const resizeVolume = (volumeId: number, data: ResizeVolumePayload) => ); export interface UpdateVolumeRequest { - label: string; + label?: string; tags?: string[]; } diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index da6ce3d28fa..1104346dcc0 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,87 @@ 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-09-23] - v1.151.0 + +### Added: + +- Ability to edit volume label on the Volume Details page ([#12820](https://github.com/linode/manager/pull/12820)) +- Plan selection table grouping for the new NVIDIA Blackwell plans ([#12821](https://github.com/linode/manager/pull/12821)) +- Action links to the Volume Details page ([#12822](https://github.com/linode/manager/pull/12822)) +- IAM RBAC: Implement IAM RBAC permissions for NodeBalancer configuration tab and create flow ([#12834](https://github.com/linode/manager/pull/12834)) +- IAM RBAC: Delay permission check on Linodes ([#12835](https://github.com/linode/manager/pull/12835)) +- IAM RBAC: Implement IAM RBAC permissions for NodeBalancer settings tab ([#12871](https://github.com/linode/manager/pull/12871)) + +### Changed: + +- Implement IAM RBAC Images permission model ([#12782](https://github.com/linode/manager/pull/12782)) +- IAM - Improve visual support for AssignedEntities chips with long labels ([#12801](https://github.com/linode/manager/pull/12801)) +- Improve UX for LKE-E BYO and IP Stack features based on customer availability ([#12826](https://github.com/linode/manager/pull/12826)) +- Improve role selection UX in assign change role drawers ([#12836](https://github.com/linode/manager/pull/12836)) +- DBaaS Backups time selector to support hours, minutes, seconds, and manual input using the Linode Time Picker ([#12868](https://github.com/linode/manager/pull/12868)) +- Update pricing copy in the Image Replicas Drawer because billing is postponed ([#12874](https://github.com/linode/manager/pull/12874)) +- IAM RBAC permissions for GoTo menu ([#12882](https://github.com/linode/manager/pull/12882)) + +### Fixed: + +- Footer height not included in useIsPageScrollable calculation ([#12695](https://github.com/linode/manager/pull/12695)) +- Maintenance banner links from showing when already on /maintenance route ([#12763](https://github.com/linode/manager/pull/12763)) +- IAM - Username & email consolidation between create and edit flows - ASCII & chars limit validation + improved messages ([#12788](https://github.com/linode/manager/pull/12788)) +- Inconsistencies across the Create, Edit, Show-details ACLP-Alerting pages ([#12803](https://github.com/linode/manager/pull/12803)) +- Search bar performing extra encoding when an option is selected ([#12808](https://github.com/linode/manager/pull/12808)) +- Linode configurations not updating after Volume is attached ([#12809](https://github.com/linode/manager/pull/12809)) +- Linode configuration row incorrectly representing volume devices ([#12809](https://github.com/linode/manager/pull/12809)) +- Misleading 'Receive a transfer' tooltip message when user lacks permission ([#12813](https://github.com/linode/manager/pull/12813)) +- ACLP: `CloudPulseWidget` to showing `ps` instead of `/s` for units ([#12872](https://github.com/linode/manager/pull/12872)) +- Inconsistent section spacing throughout LKE create form ([#12873](https://github.com/linode/manager/pull/12873)) +- Edge case allowing a node pool with 0 nodes to be configured in Node Pool Config drawer ([#12873](https://github.com/linode/manager/pull/12873)) +- DBaaS Create and Manage Networking validation allows submitting VPC configuration with empty subnet field ([#12889](https://github.com/linode/manager/pull/12889)) +- DBaaS Manage Networking drawer state does not reset after opening Unassign VPC dialog, cancelling, and reopening the drawer ([#12889](https://github.com/linode/manager/pull/12889)) + +### Removed: + +- Legacy date pickers from codebase ([#12784](https://github.com/linode/manager/pull/12784)) +- Update strategy from the Node Pool summary item in LKE checkout bar ([#12814](https://github.com/linode/manager/pull/12814)) +- Redundant Firewall label from Node Pool Config drawer ([#12873](https://github.com/linode/manager/pull/12873)) + +### Tech Stories: + +- Pass "CY_TEST_RESOURCE_PREFIX", "CY_TEST_GENWEIGHTS", and "CY_TEST_SPLIT_RUN_WEIGHTS" environment variables to Cypress containers ([#12766](https://github.com/linode/manager/pull/12766)) +- Replace `lkeEnterprise` feature flag with `lkeEnterprise2` flag ([#12826](https://github.com/linode/manager/pull/12826)) +- Ignore window.crypto.randomUUID error in Sentry config file ([#12818](https://github.com/linode/manager/pull/12818)) + +### Tests: + +- Add tests for Host & VM Maintenance banner presence ([#12753](https://github.com/linode/manager/pull/12753)) +- Temporarily skip Linode landing page power on/off and reboot tests ([#12766](https://github.com/linode/manager/pull/12766)) +- Fix issue in Object Storage bucket clean up resulting in 404 API errors ([#12816](https://github.com/linode/manager/pull/12816)) +- CloudPulse-Alerts: Add and update tests for scope filter changes in alert creation and edit flow([#12819](https://github.com/linode/manager/pull/12819)) +- Add Cypress tests for LKE-E post-LA create flows ([#12831](https://github.com/linode/manager/pull/12831)) +- Cypress tests for Premium Plans and Horizontal Resizing ([#12854](https://github.com/linode/manager/pull/12854)) +- Cypress test flake in "events-fetching.spec.ts" ([#12875](https://github.com/linode/manager/pull/12875)) +- Update Cypress tests following LKE-E postLa feature flag enablement ([#12883](https://github.com/linode/manager/pull/12883)) +- Reflect 'Allow public IPv4 access (1:1 NAT)' copy change in E2E and unit tests related to Linode Create and networking flows ([#12885](https://github.com/linode/manager/pull/12885)) + +### Upcoming Features: + +- Add Linode Interface History table ([#12321](https://github.com/linode/manager/pull/12321)) +- Fetch Kubernetes Clusters from API in Create Stream form, add pagination to clusters table. Update empty state icons ([#12696](https://github.com/linode/manager/pull/12696)) +- IAM RBAC: VPC Landing Page permissions ([#12758](https://github.com/linode/manager/pull/12758)) +- Add VPC IPv6 support in Assign Linodes and Unassign Linodes drawers ([#12778](https://github.com/linode/manager/pull/12778)) +- Add unsaved changes modal for ACLP (beta) alerts ([#12799](https://github.com/linode/manager/pull/12799)) +- VPC Create page feedback for dual-stack ([#12807](https://github.com/linode/manager/pull/12807)) +- Add Streams and Destinations to search bar ([#12811](https://github.com/linode/manager/pull/12811)) +- Add handlers for creation and edit for stream and destination ([#12823](https://github.com/linode/manager/pull/12823)) +- ACLP: Add `CloudPulseGroupByDrawer` component ([#12843](https://github.com/linode/manager/pull/12843)) +- Feature flag New Chip and Beta Chip for Linode Interfaces ([#12846](https://github.com/linode/manager/pull/12846)) +- Rename DataStream to Delivery, change routing from /datastream to /logs/delivery ([#12852](https://github.com/linode/manager/pull/12852)) +- IAM RBAC: Implement IAM RBAC permissions for VPC Create page ([#12863](https://github.com/linode/manager/pull/12863)) +- ACLP: Add `GlobalFilterGroupByRendererComponent` and `WidgetFilterGroupByRendererComponent` ([#12865](https://github.com/linode/manager/pull/12865)) +- Account scope support for ACLP-Alerting firewall dimension filters ([#12879](https://github.com/linode/manager/pull/12879)) +- Ability to edit tags on volume details page ([#12800](https://github.com/linode/manager/pull/12800)) +- Add VPC IPv6 support in Linode Create flow ([#12885](https://github.com/linode/manager/pull/12885)) +- Add VPC IPv6 support in the Linode Details page -> Network tab ([#12856](https://github.com/linode/manager/pull/12856)) + ## [2025-09-11] - v1.150.1 ### Fixed: diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index 09712fbff0f..d77503cb1df 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -204,12 +204,8 @@ describe('restricted user details pages', () => { label: randomLabel(), type: 'automatic', }); - const actions = [ - 'Edit', - 'Deploy to New Linode', - 'Rebuild an Existing Linode', - 'Delete', - ]; + const disabledActions = ['Edit', 'Deploy to New Linode', 'Delete']; + const enabledActions = ['Rebuild an Existing Linode']; const actionsMap: { [id: string]: string } = { Delete: 'delete this Image', 'Deploy to New Linode': 'create Linodes', @@ -240,7 +236,10 @@ describe('restricted user details pages', () => { .should('be.visible') .should('be.enabled') .click(); - actions.forEach((menuItem: string) => { + enabledActions.forEach((menuItem: string) => { + ui.actionMenuItem.findByTitle(menuItem).should('not.be.disabled'); + }); + disabledActions.forEach((menuItem: string) => { const tooltipMessage = `You don't have permissions to ${actionsMap[menuItem]}. Please contact your ${ADMINISTRATOR} to request the necessary permissions.`; ui.actionMenuItem.findByTitle(menuItem).should('be.disabled'); @@ -249,6 +248,7 @@ describe('restricted user details pages', () => { .trigger('mouseover'); ui.tooltip.findByText(tooltipMessage); }); + cy.reload(); // Confirm that action menu items of each image are disabled in "Recovery Images" table @@ -257,7 +257,10 @@ describe('restricted user details pages', () => { .should('be.visible') .should('be.enabled') .click(); - actions.forEach((menuItem: string) => { + enabledActions.forEach((menuItem: string) => { + ui.actionMenuItem.findByTitle(menuItem).should('not.be.disabled'); + }); + disabledActions.forEach((menuItem: string) => { const tooltipMessage = `You don't have permissions to ${actionsMap[menuItem]}. Please contact your ${ADMINISTRATOR} to request the necessary permissions.`; ui.actionMenuItem.findByTitle(menuItem).should('be.disabled'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts index edae79f9816..44eea8d8348 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts @@ -30,9 +30,18 @@ import { databaseFactory, notificationChannelFactory, } from 'src/factories'; +import { + ACCOUNT_GROUP_INFO_MESSAGE, + entityGroupingOptions, + REGION_GROUP_INFO_MESSAGE, +} from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; -import type { Database } from '@linode/api-v4'; +import type { + AlertDefinitionDimensionFilter, + AlertDefinitionMetricCriteria, + Database, +} from '@linode/api-v4'; import type { Flags } from 'src/featureFlags'; const flags: Partial = { aclp: { beta: true, enabled: true } }; @@ -43,12 +52,14 @@ const regions = [ country: 'us', id: 'us-ord', label: 'Chicago, IL', + monitors: { alerts: ['Managed Databases'] }, }), regionFactory.build({ capabilities: ['Managed Databases'], country: 'us', id: 'us-east', label: 'Newark', + monitors: { alerts: ['Managed Databases'] }, }), ]; @@ -71,17 +82,7 @@ const alertDetails = alertFactory.build({ created: '2023-10-01T12:00:00Z', updated: new Date().toISOString(), }); -const { - created_by, - description, - id, - label, - rule_criteria, - service_type, - severity, - created, - updated, -} = alertDetails; +const { id, label, rule_criteria, service_type } = alertDetails; const { rules } = rule_criteria; const notificationChannels = notificationChannelFactory.build(); @@ -98,6 +99,83 @@ const verifyRowOrder = (expectedIds: string[]) => { const mockProfile = profileFactory.build({ timezone: 'gmt', }); + +/** + * Asserts that the given dimension filter's label, operator, and value + * are correctly displayed in the UI as visible chips. + * + * @param {AlertDefinitionDimensionFilter} filter - The dimension filter object containing + * the label (string), operator (string), and value (string) to validate. + */ +const assertDimensionFilter = (filter: AlertDefinitionDimensionFilter) => { + cy.get(`[data-qa-chip="${filter.label}"]`) + .should('be.visible') + .each(($chip) => { + expect($chip).to.have.text(filter.label); + }); + + cy.get(`[data-qa-chip="${dimensionOperatorTypeMap[filter.operator]}"]`) + .should('be.visible') + .each(($chip) => { + expect($chip).to.have.text(dimensionOperatorTypeMap[filter.operator]); + }); + + cy.get(`[data-qa-chip="${capitalize(filter.value)}"]`) + .should('be.visible') + .each(($chip) => { + expect($chip).to.have.text(capitalize(filter.value)); + }); +}; +/** + * Validates the UI display of an array of metric criteria rules. + * + * For each rule, it checks the Metric Threshold section and the Dimension Filters section, + * ensuring all expected chips are visible and have the correct text. + * + * @param {AlertDefinitionMetricCriteria[]} rules - Array of metric criteria objects, + * each containing properties such as aggregate_function, label, operator, threshold, + * unit, and optional dimension_filters to validate. + */ +const assertRuleBlock = (rules: AlertDefinitionMetricCriteria[]) => { + cy.get('[data-qa-section="Criteria"]').within(() => { + rules.forEach((rule, index) => { + // Validate Metric Threshold section + cy.get('[data-qa-item="Metric Threshold"]') + .eq(index) + .within(() => { + cy.get( + `[data-qa-chip="${aggregationTypeMap[rule.aggregate_function]}"]` + ) + .should('be.visible') + .should('have.text', aggregationTypeMap[rule.aggregate_function]); + + cy.get(`[data-qa-chip="${rule.label}"]`) + .should('be.visible') + .should('have.text', rule.label); + + cy.get(`[data-qa-chip="${metricOperatorTypeMap[rule.operator]}"]`) + .should('be.visible') + .should('have.text', metricOperatorTypeMap[rule.operator]); + + cy.get(`[data-qa-chip="${rule.threshold}"]`) + .should('be.visible') + .should('have.text', rule.threshold); + + cy.get(`[data-qa-chip="${rule.unit}"]`) + .should('be.visible') + .should('have.text', rule.unit); + }); + + // Validate Dimension Filters section + cy.get('[data-qa-item="Dimension Filter"]') + .eq(index) + .within(() => { + (rule.dimension_filters ?? []).forEach(assertDimensionFilter); + }); + }); + }); +}; + /** * Integration tests for the CloudPulse Alerts Detail Page, ensuring that the alert details, criteria, and entity information are correctly displayed and validated, including various fields like name, description, status, severity, and trigger conditions. */ @@ -170,136 +248,231 @@ describe('Integration Tests for Alert Show Detail Page', () => { cy.findByText('Failed').should('be.visible'); }); - it('should correctly display the details of the DBaaS alert in the alert details view', () => { - const searchPlaceholder = 'Search for a Region or Entity'; - cy.visitWithLogin(`/alerts/definitions/detail/${service_type}/${id}`); - cy.wait(['@getDBaaSAlertDefinitions', '@getMockedDbaasDatabases']); - - // Validating contents of Overview Section - cy.get('[data-qa-section="Overview"]').within(() => { - // Validate Name field - cy.findByText('Name:').should('be.visible'); - cy.findByText(label).should('be.visible'); - - // Validate Description field - cy.findByText('Description:').should('be.visible'); - cy.findByText(description).should('be.visible'); - - // Validate Status field - cy.findByText('Status:').should('be.visible'); - cy.findByText('Enabled').should('be.visible'); - - cy.findByText('Severity:').should('be.visible'); - cy.findByText(severityMap[severity]).should('be.visible'); - - // Validate Service field - cy.findByText('Service:').should('be.visible'); - cy.findByText('Databases').should('be.visible'); - - // Validate Type field - cy.findByText('Type:').should('be.visible'); - cy.findByText('User').should('be.visible'); - - // Validate Created By field - cy.findByText('Created By:').should('be.visible'); - cy.findByText(created_by).should('be.visible'); - - // Validate Last Modified field - cy.findByText('Last Modified:').should('be.visible'); - cy.findByText( - formatDate(updated, { - format: 'MMM dd, yyyy, h:mm a', - timezone: 'GMT', - }) - ).should('be.visible'); - cy.findByText( - formatDate(created, { - format: 'MMM dd, yyyy, h:mm a', - timezone: 'GMT', - }) - ).should('be.visible'); - }); + // Define actions to validate alert details based on the grouping scope (Region or Account) + const scopeActions: Record void> = { + // Region-level alert validations + Region: () => { + cy.get('[data-qa="region-tabls"]').within(() => { + const expectedRegions = [ + 'US, Chicago, IL (us-ord)', + 'US, Newark (us-east)', + ]; + + expectedRegions.forEach((region) => { + cy.contains('tr', region).should('exist'); + }); + }); - // Validating contents of Criteria Section - cy.get('[data-qa-section="Criteria"]').within(() => { - rules.forEach((rule, index) => { - cy.get('[data-qa-item="Metric Threshold"]') - .eq(index) - .within(() => { - cy.get( - `[data-qa-chip="${aggregationTypeMap[rule.aggregate_function]}"]` - ) - .should('be.visible') - .should('have.text', aggregationTypeMap[rule.aggregate_function]); - - cy.get(`[data-qa-chip="${rule.label}"]`) - .should('be.visible') - .should('have.text', rule.label); - - cy.get(`[data-qa-chip="${metricOperatorTypeMap[rule.operator]}"]`) - .should('be.visible') - .should('have.text', metricOperatorTypeMap[rule.operator]); - - cy.get(`[data-qa-chip="${rule.threshold}"]`) - .should('be.visible') - .should('have.text', rule.threshold); - - cy.get(`[data-qa-chip="${rule.unit}"]`) - .should('be.visible') - .should('have.text', rule.unit); - }); + cy.get('[data-qa-notice="true"]') + .find('[data-testid="alert_message_notice"]') + .should('have.text', REGION_GROUP_INFO_MESSAGE); + }, + // Account-level alert validations + Account: () => { + cy.get('[data-qa-notice="true"]') + .find('[data-testid="alert_message_notice"]') + .should('have.text', ACCOUNT_GROUP_INFO_MESSAGE); + }, + // Entity-level alert validations + Entity: () => { + const searchPlaceholder = 'Search for a Region or Entity'; + cy.get('[data-qa-section="Resources"]').within(() => { + // Validate headings + ui.heading + .findByText('entity') + .scrollIntoView() + .should('be.visible') + .should('have.text', 'Entity'); - // Validating contents of Dimension Filter - cy.get('[data-qa-item="Dimension Filter"]') - .eq(index) - .within(() => { - (rule.dimension_filters ?? []).forEach((filter) => { - // Validate the filter label - cy.get(`[data-qa-chip="${filter.label}"]`) - .should('be.visible') - .each(($chip) => { - expect($chip).to.have.text(filter.label); - }); - // Validate the filter operator - cy.get( - `[data-qa-chip="${dimensionOperatorTypeMap[filter.operator]}"]` - ) - .should('be.visible') - .each(($chip) => { - expect($chip).to.have.text( - dimensionOperatorTypeMap[filter.operator] - ); - }); - // Validate the filter value - cy.get(`[data-qa-chip="${capitalize(filter.value)}"]`) - .should('be.visible') - .each(($chip) => { - expect($chip).to.have.text(capitalize(filter.value)); - }); + ui.heading + .findByText('region') + .should('be.visible') + .should('have.text', 'Region'); + + // Validate search inputs + cy.findByPlaceholderText(searchPlaceholder).should('be.visible'); + cy.findByPlaceholderText('Select Regions').should('be.visible'); + + // Assert row count + cy.get('[data-qa-alert-row]').should('have.length', 4); + + // Validate entity-region mapping + const regionMap = new Map(regions.map((r) => [r.id, r.label])); + + cy.get('[data-qa-alert-row]') + .should('have.length', 4) + .each((row, index) => { + const db = databases[index]; + const rowNumber = index + 1; + const regionLabel = regionMap.get(db.region) || 'Unknown Region'; + + cy.wrap(row).within(() => { + cy.get(`[data-qa-alert-cell="${rowNumber}_entity"]`).should( + 'have.text', + db.label + ); + + cy.get(`[data-qa-alert-cell="${rowNumber}_region"]`).should( + 'have.text', + `US, ${regionLabel} (${db.region})` + ); }); }); - }); + // Sorting validations + ui.heading.findByText('entity').click(); + verifyRowOrder(['4', '3', '2', '1']); + + ui.heading.findByText('entity').click(); + verifyRowOrder(['1', '2', '3', '4']); + + ui.heading.findByText('region').click(); + verifyRowOrder(['2', '4', '1', '3']); + + ui.heading.findByText('region').click(); + verifyRowOrder(['1', '3', '2', '4']); + + // Entity search + cy.findByPlaceholderText(searchPlaceholder).type(databases[0].label); + + cy.get('[data-qa-alert-table="true"]') + .find('[data-qa-alert-row]') + .should('have.length', 1); + + cy.findByText(databases[0].label).should('be.visible'); + [1, 2, 3].forEach((i) => + cy.findByText(databases[i].label).should('not.exist') + ); + + // Region filter + cy.findByPlaceholderText(searchPlaceholder).clear(); + ui.regionSelect + .find() + .click() + .type(`${regions[0].label}{enter}`) + .click(); + + cy.get('[data-qa-alert-table="true"]') + .find('[data-qa-alert-row]') + .should('have.length', 2); + + [0, 2].forEach((i) => + cy.get(`[data-qa-alert-cell="${i}_region"]`).should('not.exist') + ); + + [1, 3].forEach((i) => + cy.get(`[data-qa-alert-cell="${i}_region"]`).should('be.visible') + ); + }); + }, + }; + + entityGroupingOptions.forEach(({ label: groupLabel, value }) => { + it(`should correctly display the details of the DBaaS alert in the alert details view for ${groupLabel} level`, () => { + const regionList = ['us-ord', 'us-east']; + const alertDetails = alertFactory.build({ + id: 2, + label: 'Alert-1', + entity_ids: databases.slice(0, 4).map((db) => db.id.toString()), + rule_criteria: { rules: alertRulesFactory.buildList(2) }, + service_type: 'dbaas', + severity: 1, + status: 'enabled', + type: 'user', + created_by: 'user1', + updated_by: 'user2', + created: '2023-10-01T12:00:00Z', + updated: new Date().toISOString(), + scope: value, + ...(value === 'region' ? { regions: regionList } : {}), + }); + const { + created_by, + description, + id, + label, + service_type, + severity, + created, + updated, + } = alertDetails; + mockGetAllAlertDefinitions([alertDetails]).as('getAlertDefinitionsList'); + mockGetAlertDefinitions(service_type, id, alertDetails).as( + 'getDBaaSAlertDefinitions' + ); + cy.visitWithLogin(`/alerts/definitions/detail/${service_type}/${id}`); + cy.wait(['@getDBaaSAlertDefinitions']); + + // Validating contents of Overview Section + cy.get('[data-qa-section="Overview"]').within(() => { + // Validate Name field + cy.findByText('Name:').should('be.visible'); + cy.findByText(label).should('be.visible'); + + // Validate Description field + cy.findByText('Description:').should('be.visible'); + cy.findByText(description).should('be.visible'); + + // Validate Status field + cy.findByText('Status:').should('be.visible'); + cy.findByText('Enabled').should('be.visible'); + + cy.findByText('Severity:').should('be.visible'); + cy.findByText(severityMap[severity]).should('be.visible'); + + // Validate Service field + cy.findByText('Service:').should('be.visible'); + cy.findByText('Databases').should('be.visible'); + + // Validate Type field + cy.findByText('Type:').should('be.visible'); + cy.findByText('User').should('be.visible'); + + // Validate Created By field + cy.findByText('Created By:').should('be.visible'); + cy.findByText(created_by).should('be.visible'); + + // Validate Last Modified field + cy.findByText('Last Modified:').should('be.visible'); + cy.findByText( + formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + timezone: 'GMT', + }) + ).should('be.visible'); + cy.findByText( + formatDate(created, { + format: 'MMM dd, yyyy, h:mm a', + timezone: 'GMT', + }) + ).should('be.visible'); + + cy.findByText('Scope:').should('be.visible'); + cy.findByText(groupLabel).should('be.visible'); + }); + // Validate the Criteria section by checking each metric rule's threshold details + // and all related dimension filters for correct visibility and text content. + assertRuleBlock(rules); // Validating contents of Polling Interval cy.get('[data-qa-item="Polling Interval"]') .find('[data-qa-chip]') .should('be.visible') - .should('have.text', '10 minutes'); + .should('have.text', '10 min'); // Validating contents of Evaluation Periods cy.get('[data-qa-item="Evaluation Period"]') .find('[data-qa-chip]') .should('be.visible') - .should('have.text', '5 minutes'); + .should('have.text', '5 min'); // Validating contents of Trigger Alert cy.get('[data-qa-chip="All"]') .should('be.visible') .should('have.text', 'All'); - cy.get('[data-qa-chip="5 minutes"]') + cy.get('[data-qa-chip="5 min"]') .should('be.visible') - .should('have.text', '5 minutes'); + .should('have.text', '5 min'); cy.get('[data-qa-item="criteria are met for"]') .should('be.visible') @@ -308,103 +481,19 @@ describe('Integration Tests for Alert Show Detail Page', () => { cy.get('[data-qa-item="consecutive occurrences"]') .should('be.visible') .should('have.text', 'consecutive occurrences.'); - }); - // Validate the entity section (Entity and Region columns) - cy.get('[data-qa-section="Resources"]').within(() => { - ui.heading - .findByText('entity') - .scrollIntoView() - .should('be.visible') - .should('have.text', 'Entity'); - - ui.heading - .findByText('region') - .should('be.visible') - .should('have.text', 'Region'); - - cy.findByPlaceholderText(searchPlaceholder).should('be.visible'); - - cy.findByPlaceholderText('Select Regions').should('be.visible'); - - cy.get('[data-qa-alert-row]').should('have.length', 4); - - // Validate entity-region mapping for each row in the table - - const regionMap = new Map(regions.map((r) => [r.id, r.label])); - - cy.get('[data-qa-alert-row]') - .should('have.length', 4) - .each((row, index) => { - const db = databases[index]; - const rowNumber = index + 1; - const regionLabel = regionMap.get(db.region) || 'Unknown Region'; - - cy.wrap(row).within(() => { - cy.get(`[data-qa-alert-cell="${rowNumber}_entity"]`).should( - 'have.text', - db.label - ); - - cy.get(`[data-qa-alert-cell="${rowNumber}_region"]`).should( - 'have.text', - `US, ${regionLabel} (${db.region})` - ); - }); - }); - - // Sorting by entity and Region columns - ui.heading.findByText('entity').should('be.visible').click(); - verifyRowOrder(['4', '3', '2', '1']); - - ui.heading.findByText('entity').should('be.visible').click(); - verifyRowOrder(['1', '2', '3', '4']); - - ui.heading.findByText('region').should('be.visible').click(); - verifyRowOrder(['2', '4', '1', '3']); - - ui.heading.findByText('region').should('be.visible').click(); - verifyRowOrder(['1', '3', '2', '4']); - - // Search by Entity - cy.findByPlaceholderText(searchPlaceholder) - .should('be.visible') - .type(databases[0].label); - - cy.get('[data-qa-alert-table="true"]') - .find('[data-qa-alert-row]') - .should('have.length', 1); - - cy.findByText(databases[0].label).should('be.visible'); - [1, 2, 3].forEach((i) => - cy.findByText(databases[i].label).should('not.exist') - ); - - // Search by region - cy.findByPlaceholderText(searchPlaceholder).clear(); - - ui.regionSelect.find().click().type(`${regions[0].label}{enter}`); - ui.regionSelect.find().click(); - - cy.get('[data-qa-alert-table="true"]') - .find('[data-qa-alert-row]') - .should('have.length', 2); - - [0, 2].forEach((i) => - cy.get(`[data-qa-alert-cell="${i}_region"]`).should('not.exist') - ); - [1, 3].forEach((i) => - cy.get(`[data-qa-alert-cell="${i}_region"]`).should('be.visible') - ); - }); - // Validate Notification Channels Section - cy.get('[data-qa-section="Notification Channels"]').within(() => { - cy.findByText('Type:').should('be.visible'); - cy.findByText('Email').should('be.visible'); - cy.findByText('Channel:').should('be.visible'); - cy.findByText('Channel-1').should('be.visible'); - cy.findByText('To:').should('be.visible'); - cy.findByText('test@test.com').should('be.visible'); - cy.findByText('test2@test.com').should('be.visible'); + // Execute the appropriate validation logic based on the alert's grouping label (e.g., 'Region' or 'Account') + + scopeActions[label]; + // Validate Notification Channels Section + cy.get('[data-qa-section="Notification Channels"]').within(() => { + cy.findByText('Type:').should('be.visible'); + cy.findByText('Email').should('be.visible'); + cy.findByText('Channel:').should('be.visible'); + cy.findByText('Channel-1').should('be.visible'); + cy.findByText('To:').should('be.visible'); + cy.findByText('test@test.com').should('be.visible'); + cy.findByText('test2@test.com').should('be.visible'); + }); }); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index f5cfa16fe10..69a5c06847d 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -1,7 +1,6 @@ /** * @fileoverview Cypress test suite for the "Create Alert" functionality. */ - import { profileFactory, regionFactory } from '@linode/utilities'; import { statusMap } from 'support/constants/alert'; import { widgetDetails } from 'support/constants/widgets'; @@ -11,6 +10,7 @@ import { mockGetAlertChannels, mockGetAllAlertDefinitions, mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServiceByType, mockGetCloudPulseServices, } from 'support/intercepts/cloudpulse'; import { mockGetDatabases } from 'support/intercepts/databases'; @@ -29,9 +29,12 @@ import { flagsFactory, memoryRulesFactory, notificationChannelFactory, + serviceAlertFactory, + serviceTypesFactory, triggerConditionFactory, } from 'src/factories'; import { CREATE_ALERT_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/constants'; +import { entityGroupingOptions } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; export interface MetricDetails { aggregationType: string; @@ -65,11 +68,14 @@ const mockRegions = [ ]; const { metrics } = widgetDetails.dbaas; const serviceType = 'dbaas'; -const databaseMock = databaseFactory.buildList(10, { - cluster_size: 3, - engine: 'mysql', - region: 'us-ord', -}); +const regionList = ['us-ord', 'us-east']; + +const databaseMock = regionList.map((region) => + databaseFactory.build({ + engine: 'mysql', + region, + }) +); const notificationChannels = notificationChannelFactory.build({ channel_type: 'email', @@ -102,25 +108,14 @@ const mockProfile = profileFactory.build({ timezone: 'gmt', }); const mockAlerts = alertFactory.build({ - alert_channels: [{ id: 1 }], - created_by: 'user1', - description: 'My Custom Description', - entity_ids: ['2'], label: 'Alert-1', - rule_criteria: { - rules: [cpuRulesFactory.build(), memoryRulesFactory.build()], - }, - service_type: 'dbaas', - severity: 0, - tags: [''], - trigger_conditions: triggerConditionFactory.build(), }); - +const CREATE_ALERT_PAGE_URL = '/alerts/definitions/create'; /** * Fills metric details in the form. * @param ruleIndex - The index of the rule to fill. * @param dataField - The metric's data field (e.g., "CPU Utilization"). - * @param aggregationType - The aggregation type (e.g., "Avg"). + * @param aggregationType - The aggregation type (e.g., "Average"). * @param operator - The operator (e.g., ">=", "=="). * @param threshold - The threshold value for the metric. */ @@ -161,11 +156,49 @@ const fillMetricDetailsForSpecificRule = ({ cy.get('[data-qa-threshold]').should('be.visible').type(threshold); }); }; +/** + * Verifies that a specific alert row in the alert definitions table is correctly displayed. + * + * This function locates the row by the given alert label, then asserts the presence and + * visibility of key values including the status (mapped through `statusMap`), service type, + * creator, and the formatted update date. + * + * @param label - The label of the alert to find in the table. + * @param status - The raw status key to be mapped using `statusMap`. + * @param statusMap - A mapping of raw status keys to human-readable status strings. + * @param createdBy - The username of the user who created the alert. + * @param updated - The ISO timestamp string indicating when the alert was last updated. + */ +const verifyAlertRow = ( + label: string, + status: string, + statusMap: Record, + createdBy: string, + updated: string +) => { + cy.findByText(label) + .closest('tr') + .should('exist') + .then(($row) => { + cy.wrap($row).within(() => { + cy.findByText(label).should('be.visible'); + cy.findByText(statusMap[status]).should('be.visible'); + cy.findByText('Databases').should('be.visible'); + cy.findByText(createdBy).should('be.visible'); + cy.findByText( + formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + timezone: 'GMT', + }) + ).should('be.visible'); + }); + }); +}; -describe('Create Alert', () => { +describe('Create Firewall Alert Successfully', () => { /* * - Confirms that users can navigate from the Alert Listings page to the Create Alert page. - * - Confirms that users can enter alert details, select resources, and configure conditions. + * - Confirms that users can enter alert details, select entities, and configure conditions. * - Confirms that the UI allows adding notification channels and setting thresholds. * - Confirms client-side validation when entering invalid metric values. * - Confirms that API interactions work correctly and return the expected responses. @@ -181,9 +214,6 @@ describe('Create Alert', () => { mockGetDatabases(databaseMock); mockGetAllAlertDefinitions([mockAlerts]).as('getAlertDefinitionsList'); mockGetAlertChannels([notificationChannels]); - mockCreateAlertDefinition(serviceType, mockAlerts).as( - 'createAlertDefinition' - ); }); it('should navigate to the Create Alert page from the Alert Listings page', () => { @@ -200,245 +230,273 @@ describe('Create Alert', () => { .click(); // Verify the URL ends with the expected details page path - cy.url().should('endWith', '/alerts/definitions/create'); + cy.url().should('endWith', CREATE_ALERT_PAGE_URL); }); - it('should successfully create a new alert', () => { - cy.visitWithLogin('/alerts/definitions/create'); - - // Enter Name and Description - cy.findByPlaceholderText('Enter a Name') - .should('be.visible') - .type(customAlertDefinition.label); - - cy.findByPlaceholderText('Enter a Description') - .should('be.visible') - .type(customAlertDefinition.description ?? ''); - - // Select Service - ui.autocomplete - .findByLabel('Service') - .should('be.visible') - .type('Databases'); - ui.autocompletePopper.findByTitle('Databases').should('be.visible').click(); - // Select Severity - ui.autocomplete.findByLabel('Severity').should('be.visible').type('Severe'); - ui.autocompletePopper.findByTitle('Severe').should('be.visible').click(); - - // Search for Entity - cy.findByPlaceholderText('Search for a Region or Entity') - .should('be.visible') - .type('database-2'); - - // Find the table and locate the entity cell containing 'database-2', then check the corresponding checkbox - cy.get('[data-qa-alert-table="true"]') // Find the table - .contains('[data-qa-alert-cell*="entity"]', 'database-2') // Find entity cell - .parents('tr') - .find('[type="checkbox"]') - .check(); - - // Assert entity selection notice - cy.findByText('1 of 10 entities are selected.'); - - // Fill metric details for the first rule - const cpuUsageMetricDetails = { - aggregationType: 'Avg', - dataField: 'CPU Utilization', - operator: '=', - ruleIndex: 0, - threshold: '1000', - }; - - fillMetricDetailsForSpecificRule(cpuUsageMetricDetails); - - // Add metrics - cy.findByRole('button', { name: 'Add metric' }) - .should('be.visible') - .click(); - - ui.buttonGroup - .findButtonByTitle('Add dimension filter') - .should('be.visible') - .click(); - - ui.autocomplete - .findByLabel('Data Field') - .eq(1) - .should('be.visible') - .clear(); - - ui.autocomplete - .findByLabel('Data Field') - .eq(1) - .should('be.visible') - .type('State of CPU'); - - cy.findByText('State of CPU').should('be.visible').click(); - - ui.autocomplete.findByLabel('Operator').eq(1).should('be.visible').clear(); - - ui.autocomplete.findByLabel('Operator').eq(1).type('Equal'); - - cy.findByText('Equal').should('be.visible').click(); - - ui.autocomplete.findByLabel('Value').should('be.visible').type('User'); - - cy.findByText('User').should('be.visible').click(); - - // Fill metric details for the second rule - - const memoryUsageMetricDetails = { - aggregationType: 'Avg', - dataField: 'Memory Usage', - operator: '=', - ruleIndex: 1, - threshold: '1000', - }; - - fillMetricDetailsForSpecificRule(memoryUsageMetricDetails); - // Set evaluation period - ui.autocomplete - .findByLabel('Evaluation Period') - .should('be.visible') - .type('5 min'); - ui.autocompletePopper.findByTitle('5 min').should('be.visible').click(); - - // Set polling interval - ui.autocomplete - .findByLabel('Polling Interval') - .should('be.visible') - .type('5 min'); - ui.autocompletePopper.findByTitle('5 min').should('be.visible').click(); - - // Set trigger occurrences - cy.get('[data-qa-trigger-occurrences]').should('be.visible').clear(); - - cy.get('[data-qa-trigger-occurrences]').should('be.visible').type('5'); - - // Add notification channel - ui.buttonGroup.find().contains('Add notification channel').click(); - - ui.autocomplete.findByLabel('Type').should('be.visible').type('Email'); - ui.autocompletePopper.findByTitle('Email').should('be.visible').click(); - - ui.autocomplete - .findByLabel('Channel') - .should('be.visible') - .type('channel-1'); - - ui.autocompletePopper.findByTitle('channel-1').should('be.visible').click(); - - // Add channel - ui.drawer - .findByTitle('Add Notification Channel') - .should('be.visible') - .within(() => { - ui.buttonGroup - .findButtonByTitle('Add channel') - .should('be.visible') - .click(); - }); - // Click on submit button - ui.buttonGroup - .find() - .find('button') - .filter('[type="submit"]') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@createAlertDefinition').then(({ request }) => { - const { - description, - label, - rule_criteria: { rules }, - severity, - trigger_conditions: { - criteria_condition, - evaluation_period_seconds, - polling_interval_seconds, - trigger_occurrences, + // entityScopingOptions is an array of predefined scoping strategies for alert definitions. + // Each item in the array represents a way to scope entities when generating or organizing alerts. + // The scoping strategies include 'Per Account', 'Per Entity', and 'Per Region'. + entityGroupingOptions.forEach(({ label: groupLabel, value }) => { + it(`should successfully create a new alert for ${groupLabel} level`, () => { + const alerts = alertFactory.build({ + alert_channels: [{ id: 1 }], + created_by: 'user1', + description: 'My Custom Description', + label: 'Alert-1', + rule_criteria: { + rules: [cpuRulesFactory.build(), memoryRulesFactory.build()], }, - } = customAlertDefinition; - - const { created_by, status, updated } = mockAlerts; - - // Validate top-level properties - expect(request.body.label).to.equal(label); - expect(request.body.description).to.equal(description); - expect(request.body.severity).to.equal(severity); - - // Validate rule criteria - expect(request.body.rule_criteria).to.have.property('rules'); - expect(request.body.rule_criteria.rules) - .to.be.an('array') - .with.length(rules.length); - - // Validate first rule - const firstRule = request.body.rule_criteria.rules[0]; - const firstCustomRule = rules[0]; - expect(firstRule.aggregate_function).to.equal( - firstCustomRule.aggregate_function - ); - expect(firstRule.metric).to.equal(firstCustomRule.metric); - expect(firstRule.operator).to.equal(firstCustomRule.operator); - expect(firstRule.threshold).to.equal(firstCustomRule.threshold); - expect(firstRule.dimension_filters[0]?.dimension_label ?? '').to.equal( - firstCustomRule.dimension_filters?.[0]?.dimension_label ?? '' - ); - expect(firstRule.dimension_filters[0]?.operator ?? '').to.equal( - firstCustomRule.dimension_filters?.[0]?.operator ?? '' - ); - expect(firstRule.dimension_filters[0]?.value ?? '').to.equal( - firstCustomRule.dimension_filters?.[0]?.value ?? '' + service_type: 'dbaas', + severity: 0, + tags: [''], + trigger_conditions: triggerConditionFactory.build(), + scope: value, + ...(value === 'region' ? { regions: regionList } : {}), + }); + const services = serviceTypesFactory.build({ + service_type: serviceType, + label: serviceType, + alert: serviceAlertFactory.build({ + evaluation_period_seconds: [300], + polling_interval_seconds: [300], + scope: [value], + }), + regions: 'us-ord,us-east', + }); + mockGetCloudPulseServiceByType(serviceType, services); + mockGetAllAlertDefinitions([alerts]).as('getAlertDefinitionsList'); + mockCreateAlertDefinition(serviceType, alerts).as( + 'createAlertDefinition' ); - // Validate second rule - const secondRule = request.body.rule_criteria.rules[1]; - const secondCustomRule = rules[1]; - expect(secondRule.aggregate_function).to.equal( - secondCustomRule.aggregate_function - ); - expect(secondRule.metric).to.equal(secondCustomRule.metric); - expect(secondRule.operator).to.equal(secondCustomRule.operator); - expect(secondRule.threshold).to.equal(secondCustomRule.threshold); - - // Validate trigger conditions - const triggerConditions = request.body.trigger_conditions; - expect(triggerConditions.trigger_occurrences).to.equal( - trigger_occurrences + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + // Fill in Name and Description + cy.findByPlaceholderText('Enter a Name').type(alerts.label); + cy.findByPlaceholderText('Enter a Description').type( + alerts.description || '' ); - expect(triggerConditions.evaluation_period_seconds).to.equal( - evaluation_period_seconds - ); - expect(triggerConditions.polling_interval_seconds).to.equal( - polling_interval_seconds + + // Fill in Service and Severity + ui.autocomplete.findByLabel('Service').type('Databases'); + ui.autocompletePopper.findByTitle('Databases').click(); + ui.tooltip.findByText( + 'Define a severity level associated with the alert to help you prioritize and manage alerts in the Recent activity tab.' ); - expect(triggerConditions.criteria_condition).to.equal(criteria_condition); - // Validate entity IDs and channels - expect(request.body.entity_ids).to.include('2'); - expect(request.body.channel_ids).to.include(1); + ui.autocomplete.findByLabel('Severity').type('Severe'); + ui.autocompletePopper.findByTitle('Severe').click(); - // Verify URL redirection and toast notification - cy.url().should('endWith', '/alerts/definitions'); - ui.toast.assertMessage(CREATE_ALERT_SUCCESS_MESSAGE); + ui.tooltip.findByText( + 'The set of entities to which the alert applies: account-wide, specific regions, or individual entities.' + ); - // Confirm that Alert is listed on landing page with expected configuration. - cy.findByText(label) - .closest('tr') + ui.autocomplete + .findByLabel('Scope') + .should('be.visible') + .clear() + .type(groupLabel); + + ui.autocompletePopper + .findByTitle(groupLabel) + .should('be.visible') + .click(); + + groupLabel !== 'Account' && + cy.get('[data-testid="select_all_notice"]').click(); + // Fill metric details for the first rule + const cpuUsageMetricDetails = { + aggregationType: 'Avg', + dataField: 'CPU Utilization', + operator: '=', + ruleIndex: 0, + threshold: '1000', + }; + + fillMetricDetailsForSpecificRule(cpuUsageMetricDetails); + + // Add metrics + cy.findByRole('button', { name: 'Add metric' }) + .should('be.visible') + .click(); + + ui.buttonGroup + .findButtonByTitle('Add dimension filter') + .should('be.visible') + .click(); + + ui.autocomplete + .findByLabel('Data Field') + .eq(1) + .should('be.visible') + .clear(); + + ui.autocomplete + .findByLabel('Data Field') + .eq(1) + .should('be.visible') + .type('State of CPU'); + + cy.findByText('State of CPU').should('be.visible').click(); + + ui.autocomplete + .findByLabel('Operator') + .eq(1) + .should('be.visible') + .clear(); + + ui.autocomplete.findByLabel('Operator').eq(1).type('Equal'); + + cy.findByText('Equal').should('be.visible').click(); + + ui.autocomplete.findByLabel('Value').should('be.visible').type('User'); + + cy.findByText('User').should('be.visible').click(); + + // Fill metric details for the second rule + + const memoryUsageMetricDetails = { + aggregationType: 'Avg', + dataField: 'Memory Usage', + operator: '=', + ruleIndex: 1, + threshold: '1000', + }; + + fillMetricDetailsForSpecificRule(memoryUsageMetricDetails); + // Set evaluation period + ui.autocomplete + .findByLabel('Evaluation Period') + .should('be.visible') + .type('5 min'); + ui.autocompletePopper.findByTitle('5 min').should('be.visible').click(); + + // Set polling interval + ui.autocomplete + .findByLabel('Polling Interval') + .should('be.visible') + .type('5 min'); + ui.autocompletePopper.findByTitle('5 min').should('be.visible').click(); + + // Set trigger occurrences + cy.get('[data-qa-trigger-occurrences]').should('be.visible').clear(); + + cy.get('[data-qa-trigger-occurrences]').should('be.visible').type('5'); + + // Add notification channel + ui.buttonGroup.find().contains('Add notification channel').click(); + + ui.autocomplete.findByLabel('Type').should('be.visible').type('Email'); + ui.autocompletePopper.findByTitle('Email').should('be.visible').click(); + + ui.autocomplete + .findByLabel('Channel') + .should('be.visible') + .type('channel-1'); + + ui.autocompletePopper + .findByTitle('channel-1') + .should('be.visible') + .click(); + + // Add channel + ui.drawer + .findByTitle('Add Notification Channel') + .should('be.visible') .within(() => { - cy.findByText(label).should('be.visible'); - cy.findByText(statusMap[status]).should('be.visible'); - cy.findByText('Databases').should('be.visible'); - cy.findByText(created_by).should('be.visible'); - cy.findByText( - formatDate(updated, { - format: 'MMM dd, yyyy, h:mm a', - timezone: 'GMT', - }) - ).should('be.visible'); + ui.buttonGroup + .findButtonByTitle('Add channel') + .should('be.visible') + .click(); }); + // Click on submit button + ui.buttonGroup + .find() + .find('button') + .filter('[type="submit"]') + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait('@createAlertDefinition').then(({ request, response }) => { + const { + description, + label, + rule_criteria: { rules }, + severity, + trigger_conditions: { + criteria_condition, + evaluation_period_seconds, + polling_interval_seconds, + trigger_occurrences, + }, + } = customAlertDefinition; + + const { created_by, status, updated } = mockAlerts; + + // Validate top-level properties + expect(request.body.label).to.equal(label); + expect(request.body.description).to.equal(description); + expect(request.body.severity).to.equal(severity); + expect(request.body.scope).to.equal(alerts.scope); + + // Validate rule criteria + expect(request.body.rule_criteria).to.have.property('rules'); + expect(request.body.rule_criteria.rules) + .to.be.an('array') + .with.length(rules.length); + + // Validate first rule + const firstRule = request.body.rule_criteria.rules[0]; + const firstCustomRule = rules[0]; + expect(firstRule.aggregate_function).to.equal( + firstCustomRule.aggregate_function + ); + expect(firstRule.metric).to.equal(firstCustomRule.metric); + expect(firstRule.operator).to.equal(firstCustomRule.operator); + expect(firstRule.threshold).to.equal(firstCustomRule.threshold); + expect(firstRule.dimension_filters[0]?.dimension_label ?? '').to.equal( + firstCustomRule.dimension_filters?.[0]?.dimension_label ?? '' + ); + expect(firstRule.dimension_filters[0]?.operator ?? '').to.equal( + firstCustomRule.dimension_filters?.[0]?.operator ?? '' + ); + expect(firstRule.dimension_filters[0]?.value ?? '').to.equal( + firstCustomRule.dimension_filters?.[0]?.value ?? '' + ); + + // Validate second rule + const secondRule = request.body.rule_criteria.rules[1]; + const secondCustomRule = rules[1]; + expect(secondRule.aggregate_function).to.equal( + secondCustomRule.aggregate_function + ); + expect(secondRule.metric).to.equal(secondCustomRule.metric); + expect(secondRule.operator).to.equal(secondCustomRule.operator); + expect(secondRule.threshold).to.equal(secondCustomRule.threshold); + + // Validate trigger conditions + const triggerConditions = request.body.trigger_conditions; + expect(triggerConditions.trigger_occurrences).to.equal( + trigger_occurrences + ); + expect(triggerConditions.evaluation_period_seconds).to.equal( + evaluation_period_seconds + ); + expect(triggerConditions.polling_interval_seconds).to.equal( + polling_interval_seconds + ); + expect(triggerConditions.criteria_condition).to.equal( + criteria_condition + ); + // Validate entity IDs and channels + expect(request.body.channel_ids).to.include(1); + expect(response?.body.scope).to.eq(value); + + // Verify URL redirection and toast notification + cy.url().should('endWith', '/alerts/definitions'); + ui.toast.assertMessage(CREATE_ALERT_SUCCESS_MESSAGE); + // Confirm that Alert is listed on landing page with expected configuration. + verifyAlertRow(label, status, statusMap, created_by, updated); + }); }); }); -}); +}); \ No newline at end of file diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts index 44df530332a..315ce8a3135 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts @@ -194,8 +194,6 @@ describe('Integration Tests for Edit Alert', () => { const { created_by, description, severity, status, type, updated_by } = alertDetails; - expect(response).to.have.property('statusCode', 200); - const resourceIds: string[] = request.body.entity_ids.map((id: number) => String(id) ); @@ -269,4 +267,4 @@ describe('Integration Tests for Edit Alert', () => { ui.toast.assertMessage('Alert entities successfully updated.'); }); }); -}); +}); \ No newline at end of file diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index c50f8955077..e933804e6c6 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -20,6 +20,7 @@ import { mockGetAlertDefinitions, mockGetAllAlertDefinitions, mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServiceByType, mockGetCloudPulseServices, mockUpdateAlertDefinitions, } from 'support/intercepts/cloudpulse'; @@ -38,14 +39,25 @@ import { flagsFactory, memoryRulesFactory, notificationChannelFactory, + serviceAlertFactory, + serviceTypesFactory, triggerConditionFactory, } from 'src/factories'; -import { UPDATE_ALERT_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/constants'; +import { + ACCOUNT_GROUP_INFO_MESSAGE, + entityGroupingOptions, + REGION_GROUP_INFO_MESSAGE, + UPDATE_ALERT_SUCCESS_MESSAGE, +} from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; import type { Database } from '@linode/api-v4'; const mockAccount = accountFactory.build(); +const regionList = ['us-ord', 'us-east']; +const now = new Date(); + +const updated = `${now.toISOString().substring(0, 11)}10:41:00.000Z`; // Mock alert details const alertDetails = alertFactory.build({ @@ -62,10 +74,12 @@ const alertDetails = alertFactory.build({ tags: [''], trigger_conditions: triggerConditionFactory.build(), type: 'user', - updated: new Date().toISOString(), + updated, + scope: 'entity', + regions: regionList, }); -const { description, id, label, service_type, updated } = alertDetails; +const { description, id, label, service_type } = alertDetails; // Mock regions const regions = [ @@ -73,11 +87,13 @@ const regions = [ capabilities: ['Managed Databases'], id: 'us-ord', label: 'Chicago, IL', + monitors: { alerts: ['Managed Databases'] }, }), regionFactory.build({ capabilities: ['Managed Databases'], id: 'us-east', label: 'Newark', + monitors: { alerts: ['Managed Databases'] }, }), ]; @@ -107,7 +123,12 @@ const notificationChannels = notificationChannelFactory.build({ const mockProfile = profileFactory.build({ timezone: 'gmt', }); - +const services = serviceTypesFactory.build({ + service_type: 'dbaas', + label: 'dbaas', + alert: serviceAlertFactory.build(), + regions: 'us-ord,us-east', +}); describe('Integration Tests for Edit Alert', () => { /* * - Confirms that the Edit Alert page loads with the correct alert details. @@ -129,19 +150,9 @@ describe('Integration Tests for Edit Alert', () => { mockGetAccount(mockAccount); mockGetProfile(mockProfile); mockGetRegions(regions); - mockGetCloudPulseServices([alertDetails.service_type]); - mockGetAllAlertDefinitions([alertDetails]).as('getAlertDefinitionsList'); - mockGetAlertDefinitions(service_type, id, alertDetails).as( - 'getAlertDefinitions' - ); - mockGetDatabases(databases).as('getDatabases'); - mockUpdateAlertDefinitions(service_type, id, alertDetails).as( - 'updateDefinitions' - ); - mockCreateAlertDefinition(service_type, alertDetails).as( - 'createAlertDefinition' - ); + mockGetCloudPulseServiceByType('dbaas', services); mockGetCloudPulseMetricDefinitions(service_type, metricDefinitions); + mockGetDatabases(databases).as('getDatabases'); mockGetAlertChannels([notificationChannels]); }); @@ -160,6 +171,28 @@ describe('Integration Tests for Edit Alert', () => { operator: 'operator', threshold: 'threshold', }; + /** + * Assert that a table row corresponding to a specific alert label + * contains all expected values including status, service type, user, and timestamp. + * + * @param {string} label - The alert label to find in the table row. + * @param {string | Date} updated - The last updated timestamp (ISO string or Date object). + */ + const assertAlertRow = (label: string, updated: string): void => { + const formattedDate = formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + timezone: 'GMT', + }); + cy.findByText(label) + .closest('tr') + .within(() => { + cy.findByText(label).should('be.visible'); + cy.findByText('Enabled').should('be.visible'); + cy.findByText('Databases').should('be.visible'); + cy.findByText('user1').should('be.visible'); + cy.findByText(formattedDate).should('be.visible'); + }); + }; // Function to assert rule values const assertRuleValues = (ruleIndex: number, rule: RuleCriteria) => { @@ -175,7 +208,131 @@ describe('Integration Tests for Edit Alert', () => { }); }; + const scopeActions: Record void> = { + // Region-level alert validations + Region: () => { + cy.get('[data-qa="region-tabls"]').within(() => { + const expectedRegions = [ + 'US, Chicago, IL (us-ord)', + 'US, Newark (us-east)', + ]; + + expectedRegions.forEach((region) => { + cy.contains('tr', region).should('exist'); + }); + }); + + cy.get('[data-qa-notice="true"]') + .find('[data-testid="alert_message_notice"]') + .should('have.text', REGION_GROUP_INFO_MESSAGE); + }, + // Account-level alert validations + Account: () => { + cy.get('[data-qa-notice="true"]') + .find('[data-testid="alert_message_notice"]') + .should('have.text', ACCOUNT_GROUP_INFO_MESSAGE); + }, + // Entity-level alert validations + Entity: () => { + const searchPlaceholder = 'Search for a Region or Entity'; + // Validate headings + ui.heading + .findByText('entity') + .scrollIntoView() + .should('be.visible') + .should('have.text', 'Entity'); + + ui.heading + .findByText('region') + .should('be.visible') + .should('have.text', 'Region'); + + // Validate search inputs + cy.findByPlaceholderText(searchPlaceholder).should('be.visible'); + cy.findByPlaceholderText('Select Regions').should('be.visible'); + + // Assert row count + cy.get('[data-qa-alert-row]').should('have.length', 5); + + // Validate entity-region mapping + const regionMap = new Map( + regions.map((r) => [r.id, `US, ${r.label} (${r.id})`]) + ); + + cy.get('[data-qa-alert-row]') + .should('have.length', 5) + .each((row, index) => + validateAlertRow(row, index, databases, regionMap) + ); + + // Entity search + cy.findByPlaceholderText(searchPlaceholder).type('database-1'); + + cy.get('[data-qa-alert-table="true"]') + .find('[data-qa-alert-row]') + .should('have.length', 1); + + cy.findByText(databases[0].label).should('be.visible'); + + [1, 2, 3].forEach((i) => + cy.findByText(databases[i].label).should('not.exist') + ); + + // Region filter + cy.get('[data-qa-debounced-search="true"]').within(() => { + cy.get('[aria-label="Clear"]').click(); + }); + + ui.regionSelect.find().click().type(`${regions[0].label}{enter}`).click(); + + // Filtered table should have exactly 1 row + cy.get('[data-qa-alert-table="true"]') + .find('[data-qa-alert-row]') + .should('have.length', 3) + .first() + .within(() => { + cy.get('[data-qa-alert-cell$="_region"]').should('be.visible'); + }); + }, + }; + const validateAlertRow = ( + row: JQuery, + rowNumber: number, + databases: Database[], + regionMap: Map + ) => { + const db = databases[rowNumber]; + + cy.wrap(row).within(() => { + cy.get(`[data-qa-alert-cell="${rowNumber}_entity"]`).should( + 'have.text', + db.label + ); + + cy.get(`[data-qa-alert-cell="${rowNumber}_database engine"]`).should( + 'have.text', + 'MySQL' + ); + + cy.get(`[data-qa-alert-cell="${rowNumber}_region"]`).should( + 'have.text', + regionMap.get(db.region) + ); + }); + }; + it('should correctly display the details of the alert in the Edit Alert page', () => { + mockGetCloudPulseServices([alertDetails.service_type]); + mockGetAllAlertDefinitions([alertDetails]).as('getAlertDefinitionsList'); + mockGetAlertDefinitions(service_type, id, alertDetails).as( + 'getAlertDefinitions' + ); + mockUpdateAlertDefinitions(service_type, id, alertDetails).as( + 'updateDefinitions' + ); + mockCreateAlertDefinition(service_type, alertDetails).as( + 'createAlertDefinition' + ); cy.visitWithLogin(`/alerts/definitions/edit/${service_type}/${id}`); cy.wait('@getAlertDefinitions'); @@ -188,7 +345,9 @@ describe('Integration Tests for Edit Alert', () => { cy.findByLabelText('Service') .should('be.disabled') .should('have.value', 'Databases'); + cy.findByLabelText('Severity').should('have.value', 'Severe'); + cy.findByLabelText('Scope').should('have.value', 'Entity'); // Verify alert entity selection cy.get('[data-qa-alert-table="true"]') @@ -264,114 +423,124 @@ describe('Integration Tests for Edit Alert', () => { }); }); - it('successfully updated alert details and verified that the API request matches the expected test data.', () => { - cy.visitWithLogin(`/alerts/definitions/edit/${service_type}/${id}`); - cy.wait('@getAlertDefinitions'); - - // Make changes to alert form - cy.findByLabelText('Name').clear(); - cy.findByLabelText('Name').type('Alert-2'); - cy.findByLabelText('Description (optional)').clear(); - cy.findByLabelText('Description (optional)').type('update-description'); - cy.findByLabelText('Service').should('be.disabled'); - ui.autocomplete.findByLabel('Severity').clear(); - ui.autocomplete.findByLabel('Severity').type('Info'); - ui.autocompletePopper.findByTitle('Info').should('be.visible').click(); - cy.get('[data-qa-notice="true"]') - .find('button') - .contains('Deselect All') - .click(); - cy.get('[data-qa-notice="true"]') - .find('button') - .contains('Select All') - .click(); - - cy.get( - '[data-qa-metric-threshold="rule_criteria.rules.0-data-field"]' - ).within(() => { - ui.button.findByAttribute('aria-label', 'Clear').click(); - }); - - cy.get('[data-testid="rule_criteria.rules.0-id"]').within(() => { - ui.autocomplete.findByLabel('Data Field').type('Disk I/O'); - ui.autocompletePopper.findByTitle('Disk I/O').click(); - ui.autocomplete.findByLabel('Aggregation Type').type('Min'); - ui.autocompletePopper.findByTitle('Min').click(); - ui.autocomplete.findByLabel('Operator').type('>'); - ui.autocompletePopper.findByTitle('>').click(); - cy.get('[data-qa-threshold]').should('be.visible').clear(); - cy.get('[data-qa-threshold]').should('be.visible').type('2000'); - }); - - // click on the submit button - ui.buttonGroup - .find() - .find('button') - .filter('[type="submit"]') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@updateDefinitions').then(({ request }) => { - // Assert the API request data - expect(request.body.label).to.equal('Alert-2'); - expect(request.body.description).to.equal('update-description'); - expect(request.body.severity).to.equal(3); - expect(request.body.entity_ids).to.have.members([ - '0', - '1', - '2', - '3', - '4', - ]); - expect(request.body.channel_ids[0]).to.equal(1); - expect(request.body).to.have.property('trigger_conditions'); - expect(request.body.trigger_conditions.criteria_condition).to.equal( - 'ALL' - ); - expect( - request.body.trigger_conditions.evaluation_period_seconds - ).to.equal(300); - expect(request.body.trigger_conditions.polling_interval_seconds).to.equal( - 300 - ); - expect(request.body.trigger_conditions.trigger_occurrences).to.equal(5); - expect(request.body.rule_criteria.rules[0].threshold).to.equal(2000); - expect(request.body.rule_criteria.rules[0].operator).to.equal('gt'); - expect(request.body.rule_criteria.rules[0].aggregate_function).to.equal( - 'min' - ); - expect(request.body.rule_criteria.rules[0].metric).to.equal( - 'system_disk_OPS_total' + entityGroupingOptions.forEach(({ label: groupLabel, value }) => { + it(`successfully updates alert details and verifies the API request matches the expected data for the "${groupLabel}" group.`, () => { + const alertDetails = alertFactory.build({ + alert_channels: [{ id: 1 }], + created_by: 'user1', + description: 'My Custom Description', + entity_ids: ['2'], + label: 'Alert-2', + rule_criteria: { + rules: [cpuRulesFactory.build(), memoryRulesFactory.build()], + }, + service_type: 'dbaas', + severity: 0, + tags: [''], + trigger_conditions: triggerConditionFactory.build(), + type: 'user', + updated, + scope: value, + id: 1, + regions: regionList, + }); + mockGetRegions(regions); + mockGetCloudPulseServiceByType('dbaas', services); + mockGetCloudPulseServices([alertDetails.service_type]); + mockGetAllAlertDefinitions([alertDetails]).as('getAlertDefinitionsList'); + mockGetAlertDefinitions(service_type, id, alertDetails).as( + 'getAlertDefinitions' ); - expect(request.body.rule_criteria.rules[1].aggregate_function).to.equal( - 'avg' + mockUpdateAlertDefinitions(service_type, id, alertDetails).as( + 'updateDefinitions' ); - expect(request.body.rule_criteria.rules[1].metric).to.equal( - 'system_memory_usage_by_resource' + mockCreateAlertDefinition(service_type, alertDetails).as( + 'createAlertDefinition' ); - expect(request.body.rule_criteria.rules[1].operator).to.equal('eq'); - expect(request.body.rule_criteria.rules[1].threshold).to.equal(1000); + cy.visitWithLogin(`/alerts/definitions/edit/${service_type}/${id}`); + cy.wait('@getAlertDefinitions'); + cy.findByLabelText('Name').clear(); + cy.findByLabelText('Name').type('Alert-2'); + cy.findByLabelText('Description (optional)').clear(); + cy.findByLabelText('Description (optional)').type('update-description'); + cy.findByLabelText('Service').should('be.disabled'); + ui.autocomplete.findByLabel('Severity').clear(); + ui.autocomplete.findByLabel('Severity').type('Info'); + ui.autocompletePopper.findByTitle('Info').should('be.visible').click(); + // Execute the appropriate validation logic based on the alert's grouping label (e.g., 'Region' or 'Account' or 'Entity') + scopeActions[groupLabel](); - // Verify URL redirection and toast notification - cy.url().should('endWith', 'alerts/definitions'); - ui.toast.assertMessage(UPDATE_ALERT_SUCCESS_MESSAGE); + cy.get( + '[data-qa-metric-threshold="rule_criteria.rules.0-data-field"]' + ).within(() => { + ui.button.findByAttribute('aria-label', 'Clear').click(); + }); + cy.get('[data-testid="rule_criteria.rules.0-id"]').within(() => { + ui.autocomplete.findByLabel('Data Field').type('Disk I/O'); + ui.autocompletePopper.findByTitle('Disk I/O').click(); + ui.autocomplete.findByLabel('Aggregation Type').type('Min'); + ui.autocompletePopper.findByTitle('Min').click(); + ui.autocomplete.findByLabel('Operator').type('>'); + ui.autocompletePopper.findByTitle('>').click(); + cy.get('[data-qa-threshold]').should('be.visible').clear(); + cy.get('[data-qa-threshold]').should('be.visible').type('2000'); + }); - // Confirm that Alert is listed on landing page with expected configuration. - cy.findByText('Alert-2') - .closest('tr') - .within(() => { - cy.findByText('Alert-2').should('be.visible'); - cy.findByText('Enabled').should('be.visible'); - cy.findByText('Databases').should('be.visible'); - cy.findByText('user1').should('be.visible'); - cy.findByText( - formatDate(updated, { - format: 'MMM dd, yyyy, h:mm a', - timezone: 'GMT', - }) - ).should('be.visible'); - }); + // click on the submit button + ui.buttonGroup + .find() + .find('button') + .filter('[type="submit"]') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@updateDefinitions').then(({ request }) => { + // Assert the API request data + expect(request.body.label).to.equal('Alert-2'); + expect(request.body.description).to.equal('update-description'); + expect(request.body.severity).to.equal(3); + value === 'region' && + expect( + request.body.regions, + 'Regions should match when grouping is per-region' + ).to.have.members(regionList); + expect(request.body.channel_ids[0]).to.equal(1); + expect(request.body).to.have.property('trigger_conditions'); + expect(request.body.trigger_conditions.criteria_condition).to.equal( + 'ALL' + ); + expect( + request.body.trigger_conditions.evaluation_period_seconds + ).to.equal(300); + expect( + request.body.trigger_conditions.polling_interval_seconds + ).to.equal(300); + expect(request.body.trigger_conditions.trigger_occurrences).to.equal(5); + expect(request.body.rule_criteria.rules[0].threshold).to.equal(2000); + expect(request.body.rule_criteria.rules[0].operator).to.equal('gt'); + expect(request.body.rule_criteria.rules[0].aggregate_function).to.equal( + 'min' + ); + expect(request.body.rule_criteria.rules[0].metric).to.equal( + 'system_disk_OPS_total' + ); + expect(request.body.rule_criteria.rules[1].aggregate_function).to.equal( + 'avg' + ); + expect(request.body.rule_criteria.rules[1].metric).to.equal( + 'system_memory_usage_by_resource' + ); + expect(request.body.rule_criteria.rules[1].operator).to.equal('eq'); + expect(request.body.rule_criteria.rules[1].threshold).to.equal(1000); + + // Verify URL redirection and toast notification + cy.url().should('endWith', 'alerts/definitions'); + ui.toast.assertMessage(UPDATE_ALERT_SUCCESS_MESSAGE); + + // Confirm that Alert is listed on landing page with expected configuration. + assertAlertRow('Alert-2', updated); + }); }); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts index 82a3548e5ed..af6bf62bcd1 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts @@ -195,8 +195,13 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.get('[data-testid="betaChip"]').should('not.exist'); }); }); + // SKIPPED: The feature flag normalizer auto-enables Linode when `{ aclpServices: { linode: {} } }` is provided, + // expanding to `aclpServices.linode.metrics = { beta: true, enabled: true }`. Expected: Linode hidden when the + // flag is missing/empty. Unskip when either (a) the flag lib distinguishes missing/empty and treats them as + // disabled, or (b) product adopts "omit key = disabled" and the test is updated accordingly. + // Tracked in DI-27224 — unskip once flag handling is fixed. - it('should not display "Linode" when its feature flag is missing', () => { + it.skip('should not display "Linode" when its feature flag is missing', () => { // Mock the feature flags without linode under aclpServices const flags = { aclp: { beta: true, enabled: true }, @@ -253,8 +258,11 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.get('[data-testid="betaChip"]').should('not.exist'); }); }); - - it('should show no service options when aclpServices flag is missing', () => { + // SKIPPED: If `aclpServices` is not passed, LaunchDarkly should treat it as null/false. + // Currently, missing/partial flags are defaulted to `{ beta: true, enabled: true }`, + // which causes Linode to appear enabled instead of hidden. + // Tracked in DI-27224 — unskip once flag handling is fixed. + it.skip('should show no service options when aclpServices flag is missing', () => { // Mock the feature flags without linode under aclpServices const flags = { aclp: { beta: true, enabled: true }, diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts index 0c5dc680376..d94b7e32ff4 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -262,6 +262,7 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura .first() .should('be.visible', { timeout: 10000 }) // waits up to 10 seconds .as('timePickerButton'); + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); cy.get('@timePickerButton', { timeout: 15000 }) @@ -278,19 +279,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get(`[aria-label="${startHour} hours"]`).click(); }); - cy.wait(1000); - ui.button - .findByAttribute('aria-label^', 'Choose time') - .first() - .should('be.visible', { timeout: 10000 }) - .as('timePickerButton'); - - cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - - cy.get('@timePickerButton', { timeout: 15000 }) - .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) - .click(); - cy.findByLabelText('Select minutes') .as('selectMinutes') .scrollIntoView({ duration: 500, easing: 'linear' }); @@ -299,18 +287,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get(`[aria-label="${startMinute} minutes"]`).click(); }); - ui.button - .findByAttribute('aria-label^', 'Choose time') - .first() - .should('be.visible', { timeout: 10000 }) - .as('timePickerButton'); - - cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - - cy.get('@timePickerButton', { timeout: 15000 }) - .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) - .click(); - cy.findByLabelText('Select meridiem') .as('startMeridiemSelect') .scrollIntoView(); @@ -334,31 +310,10 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get(`[aria-label="${endHour} hours"]`).click(); }); - cy.get('[aria-label^="Choose time"]') - .last() - .should('be.visible') - .as('timePickerButton'); - - cy.get('@timePickerButton', { timeout: 15000 }) - .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) - .click(); - cy.findByLabelText('Select minutes').scrollIntoView({ - duration: 500, - easing: 'linear', - }); cy.get('@selectMinutes').within(() => { cy.get(`[aria-label="${endMinute} minutes"]`).click(); }); - cy.get('[aria-label^="Choose time"]') - .last() - .should('be.visible', { timeout: 10000 }) - .as('timePickerButton'); - - cy.get('@timePickerButton', { timeout: 15000 }) - .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) - .click(); - cy.findByLabelText('Select meridiem') .as('endMeridiemSelect') .scrollIntoView(); diff --git a/packages/manager/cypress/e2e/core/databases/advanced-configuration.spec.ts b/packages/manager/cypress/e2e/core/databases/advanced-configuration.spec.ts index 5a00b2188bb..deee96b0079 100644 --- a/packages/manager/cypress/e2e/core/databases/advanced-configuration.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/advanced-configuration.spec.ts @@ -2,7 +2,6 @@ * @file DBaaS integration tests for advanced configuration operations. */ -import { ConfigCategoryValues, DatabaseEngineConfig } from '@linode/api-v4'; import { accountFactory } from '@src/factories'; import { databaseConfigurationsAdvConfig, @@ -26,6 +25,10 @@ import { postgresConfigResponse, } from 'src/factories/databases'; +import type { + ConfigCategoryValues, + DatabaseEngineConfig, +} from '@linode/api-v4'; import type { DatabaseClusterConfiguration } from 'support/constants/databases'; /** diff --git a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts index 0da780014d4..27faaca482b 100644 --- a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts @@ -89,7 +89,9 @@ describe('create a database cluster, mocked data', () => { const clusterCpuType = configuration.linodeType.indexOf('-dedicated-') !== -1 ? 'Dedicated CPU' - : 'Shared CPU'; + : configuration.linodeType.indexOf('-premium-') !== -1 + ? 'Premium CPU' + : 'Shared CPU'; // Function to validate Action Menu on the landing page as per db cluster status const validateActionItems = (state: string) => { diff --git a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts index 509bdd9ded2..62d822849ba 100644 --- a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts @@ -1,10 +1,15 @@ /** * @file DBaaS integration tests for resize operations. */ - +import { + ClusterSize, + DatabaseStatus, + RegionAvailability, +} from '@linode/api-v4'; import { accountFactory } from '@src/factories'; import { databaseConfigurationsResize, + databaseRegionAvailability, mockDatabaseNodeTypes, } from 'support/constants/databases'; import { mockGetAccount } from 'support/intercepts/account'; @@ -15,10 +20,12 @@ import { mockResize, mockResizeProvisioningDatabase, } from 'support/intercepts/databases'; +import { mockGetRegionAvailability } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { randomIp, randomNumber, randomString } from 'support/util/random'; +import { getRegionById } from 'support/util/regions'; -import { databaseFactory, possibleStatuses } from 'src/factories/databases'; +import { databaseFactory } from 'src/factories/databases'; import type { DatabaseClusterConfiguration } from 'support/constants/databases'; @@ -50,42 +57,62 @@ const resizeDatabase = (initialLabel: string) => { }); }; +/** + * Get Nodes label as per the clusterSize of the database + * + * @param clusterSize - Database Cluster Size + */ +const getNodes = (clusterSize: number) => { + const nodes = + clusterSize == 1 + ? 'Primary (1 Node)' + : clusterSize == 2 + ? 'Primary (+1 Node)' + : 'Primary (+2 Nodes)'; + return nodes; +}; + describe('Resizing existing clusters', () => { databaseConfigurationsResize.forEach( (configuration: DatabaseClusterConfiguration) => { - describe(`Resizes a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster (legacy DBaaS)`, () => { + describe(`Resizes a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { /* * - Tests active database resize UI flows using mocked data. * - Confirms that users can resize an existing database. * - Confirms that users can not downsize and smaller plans are disabled. * - Confirms that larger size plans are enabled to select for resizing and summary section displays pricing details for the selected plan. */ - it('Can resize active database clusters', () => { + + it(`Validated initial state of resizing a database cluster`, () => { const initialLabel = configuration.label; const allowedIp = randomIp(); const initialPassword = randomString(16); const database = databaseFactory.build({ allow_list: [allowedIp], - cluster_size: 3, + cluster_size: configuration.clusterSize, engine: configuration.dbType, id: randomNumber(1, 1000), label: initialLabel, - platform: 'rdbms-legacy', + platform: 'rdbms-default', region: configuration.region.id, status: 'active', type: configuration.linodeType, + used_disk_size_gb: 100, }); // Mock account to ensure 'Managed Databases' capability. const databaseType = mockDatabaseNodeTypes.find( (nodeType) => nodeType.id === database.type ); + const regionAvailability: RegionAvailability[] = + databaseRegionAvailability; if (!databaseType) { throw new Error(`Unknown database type ${database.type}`); } mockGetAccount(accountFactory.build()).as('getAccount'); mockGetDatabase(database).as('getDatabase'); mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); + mockGetRegionAvailability(database.region, regionAvailability); mockGetDatabaseCredentials( database.id, database.engine, @@ -104,22 +131,45 @@ describe('Resizing existing clusters', () => { .should('be.disabled'); let nodeTypeClass = ''; + let tabs = ['Dedicated CPU', 'Shared CPU']; + if (configuration.linodeType.includes('premium')) { + tabs = ['Premium CPU']; + } - ['Dedicated CPU', 'Shared CPU'].forEach((tabTitle) => { + tabs.forEach((tabTitle) => { // Click on the tab we want. ui.button.findByTitle(tabTitle).should('be.visible').click(); if (tabTitle == 'Dedicated CPU') { nodeTypeClass = 'dedicated'; + } else if (tabTitle == 'Premium CPU') { + nodeTypeClass = 'premium'; } else { nodeTypeClass = 'standard'; } - // Find the smaller plans name using `nodeType` and check radio button is disabled to select + + if (configuration.linodeType.includes('premium')) { + ui.button + .findByTitle('Dedicated CPU') + .should('be.visible') + .should('be.disabled'); + ui.button + .findByTitle('Shared CPU') + .should('be.visible') + .should('be.disabled'); + } else { + ui.button + .findByTitle('Premium CPU') + .should('be.visible') + .should('be.disabled'); + } + // Find the smaller plans name using `nodeType` and check radio button is disabled to select only if size is smaller than used_disk_space mockDatabaseNodeTypes .filter( (nodeType) => nodeType.class === nodeTypeClass && - nodeType.memory < databaseType.memory + nodeType.memory < databaseType.memory && + nodeType.disk / 1024 <= (database.used_disk_size_gb ?? 0) ) .forEach((nodeType) => { cy.get('[aria-label="List of Linode Plans"]') @@ -136,6 +186,7 @@ describe('Resizing existing clusters', () => { (nodeType) => nodeType.class === nodeTypeClass && nodeType.memory > databaseType.memory + // nodeType.class != mockGetDatabaseRegionsAvailability(configuration.region) ) .forEach((nodeType) => { cy.get('[aria-label="List of Linode Plans"]') @@ -160,15 +211,34 @@ describe('Resizing existing clusters', () => { ); }); }); + + // Check radio button is enabled to select different number of nodes + const size = [1, 2, 3]; + cy.get('[data-testid="database-nodes"]').within(() => { + size.forEach((clusterSize) => { + if (clusterSize == 2) { + if ( + tabTitle == 'Premium CPU' || + tabTitle == 'Dedicated CPU' + ) { + cy.get(`[data-testid="database-node-${clusterSize}"]`) + .should('be.enabled') + .click(); + } else { + cy.get( + `[data-testid="database-node-${clusterSize}"]` + ).should('not.exist'); + } + } else { + cy.get(`[data-testid="database-node-${clusterSize}"]`) + .should('be.enabled') + .click(); + } + }); + }); }); + // Find the current plan name using `nodeType` and check if it has current tag displaying in UI and radio button disabled, - if (configuration.linodeType.includes('dedicated')) { - nodeTypeClass = 'dedicated'; - ui.button.findByTitle('Dedicated CPU').should('be.visible').click(); - } else { - nodeTypeClass = 'standard'; - ui.button.findByTitle('Shared CPU').should('be.visible').click(); - } mockDatabaseNodeTypes .filter( (nodeType) => @@ -185,31 +255,286 @@ describe('Resizing existing clusters', () => { .should('contain', 'Current Plan'); }); }); + }); - const largePlan = mockDatabaseNodeTypes.filter( - (nodeType) => - nodeType.class === nodeTypeClass && - nodeType.memory > databaseType.memory - ); - if (!databaseType) { - throw new Error(`Unknown database type ${database.type}`); + /* + * - Tests active/provisioning/resizing databases resize UI flows using mocked data. + */ + ['active', 'provisioning', 'resizing'].forEach( + (dbstatus: DatabaseStatus) => { + /* + * - Confirms that users can resize an existing database. + * - Confirms that users can scale up/down to available plans as per used_disk_space. + * - Confirms the summary page is updated with the new resized plan of database cluster. + */ + it(`Can resize ${dbstatus} database clusters vertically to lower/higher available plans`, () => { + const initialLabel = configuration.label; + const allowedIp = randomIp(); + const initialPassword = randomString(16); + const database = databaseFactory.build({ + allow_list: [allowedIp], + cluster_size: 3, + engine: configuration.dbType, + id: randomNumber(1, 1000), + label: initialLabel, + platform: 'rdbms-default', + region: configuration.region.id, + status: dbstatus, + type: configuration.linodeType, + used_disk_size_gb: 100, + }); + + const regionAvailability: RegionAvailability[] = + databaseRegionAvailability; + const databaseRegionLabel = getRegionById(database.region).label; + const nodes = getNodes(configuration.clusterSize); + // Mock account to ensure 'Managed Databases' capability. + const databaseType = mockDatabaseNodeTypes.find( + (nodeType) => nodeType.id === database.type + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabase(database).as('getDatabase'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as( + 'getDatabaseTypes' + ); + mockGetRegionAvailability(database.region, regionAvailability); + mockGetDatabaseCredentials( + database.id, + database.engine, + initialPassword + ).as('getCredentials'); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getAccount', '@getDatabase', '@getDatabaseTypes']); + + cy.get('[data-reach-tab-list]').within(() => { + cy.findByText('Resize').should('be.visible').click(); + }); + ui.cdsButton + .findButtonByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.disabled'); + + let nodeTypeClass = ''; + + if (configuration.linodeType.includes('dedicated')) { + nodeTypeClass = 'dedicated'; + ui.button + .findByTitle('Dedicated CPU') + .should('be.visible') + .click(); + } else if (configuration.linodeType.includes('premium')) { + nodeTypeClass = 'premium'; + ui.button + .findByTitle('Premium CPU') + .should('be.visible') + .click(); + } else { + nodeTypeClass = 'standard'; + ui.button + .findByTitle('Shared CPU') + .should('be.visible') + .click(); + } + // Find all the available plans name using `nodeType` and check radio button is enabled to select only if disk is greater than used_disk_space + const availPlans = mockDatabaseNodeTypes.filter( + (nodeType) => + nodeType.class === nodeTypeClass && + (nodeType.memory < databaseType.memory || + nodeType.memory > databaseType.memory) && + nodeType.disk / 1024 > (database.used_disk_size_gb ?? 0) + ); + + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + + // Select a random plan from the available list to resize + if (availPlans.length > 0) { + const newPlan = + availPlans[Math.floor(Math.random() * availPlans.length)]; + cy.get(`[id="${newPlan.id}"]`).click(); + + const databaseMock = databaseFactory.build({ + ...database, + type: newPlan.id, + }); + + mockResize(database.id, database.engine, databaseMock).as( + 'scaleUpDatabase' + ); + resizeDatabase(initialLabel); + cy.wait('@scaleUpDatabase'); + + // Validate resizing redirects to Summary page + cy.url().should( + 'endWith', + `/databases/${databaseMock.engine}/${databaseMock.id}/summary` + ); + + // Validate New Cluster Configuration on Summary page + [ + 'Status', + 'Plan', + 'Nodes', + 'CPUs', + 'Engine', + 'Region', + 'RAM', + 'Total Disk Size', + databaseMock.label, + databaseRegionLabel, + `${configuration.engine} v${databaseMock.version}`, + nodes, + `${databaseMock.total_disk_size_gb} GB`, + ].forEach((text: string) => { + cy.findByText(text).should('be.visible'); + }); + } + }); + + /* + * - Confirms that users can resize an existing database. + * - Confirms that users can scale horizontally to available nodes. + * - Confirms the summary page is updated with the new resized nodes of database cluster. + */ + it(`Can resize ${dbstatus} database clusters horizontally`, () => { + const initialLabel = configuration.label; + const allowedIp = randomIp(); + const initialPassword = randomString(16); + const database = databaseFactory.build({ + allow_list: [allowedIp], + cluster_size: 3, + engine: configuration.dbType, + id: randomNumber(1, 1000), + label: initialLabel, + platform: 'rdbms-default', + region: configuration.region.id, + status: dbstatus, + type: configuration.linodeType, + used_disk_size_gb: 100, + }); + + const regionAvailability: RegionAvailability[] = + databaseRegionAvailability; + const databaseRegionLabel = getRegionById(database.region).label; + // Mock account to ensure 'Managed Databases' capability. + const databaseType = mockDatabaseNodeTypes.find( + (nodeType) => nodeType.id === database.type + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabase(database).as('getDatabase'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as( + 'getDatabaseTypes' + ); + mockGetRegionAvailability(database.region, regionAvailability); + mockGetDatabaseCredentials( + database.id, + database.engine, + initialPassword + ).as('getCredentials'); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getAccount', '@getDatabase', '@getDatabaseTypes']); + + cy.get('[data-reach-tab-list]').within(() => { + cy.findByText('Resize').should('be.visible').click(); + }); + ui.cdsButton + .findButtonByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.disabled'); + + let tabs: string = 'Shared CPU'; + if (configuration.linodeType.includes('premium')) { + tabs = 'Premium CPU'; + } else if (configuration.linodeType.includes('dedicated')) { + tabs = 'Dedicated CPU'; + } + + // Click on the tab we want. + ui.button.findByTitle(tabs).should('be.visible').click(); + + let newNode: ClusterSize = configuration.clusterSize; + + // Find an available node for resize + if ( + configuration.linodeType.includes('dedicated') || + configuration.linodeType.includes('premium') + ) { + const newNodeList = [1, 2, 3].filter( + (size) => size !== configuration.clusterSize + ); + newNode = newNodeList[ + Math.floor(Math.random() * newNodeList.length) + ] as ClusterSize; + } else { + newNode = [1, 3].find( + (size) => size !== configuration.clusterSize + ) as ClusterSize; + } + + if (newNode === configuration.clusterSize) { + ui.cdsButton + .findButtonByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.disabled'); + } else { + // Select a node for resize and resize the current database cluster + cy.get('[data-testid="database-nodes"]').within(() => { + cy.get(`[data-testid="database-node-${newNode}"]`).click(); + }); + const databaseMock = databaseFactory.build({ + ...database, + cluster_size: newNode, + }); + mockResize(database.id, database.engine, databaseMock).as( + 'scaleUpDatabase' + ); + resizeDatabase(initialLabel); + cy.wait('@scaleUpDatabase'); + + // Validate resizing redirects to Summary page + cy.url().should( + 'endWith', + `/databases/${databaseMock.engine}/${databaseMock.id}/summary` + ); + + // Validate Cluster Configuration on Summary page + [ + 'Status', + 'Plan', + 'Nodes', + 'CPUs', + 'Engine', + 'Region', + 'RAM', + 'Total Disk Size', + databaseMock.label, + databaseRegionLabel, + `${configuration.engine} v${databaseMock.version}`, + getNodes(newNode), + `${databaseMock.total_disk_size_gb} GB`, + ].forEach((text: string) => { + cy.findByText(text).should('be.visible'); + }); + } + }); } - cy.get(`[id="${largePlan[0].id}"]`).click(); - - mockResize(database.id, database.engine, { - ...database, - type: 'g6-standard-32', - }).as('scaleUpDatabase'); - resizeDatabase(initialLabel); - cy.wait('@scaleUpDatabase'); - }); + ); /* * - Tests active database resize UI flows using mocked data. * - Confirms that users can resize an existing database from dedicated to shared. * - Confirms that users can resize an existing database from shared to dedicated. */ - it(`Can resize active database clusters from ${configuration.linodeType} type and switch plan type`, () => { + it(`Can only resize dedicated/shared database clusters type and switch plan type`, () => { const initialLabel = configuration.label; const allowedIp = randomIp(); const initialPassword = randomString(16); @@ -218,10 +543,12 @@ describe('Resizing existing clusters', () => { cluster_size: 3, engine: configuration.dbType, id: randomNumber(1, 1000), + platform: 'rdbms-default', label: initialLabel, region: configuration.region.id, status: 'active', type: configuration.linodeType, + used_disk_size_gb: 100, }); // Mock account to ensure 'Managed Databases' capability. @@ -253,44 +580,75 @@ describe('Resizing existing clusters', () => { .should('be.disabled'); let nodeTypeClass = ''; + + // Cannot cross resize from and to premium plans + if (configuration.linodeType.includes('premium')) { + ui.button + .findByTitle('Shared CPU') + .should('be.visible') + .should('be.disabled'); + ui.button + .findByTitle('Dedicated CPU') + .should('be.visible') + .should('be.disabled'); + return; + } + // Find the current plan name using `nodeType` and switch to another tab for selecting plan. if (configuration.linodeType.includes('dedicated')) { nodeTypeClass = 'dedicated'; ui.button.findByTitle('Shared CPU').should('be.visible').click(); + ui.button + .findByTitle('Premium CPU') + .should('be.visible') + .should('be.disabled'); } else { nodeTypeClass = 'standard'; ui.button.findByTitle('Dedicated CPU').should('be.visible').click(); + ui.button + .findByTitle('Premium CPU') + .should('be.visible') + .should('be.disabled'); } - const largePlan = mockDatabaseNodeTypes.filter( + const availPlans = mockDatabaseNodeTypes.filter( (nodeType) => nodeType.class != nodeTypeClass && - nodeType.memory > databaseType.memory + nodeType.class != 'premium' && + (nodeType.memory < databaseType.memory || + nodeType.memory > databaseType.memory) && + nodeType.disk / 1024 > (database.used_disk_size_gb ?? 0) ); if (!databaseType) { throw new Error(`Unknown database type ${database.type}`); } - cy.get(`[id="${largePlan[0].id}"]`).click(); - - mockResize(database.id, database.engine, { - ...database, - type: 'g6-standard-32', - }).as('scaleUpDatabase'); - resizeDatabase(initialLabel); - cy.wait('@scaleUpDatabase'); + // Select a random plan from the available list to resize + if (availPlans.length > 0) { + const newPlan = + availPlans[Math.floor(Math.random() * availPlans.length)]; + cy.get(`[id="${newPlan.id}"]`).click(); + + const databaseMock = databaseFactory.build({ + ...database, + type: newPlan.id, + }); + mockResize(database.id, database.engine, databaseMock).as( + 'scaleUpDatabase' + ); + resizeDatabase(initialLabel); + cy.wait('@scaleUpDatabase'); + } }); /* * - Tests resizing database using mocked data. - * - Confirms that users cannot resize database for provisioning DBs. - * - Confirms that users cannot resize database for restoring DBs. - * - Confirms that users cannot resize database for failed DBs. - * - Confirms that users cannot resize database for degraded DBs + * - Confirms that users cannot resize database for suspending DBs. + * - Confirms that users cannot resize database for suspended DBs. + * - Confirms that users cannot resize database for resuming DBs. */ - it('Cannot resize database clusters while they are not in active state', () => { - // const databaseStatus = ["provisioning", 'failed', 'restoring']; - possibleStatuses.forEach((dbstatus) => { - if (dbstatus != 'active') { + ['suspending', 'suspended', 'resuming'].forEach( + (dbstatus: DatabaseStatus) => { + it(`Cannot resize database clusters while they are in ${dbstatus} state`, () => { const initialLabel = configuration.label; const allowedIp = randomIp(); const database = databaseFactory.build({ @@ -302,6 +660,7 @@ describe('Resizing existing clusters', () => { secondary: undefined, }, id: randomNumber(1, 1000), + platform: 'rdbms-default', label: initialLabel, region: configuration.region.id, status: dbstatus, @@ -339,31 +698,38 @@ describe('Resizing existing clusters', () => { if (!databaseType) { throw new Error(`Unknown database type ${database.type}`); } - let nodeTypeClass = ''; - if (configuration.linodeType.includes('standard')) { - nodeTypeClass = 'standard'; - } else { + let nodeTypeClass = 'standard'; + if (configuration.linodeType.includes('premium')) { + nodeTypeClass = 'premium'; + } else if (configuration.linodeType.includes('dedicated')) { nodeTypeClass = 'dedicated'; } - const largePlan = mockDatabaseNodeTypes.filter( + const availPlans = mockDatabaseNodeTypes.filter( (nodeType) => nodeType.class === nodeTypeClass && - nodeType.memory > databaseType.memory + (nodeType.memory < databaseType.memory || + nodeType.memory > databaseType.memory) && + nodeType.disk / 1024 > (database.used_disk_size_gb ?? 0) ); if (!databaseType) { throw new Error(`Unknown database type ${database.type}`); } - cy.get(`[id="${largePlan[0].id}"]`).click(); - resizeDatabase(initialLabel); - cy.wait('@resizeDatabase'); - cy.findByText(errorMessage).should('be.visible'); - cy.get('[data-qa-cancel="true"]') - .should('be.visible') - .should('be.enabled') - .click(); - } - }); - }); + // Select a random plan from the available list to resize + if (availPlans.length > 0) { + const newPlan = + availPlans[Math.floor(Math.random() * availPlans.length)]; + cy.get(`[id="${newPlan.id}"]`).click(); + resizeDatabase(initialLabel); + cy.wait('@resizeDatabase'); + cy.findByText(errorMessage).should('be.visible'); + cy.get('[data-qa-cancel="true"]') + .should('be.visible') + .should('be.enabled') + .click(); + } + }); + } + ); }); } ); diff --git a/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts index 8382b0b5429..b420d385708 100644 --- 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 @@ -40,6 +40,7 @@ function checkActionMenu(tableAlias: string, mockImages: any[]) { cy.get('ul[role="menu"]') .find('li') .not(':contains("Deploy to New Linode")') + .not(':contains("Rebuild an Existing Linode")') .each(($li) => { cy.wrap($li).should('be.visible').and('be.disabled'); }); 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 8a4866a8d03..930cc1e7618 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 @@ -149,7 +149,9 @@ describe('create image (using mocks)', () => { }), ]; - const mockLinode = linodeFactory.build(); + const mockLinode = linodeFactory.build({ + id: 123, + }); mockGetProfile(mockProfile); mockGetProfileGrants(mockGrants); 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 3ecf54685fb..b81ff900f00 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -24,11 +24,15 @@ import { latestKubernetesVersion, mockedLKEClusterTypes, mockedLKEEnterprisePrices, + mockTieredEnterpriseVersions, + mockTieredStandardVersions, + mockTieredVersions, nanodeNodeCount, nanodeType, } from 'support/constants/lke'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockCreateCluster, @@ -48,11 +52,13 @@ import { mockGetRegions, } from 'support/intercepts/regions'; import { ui } from 'support/ui'; +import { lkeClusterCreatePage } from 'support/ui/pages'; import { randomItem, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion, extendRegion } from 'support/util/regions'; import { accountFactory, + firewallFactory, kubeLinodeFactory, kubernetesClusterFactory, kubernetesControlPlaneACLFactory, @@ -64,6 +70,7 @@ import { CLUSTER_VERSIONS_DOCS_LINK, } from 'src/features/Kubernetes/constants'; import { getTotalClusterMemoryCPUAndStorage } from 'src/features/Kubernetes/kubeUtils'; +import { extendType } from 'src/utilities/extendType'; import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; import type { PriceType } from '@linode/api-v4/lib/types'; @@ -121,11 +128,11 @@ describe('LKE Cluster Creation', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out mockAppendFeatureFlags({ - lkeEnterprise: { + lkeEnterprise2: { enabled: true, la: true, postLa: false, - phase2Mtc: true, + phase2Mtc: { byoVPC: true, dualStack: true }, }, }).as('getFeatureFlags'); }); @@ -399,7 +406,7 @@ describe('LKE Cluster Creation with APL enabled', () => { mockAppendFeatureFlags({ apl: true, aplGeneralAvailability: true, - lkeEnterprise: { enabled: true, la: true, postLa: false }, + lkeEnterprise2: { enabled: true, la: true, postLa: false }, }).as('getFeatureFlags'); mockCreateCluster(mockedLKECluster).as('createCluster'); mockGetCluster(mockedLKECluster).as('getCluster'); @@ -499,7 +506,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, postLa: false }, + lkeEnterprise2: { enabled: true, la: true, postLa: false }, }).as('getFeatureFlags'); }); @@ -624,7 +631,7 @@ describe('LKE Cluster Creation with ACL', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, postLa: false }, + lkeEnterprise2: { enabled: true, la: true, postLa: false }, }).as('getFeatureFlags'); }); @@ -1241,7 +1248,7 @@ describe('LKE Cluster Creation with LKE-E', () => { */ it('does not show the LKE-E flow with the feature flag off', () => { mockAppendFeatureFlags({ - lkeEnterprise: { enabled: false, la: false, postLa: false }, + lkeEnterprise2: { enabled: false, la: false, postLa: false }, }).as('getFeatureFlags'); cy.visitWithLogin('/kubernetes/clusters'); @@ -1260,7 +1267,7 @@ describe('LKE Cluster Creation with LKE-E', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, postLa: false }, + lkeEnterprise2: { enabled: true, la: true, postLa: false }, }).as('getFeatureFlags'); }); @@ -1587,6 +1594,261 @@ describe('LKE Cluster Creation with LKE-E', () => { }); }); +/* + * Tests for standard LKE create flow when the `lkeEnterprise2.postLa` feature flag is enabled. + * The main change introduced by this feature flag is a new flow when adding node pools: + * Node pool size is specified inside of a configuration drawer instead of directly in the plan table, + * and additional node pool options have been added exclusively for LKE Enterprise clusters. + */ +describe('LKE cluster creation with LKE-E Post-LA', () => { + const mockRegions = [ + ...regionFactory.buildList(3, { + capabilities: ['Linodes', 'Kubernetes'], + }), + ...regionFactory.buildList(3, { + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + }), + ]; + + const mockPlan = extendType(linodeTypeFactory.build({ class: 'standard' })); + const mockPlans = [ + mockPlan, + ...linodeTypeFactory + .buildList(10, { class: 'standard' }) + .map((plan) => extendType(plan)), + ]; + + beforeEach(() => { + // TODO M3-8838: Remove feature flag `lkeEnterprise2` mocks, remove redundant tests as-needed. + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ); + mockGetKubernetesVersions(mockTieredVersions.map((version) => version.id)); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); + mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions); + }); + + /* + * - Confirms that a user can create a standard LKE cluster when the LKE-E Post-LA feature is enabled. + * - Confirms that user can add and configure node pools via the Configure Node Pools drawer. + * - Confirms that LKE-E-specific node pool options are absent when configuring pools for standard clusters. + * - Confirms that outgoing cluster create API request contains the expected payload data. + * - Confirms that UI redirects to cluster details page upon successful cluster creation. + */ + it('can create standard LKE clusters with the LKE-E Post-LA feature enabled', () => { + const mockCluster = kubernetesClusterFactory.build({ + label: randomLabel(), + tier: 'standard', + region: chooseRegion({ + capabilities: ['Linodes', 'Kubernetes'], + regions: mockRegions, + }).id, + }); + + mockGetRegions(mockRegions); + mockGetLinodeTypes(mockPlans); + mockCreateCluster(mockCluster).as('createCluster'); + mockGetCluster(mockCluster); + + cy.visitWithLogin('/kubernetes/create'); + + // Configure a standard LKE cluster. + lkeClusterCreatePage.setLabel(mockCluster.label); + lkeClusterCreatePage.selectClusterTier('standard'); + lkeClusterCreatePage.selectRegionById(mockCluster.region, mockRegions); + lkeClusterCreatePage.setEnableApl(false); + lkeClusterCreatePage.setEnableHighAvailability(false); + + // Configure a node pool with the default pool size of 3. + // Additionally assert that LKE-E specific options are absent in the drawer, + // and that the order summary section updates to reflect the user's selection. + lkeClusterCreatePage.selectPlanTab('Shared CPU'); + lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel); + lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => { + cy.findByLabelText('Update Strategy').should('not.exist'); + cy.findByLabelText('Firewall').should('not.exist'); + + ui.button + .findByTitle('Add Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + lkeClusterCreatePage.withinOrderSummary(() => { + cy.contains(mockPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('3 Nodes').should('be.visible'); + cy.findByText('Edit Configuration').should('be.visible').click(); + }); + }); + + // Confirm that node pool size can be configured, and that the order summary + // UI updates upon clicking the "Update Pool" button. + lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => { + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.button + .findByTitle('Update Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + lkeClusterCreatePage.withinOrderSummary(() => { + cy.contains(mockPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('4 Nodes').should('be.visible'); + }); + + // Create the LKE cluster and confirm that the outgoing API request contains + // the expected payload data. + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster').then((xhr) => { + const body = xhr.request.body; + + // Validate outgoing `node_pools` request payload configuration. + expect(body['node_pools']).to.be.an('array'); + expect(body['node_pools']).to.have.length(1); + expect(body['node_pools'][0]!.type).to.equal(mockPlan.id); + expect(body['node_pools'][0]!.count).to.equal(4); + + // Validate that the rest of the payload matches the user's input. + expect(body['region']).to.equal(mockCluster.region); + expect(body['label']).to.equal(mockCluster.label); + expect(body['tier']).to.equal('standard'); + expect(body['apl_enabled']).to.be.false; + expect(body['control_plane']['acl']['enabled']).to.be.false; + expect(body['control_plane']['high_availability']).to.be.false; + }); + + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); + + /* + * - Confirms that regular LKE cluster creation works when a user initially configures an LKE-E cluster. + * - Configures an LKE-E cluster with LKE-E specific choices, then switches to a regular cluster before proceeding. + * - Confirms that outgoing cluster request respects user selection, cluster is created as expected. + */ + it('can switch to a standard cluster after configuring an LKE-E cluster with LKE-E Post-LA feature enabled', () => { + const mockCluster = kubernetesClusterFactory.build({ + label: randomLabel(), + tier: 'standard', + region: chooseRegion({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + regions: mockRegions, + }).id, + }); + + const mockFirewall = firewallFactory.build(); + + mockGetRegions(mockRegions); + mockGetFirewalls([mockFirewall]); + mockGetLinodeTypes(mockPlans); + mockCreateCluster(mockCluster).as('createCluster'); + mockGetCluster(mockCluster); + + cy.visitWithLogin('/kubernetes/create'); + + lkeClusterCreatePage.setLabel(mockCluster.label); + lkeClusterCreatePage.selectClusterTier('enterprise'); + lkeClusterCreatePage.selectRegionById(mockCluster.region, mockRegions); + lkeClusterCreatePage.selectPlanTab('Shared CPU'); + + // Confirm that order summary updates to reflect that Enterprise tier is selected, + // then configure a node pool. + lkeClusterCreatePage.withinOrderSummary(() => { + cy.findByText('LKE Enterprise').should('be.visible'); + }); + + lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel); + lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => { + // Confirm that LKE-E specific options are present. + + // Set "Update Strategy" to "Rolling Updates". + cy.findByText('Update Strategy').should('be.visible').click(); + cy.focused().type('Rolling Updates'); + ui.autocompletePopper.findByTitle('Rolling Updates').click(); + + // Select the existing mock firewall. + cy.findByText('Select existing firewall').click(); + cy.get('[aria-label="Firewall"]').type(mockFirewall.label); + ui.autocompletePopper.findByTitle(mockFirewall.label).click(); + + ui.button + .findByTitle('Add Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Now switch to a standard LKE cluster, assert the state of the UI and + // outgoing API request after the user makes this switch. + lkeClusterCreatePage.selectClusterTier('standard'); + lkeClusterCreatePage.setEnableApl(false); + lkeClusterCreatePage.setEnableHighAvailability(true); + + lkeClusterCreatePage.withinOrderSummary(() => { + cy.findByText('LKE Enterprise').should('not.exist'); + + // Create the LKE cluster and assert that the outgoing API request contains + // the expected payload data. + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster').then((xhr) => { + const body = xhr.request.body; + expect(body['label']).to.equal(mockCluster.label); + expect(body['region']).to.equal(mockCluster.region); + expect(body['tier']).to.equal('standard'); + expect(body['control_plane']['acl']['enabled']).to.be.false; + expect(body['control_plane']['high_availability']).to.be.true; + expect(body['apl_enabled']).to.be.false; + + const nodePools = body['node_pools']; + expect(nodePools).to.be.an('array'); + expect(nodePools).to.have.length(1); + expect(nodePools[0]).to.be.an('object'); + expect(nodePools[0]['type']).to.equal(mockPlan.id); + expect(nodePools[0]['count']).to.equal(3); + + // TODO M3-10590 - Uncomment and adjust according to chosen resolution. + // expect(nodePools[0]['update_strategy']).to.be.undefined; + // expect(nodePools[0]['firewall_id']).to.be.undefined; + }); + + cy.url().should('endWith', `kubernetes/clusters/${mockCluster.id}/summary`); + }); +}); + /** * Returns each plan in an array which is similar to the given plan. * diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts index 78dc2c33408..f4a4737a056 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -2,20 +2,25 @@ * Confirms create operations on LKE-Enterprise clusters. */ -import { regionFactory } from '@linode/utilities'; +import { linodeTypeFactory, regionFactory } from '@linode/utilities'; import { clusterPlans, latestEnterpriseTierKubernetesVersion, latestKubernetesVersion, mockedLKEClusterTypes, mockedLKEEnterprisePrices, + mockTieredEnterpriseVersions, + mockTieredStandardVersions, + mockTieredVersions, } from 'support/constants/lke'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockCreateCluster, mockCreateClusterError, + mockGetCluster, mockGetKubernetesVersions, mockGetLKEClusterTypes, mockGetTieredKubernetesVersions, @@ -23,14 +28,18 @@ import { import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; +import { lkeClusterCreatePage } from 'support/ui/pages'; import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; import { accountFactory, + firewallFactory, kubernetesClusterFactory, subnetFactory, vpcFactory, } from 'src/factories'; +import { extendType } from 'src/utilities/extendType'; const clusterLabel = randomLabel(); const selectedVpcId = 1; @@ -59,20 +68,39 @@ const mockVpcs = [ }, ]; +const mockDualStackVPCRegion = regionFactory.build({ + capabilities: [ + 'Linodes', + 'Kubernetes', + 'Kubernetes Enterprise', + 'VPCs', + 'VPC Dual Stack', + ], + id: 'us-iad', + label: 'Washington, DC', +}); +const mockNoDualStackVPCRegion = regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'], +}); + describe('LKE Cluster Creation with LKE-E', () => { beforeEach(() => { // TODO LKE-E: Remove feature flag mocks once we're in GA mockAppendFeatureFlags({ - lkeEnterprise: { + lkeEnterprise2: { enabled: true, la: true, postLa: false, - phase2Mtc: true, + phase2Mtc: { byoVPC: true, dualStack: true }, }, }).as('getFeatureFlags'); mockGetAccount( accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], + capabilities: [ + 'Kubernetes Enterprise', + 'Kubernetes Enterprise BYO VPC', + 'Kubernetes Enterprise Dual Stack', + ], }) ).as('getAccount'); @@ -89,19 +117,9 @@ describe('LKE Cluster Creation with LKE-E', () => { ); mockCreateCluster(mockEnterpriseCluster).as('createCluster'); - mockGetRegions([ - regionFactory.build({ - capabilities: [ - 'Linodes', - 'Kubernetes', - 'Kubernetes Enterprise', - 'VPCs', - ], - id: 'us-iad', - label: 'Washington, DC', - }), - ]).as('getRegions'); - + mockGetRegions([mockNoDualStackVPCRegion, mockDualStackVPCRegion]).as( + 'getRegions' + ); mockGetVPCs(mockVpcs).as('getVPCs'); cy.visitWithLogin('/kubernetes/clusters'); @@ -395,5 +413,364 @@ describe('LKE Cluster Creation with LKE-E', () => { cy.findByLabelText('Use an existing VPC').click(); cy.findByText(errorText).should('not.exist'); }); + + it('disables the dual stack IP Stack option if the region capability is not present', () => { + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + + // Before region selection, confirm both IP Stack options are enabled initially and the default is selected. + cy.findByLabelText('IPv4').should('be.checked'); + cy.findByLabelText('IPv4 + IPv6 (dual-stack)').should('be.enabled'); + + ui.regionSelect + .find() + .clear() + .type(`${mockDualStackVPCRegion.label}{enter}`); + + // Confirm the dual stack option is available for a region with VPC IPv6. + cy.findByLabelText('IPv4 + IPv6 (dual-stack)') + .should('be.enabled') + .click(); + + // Change the region. + ui.regionSelect + .find() + .clear() + .type(`${mockNoDualStackVPCRegion.label}{enter}`); + + // Confirm the dual stack option is disabled and default is reset after the region changes to a non-VPC IPv6 capable region. + cy.findByLabelText('IPv4 + IPv6 (dual-stack)').should('be.disabled'); + cy.findByLabelText('IPv4').should('be.checked'); + }); + }); +}); + +/* + * Tests for the LKE-E create flow when the `lkeEnterprise2.postLa` feature flag is enabled. + * The main change introduced by this feature flag is a new flow when adding node pools: + * Node pool size is specified inside of a configuration drawer instead of directly in the plan table, + * and additional node pool options have been added exclusively for LKE Enterprise clusters. + */ +describe('LKE Enterprise cluster creation with LKE-E Post-LA', () => { + const mockRegionsNoEnterprise = regionFactory.buildList(3, { + capabilities: ['Linodes', 'Kubernetes'], + }); + + const mockRegions = [ + ...mockRegionsNoEnterprise, + ...regionFactory.buildList(3, { + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + }), + ]; + + const mockStandardPlan = extendType( + linodeTypeFactory.build({ + class: 'standard', + }) + ); + + const mockPlan = extendType( + linodeTypeFactory.build({ + class: 'dedicated', + }) + ); + + const mockPlans = [ + mockStandardPlan, + mockPlan, + ...linodeTypeFactory + .buildList(10, { + class: 'dedicated', + }) + .map((plan) => extendType(plan)), + ]; + + beforeEach(() => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ); + mockGetKubernetesVersions(mockTieredVersions.map((version) => version.id)); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); + mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions); + }); + + /* + * - Confirms that a user can create an Enterprise LKE cluster when the LKE-E Post-LA feature is enabled. + * - Confirms that user can add and configure node pools via the Configure Node Pools drawer. + * - Confirms that a user can create a cluster with more than 1 node pool. + * - Confirms that outgoing cluster create API request contains the expected payload data. + * - Confirms that UI redirects to cluster details page upon successful cluster creation. + */ + it('can create LKE-E clusters with the LKE-E Post-LA feature flag enabled', () => { + const mockCluster = kubernetesClusterFactory.build({ + label: randomLabel(), + tier: 'enterprise', + region: chooseRegion({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + regions: mockRegions, + }).id, + }); + + const mockSecondPlan = extendType( + linodeTypeFactory.build({ + class: 'dedicated', + }) + ); + + const mockFirewall = firewallFactory.build(); + + mockGetRegions(mockRegions); + mockGetFirewalls([mockFirewall]); + mockGetLinodeTypes([...mockPlans, mockSecondPlan]); + mockCreateCluster(mockCluster).as('createCluster'); + mockGetCluster(mockCluster); + + cy.visitWithLogin('/kubernetes/create'); + + // Configure an Enterprise LKE cluster. + lkeClusterCreatePage.setLabel(mockCluster.label); + lkeClusterCreatePage.selectClusterTier('enterprise'); + lkeClusterCreatePage.selectRegionById(mockCluster.region, mockRegions); + lkeClusterCreatePage.setEnableBypassAcl(true); + lkeClusterCreatePage.selectPlanTab('Dedicated CPU'); + lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel); + + // Create a node pool with the default options set, then confirm the order + // summary UI updates to reflect node pool selection. + lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => { + cy.findByText('Update Strategy').should('be.visible'); + cy.findByText('Firewall').should('be.visible'); + ui.button + .findByTitle('Add Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + lkeClusterCreatePage.withinOrderSummary(() => { + cy.findByText('LKE Enterprise').should('be.visible'); + cy.contains(mockPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('3 Nodes').should('be.visible'); + cy.findByText('Edit Configuration').should('be.visible').click(); + }); + }); + + // Update node pool size and update strategy, then add a new node pool. + lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => { + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText('Update Strategy').should('be.visible').click(); + cy.focused().type('Rolling Updates'); + ui.autocompletePopper.findByTitle('Rolling Updates').click(); + + ui.button + .findByTitle('Update Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + lkeClusterCreatePage.selectNodePoolPlan(mockSecondPlan.formattedLabel); + lkeClusterCreatePage.withinNodePoolDrawer( + mockSecondPlan.formattedLabel, + () => { + cy.findByText('Select existing firewall').click(); + cy.get('[aria-label="Firewall"]').type(mockFirewall.label); + ui.autocompletePopper.findByTitle(mockFirewall.label).click(); + + ui.button + .findByTitle('Add Pool') + .should('be.visible') + .should('be.enabled') + .click(); + } + ); + + // Confirm that both node pools are listed with the expected node pool + // count shown, then click "Create Cluster". + lkeClusterCreatePage.withinOrderSummary(() => { + cy.contains(mockPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('4 Nodes').should('be.visible'); + }); + + cy.contains(mockSecondPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('3 Nodes').should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster').then((xhr) => { + const body = xhr.request.body; + expect(body['label']).to.equal(mockCluster.label); + expect(body['region']).to.equal(mockCluster.region); + expect(body['tier']).to.equal('enterprise'); + expect(body['control_plane']['acl']['enabled']).to.be.true; + expect(body['control_plane']['high_availability']).to.be.true; + + // Confirm node pool payload is expected: + // - Both node pools are included in the request + // - Specified configuration is honored for each node pool + expect(body['node_pools']).to.be.an('array'); + expect(body['node_pools']).to.have.length(2); + + expect(body['node_pools'][0]['type']).to.equal(mockPlan.id); + expect(body['node_pools'][0]['count']).to.equal(4); + expect(body['node_pools'][0]['update_strategy']).to.equal( + 'rolling_update' + ); + expect(body['node_pools'][0]['firewall_id']).to.be.undefined; + + expect(body['node_pools'][1]['type']).to.equal(mockSecondPlan.id); + expect(body['node_pools'][1]['count']).to.equal(3); + expect(body['node_pools'][1]['update_strategy']).to.equal('on_recycle'); + expect(body['node_pools'][1]['firewall_id']).to.equal(mockFirewall.id); + }); + + cy.url().should('endWith', `kubernetes/clusters/${mockCluster.id}/summary`); + }); + + /* + * - Confirms that Enterprise LKE cluster creation works when a user initially configures an LKE-E cluster. + * - Configures a standard LKE cluster, then switches to an Enterprise cluster before proceeding. + * - Confirms that UI reacts to switch as expected, and invalid configurations (e.g. region) are reset. + * - Confirms that outgoing cluster request respects user selection, and cluster is created as expected. + */ + it('can switch to an Enterprise cluster after configuring a standard LKE cluster with the LKE-E Post-LA feature enabled', () => { + const mockFirstRegion = chooseRegion({ regions: mockRegionsNoEnterprise }); + const mockRegion = chooseRegion({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + regions: mockRegions, + }); + const mockCluster = kubernetesClusterFactory.build({ + label: randomLabel(), + tier: 'enterprise', + region: mockRegion.id, + }); + + mockGetRegions(mockRegions); + mockGetLinodeTypes(mockPlans); + mockCreateCluster(mockCluster).as('createCluster'); + mockGetCluster(mockCluster); + + cy.visitWithLogin('/kubernetes/create'); + + lkeClusterCreatePage.setLabel(mockCluster.label); + lkeClusterCreatePage.selectClusterTier('standard'); + lkeClusterCreatePage.selectPlanTab('Shared CPU'); + lkeClusterCreatePage.setEnableApl(false); + + // Initially select a region and HA control plane that + // selection which isn't supported under LKE-E. + lkeClusterCreatePage.selectRegionById(mockFirstRegion.id, mockRegions); + lkeClusterCreatePage.setEnableHighAvailability(false); + + // Change selection from a standard cluster to an Enterprise LKE cluster, + // confirm that region selection has been reset, then switch back to standard + // and choose a new region which supports LKE-E. + lkeClusterCreatePage.selectClusterTier('enterprise'); + cy.findByLabelText('Region').should('have.value', ''); + + lkeClusterCreatePage.selectClusterTier('standard'); + lkeClusterCreatePage.selectRegionById(mockCluster.region, mockRegions); + + // Add a node pool. + lkeClusterCreatePage.selectNodePoolPlan(mockStandardPlan.formattedLabel); + lkeClusterCreatePage.withinNodePoolDrawer( + mockStandardPlan.formattedLabel, + () => { + ui.button + .findByTitle('Add Pool') + .should('be.visible') + .should('be.enabled') + .click(); + } + ); + + lkeClusterCreatePage.withinOrderSummary(() => { + cy.contains(mockStandardPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('3 Nodes').should('be.visible'); + }); + }); + + // Switch back to LKE-E, assert that region selection persists in this case. + lkeClusterCreatePage.selectClusterTier('enterprise'); + cy.findByLabelText('Region').should( + 'have.value', + `${mockRegion.label} (${mockRegion.id})` + ); + + lkeClusterCreatePage.withinOrderSummary(() => { + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that a UI error appears because ACL IPs haven't been specified. + // Bypass ACL IP selection, and proceed with creation. + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('be.visible'); + + lkeClusterCreatePage.setEnableBypassAcl(true); + lkeClusterCreatePage.withinOrderSummary(() => { + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster').then((xhr) => { + const body = xhr.request.body; + expect(body['label']).to.equal(mockCluster.label); + expect(body['region']).to.equal(mockRegion.id); + expect(body['tier']).to.equal('enterprise'); + expect(body['control_plane']['acl']['enabled']).to.be.true; + expect(body['control_plane']['high_availability']).to.be.true; + + // Assert node pool configuration is as expected. + // `firewall_id` and `update_strategy` are `undefined` because the node + // pool was configured while the standard tier was selected. However, + // this is still a valid request and the API uses default values in this + // case. + expect(body['node_pools']).to.be.an('array'); + expect(body['node_pools']).to.have.length(1); + expect(body['node_pools'][0]['type']).to.equal(mockStandardPlan.id); + expect(body['node_pools'][0]['count']).to.equal(3); + expect(body['node_pools'][0]['firewall_id']).to.be.undefined; + expect(body['node_pools'][0]['update_strategy']).to.be.undefined; + }); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); }); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts index b5724cab3e7..a331de55b59 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts @@ -118,11 +118,18 @@ describe('LKE-E Cluster Summary - VPC Section', () => { beforeEach(() => { // TODO LKE-E: Remove once feature is in GA mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + }, }); mockGetAccount( accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], + capabilities: [ + 'Kubernetes Enterprise', + 'Kubernetes Enterprise BYO VPC', + ], }) ).as('getAccount'); }); @@ -195,11 +202,18 @@ describe('LKE-E Node Pools', () => { it('shows VPC IPv4 and IPv6 columns for an LKE-E cluster', () => { mockAppendFeatureFlags({ // TODO LKE-E: Remove once feature is in GA - lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + }, }); mockGetAccount( accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], + capabilities: [ + 'Kubernetes Enterprise', + 'Kubernetes Enterprise Dual Stack', + ], }) ).as('getAccount'); @@ -208,7 +222,6 @@ describe('LKE-E Node Pools', () => { mockGetClusterPools(mockClusterWithVPC.id, mockNodePools).as( 'getNodePools' ); - mockGetVPC(mockVPC).as('getVPC'); mockGetProfile(mockProfile).as('getProfile'); mockGetLinodes(mockLinodes).as('getLinodes'); mockGetLinodeIPAddresses(mockLinodes[0].id, mockLinodeIPs).as( @@ -216,13 +229,7 @@ describe('LKE-E Node Pools', () => { ); cy.visitWithLogin(`/kubernetes/clusters/${mockClusterWithVPC.id}/summary`); - cy.wait([ - '@getCluster', - '@getNodePools', - '@getVersions', - '@getProfile', - '@getVPC', - ]); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); // Confirm VPC IP columns are present in the table header cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { 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 237c2ae472a..97b14b8b6f5 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 @@ -218,7 +218,7 @@ describe('LKE landing page', () => { // TODO LKE-E: Remove once feature is in GA mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true }, + lkeEnterprise2: { enabled: true, la: true }, }); const cluster = kubernetesClusterFactory.build({ @@ -311,7 +311,7 @@ describe('LKE landing page', () => { // TODO LKE-E: Remove once feature is in GA mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true }, + lkeEnterprise2: { enabled: true, la: true }, }); const cluster = kubernetesClusterFactory.build({ diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts index 07c8162b686..0791247057c 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts @@ -52,7 +52,11 @@ describe('LKE Cluster Summary', () => { it('does not show linked VPC in summary for a standard cluster', () => { // TODO LKE-E: Remove once feature is in GA mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: false }, + }, }); mockGetAccount( accountFactory.build({ @@ -86,7 +90,11 @@ describe('LKE Node Pools', () => { it('does not show VPC IP columns for standard LKE cluster', () => { // TODO LKE-E: Remove once feature is in GA mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: false }, + }, }); mockGetAccount( accountFactory.build({ 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 f1f59828e54..3720db12603 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -274,7 +274,7 @@ describe('LKE cluster updates', () => { // TODO LKE-E: Remove once feature is in GA mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true }, + lkeEnterprise2: { enabled: true, la: true }, }); const mockCluster = kubernetesClusterFactory.build({ @@ -535,7 +535,7 @@ describe('LKE cluster updates', () => { ).as('getAccount'); // TODO LKE-E: Remove once feature is in GA mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true }, + lkeEnterprise2: { enabled: true, la: true }, }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); @@ -687,7 +687,7 @@ describe('LKE cluster updates', () => { ).as('getAccount'); // TODO LKE-E: Remove once feature is in GA mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true }, + lkeEnterprise2: { enabled: true, la: true }, }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); @@ -994,7 +994,7 @@ describe('LKE cluster updates', () => { }); mockAppendFeatureFlags({ - lkeEnterprise: { + lkeEnterprise2: { enabled: true, postLa: true, }, diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts index 01cc1e7ab25..86f121919f5 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts @@ -57,25 +57,29 @@ const mockRegions = [ ]; /** - * - Confirms VPC and IP Stack selections are shown with `phase2Mtc` feature flag is enabled. - * - Confirms VPC and IP Stack selections are not shown in create flow with `phase2Mtc` feature flag is disabled. + * - Confirms VPC and IP Stack selections are shown with the respective `phase2Mtc` feature flags enabled. + * - Confirms VPC and IP Stack selections are not shown in create flow with their respective `phase2Mtc` feature flags disabled. */ describe('LKE-E Cluster Create', () => { beforeEach(() => { mockGetAccount( accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], + capabilities: [ + 'Kubernetes Enterprise', + 'Kubernetes Enterprise BYO VPC', + 'Kubernetes Enterprise Dual Stack', + ], }) ).as('getAccount'); }); - it('Simple Page Check - Phase 2 MTC Flag ON', () => { + it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => { mockAppendFeatureFlags({ - lkeEnterprise: { + lkeEnterprise2: { enabled: true, la: true, postLa: false, - phase2Mtc: true, + phase2Mtc: { byoVPC: true, dualStack: false }, }, }).as('getFeatureFlags'); @@ -101,7 +105,151 @@ describe('LKE-E Cluster Create', () => { .should('be.visible') .click(); - // Confirms LKE-E Phase 2 IP Stack and VPC options display with the flag ON. + // Confirms LKE-E Phase 2 VPC options do not display with the Dual Stack flag OFF. + cy.findByText('IP Stack').should('not.exist'); + cy.findByText('IPv4', { exact: true }).should('not.exist'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); + + // Confirms LKE-E Phase 2 VPC options display with the BYO VPC flag ON. + cy.findByText('Automatically generate a VPC for this cluster').should( + 'be.visible' + ); + cy.findByText('Use an existing VPC').should('be.visible'); + + cy.findByText('Shared CPU').should('be.visible').click(); + addNodes('Linode 2 GB'); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText('Linode 2 GB Plan').should('be.visible'); + cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); + + it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: { byoVPC: false, dualStack: true }, + }, + }).as('getFeatureFlags'); + + mockCreateCluster(mockCluster).as('createCluster'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetRegions(mockRegions); + + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); + + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); + + cy.findByText('LKE Enterprise').click(); + + ui.regionSelect.find().click().type(`${mockRegions[0].label}`); + ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); + + // Confirms LKE-E Phase 2 IP Stack displays with the Dual Stack flag ON. + cy.findByText('IP Stack').should('be.visible'); + cy.findByText('IPv4', { exact: true }).should('be.visible'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible'); + + // Confirms LKE-E Phase 2 VPC options do not display with the BYO VPC flag OFF. + cy.findByText('Automatically generate a VPC for this cluster').should( + 'not.exist' + ); + cy.findByText('Use an existing VPC').should('not.exist'); + + cy.findByText('Shared CPU').should('be.visible').click(); + addNodes('Linode 2 GB'); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText('Linode 2 GB Plan').should('be.visible'); + cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); + + it('Simple Page Check - Phase 2 MTC Flags Both ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: { byoVPC: true, dualStack: true }, + }, + }).as('getFeatureFlags'); + + mockCreateCluster(mockCluster).as('createCluster'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetRegions(mockRegions); + + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); + + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); + + cy.findByText('LKE Enterprise').click(); + + ui.regionSelect.find().click().type(`${mockRegions[0].label}`); + ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); + + // Confirms LKE-E Phase 2 IP Stack and VPC options display with both flags ON. cy.findByText('IP Stack').should('be.visible'); cy.findByText('IPv4', { exact: true }).should('be.visible'); cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible'); @@ -139,13 +287,13 @@ describe('LKE-E Cluster Create', () => { ); }); - it('Simple Page Check - Phase 2 MTC Flag OFF', () => { + it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => { mockAppendFeatureFlags({ - lkeEnterprise: { + lkeEnterprise2: { enabled: true, la: true, postLa: false, - phase2Mtc: false, + phase2Mtc: { byoVPC: false, dualStack: false }, }, }).as('getFeatureFlags'); @@ -171,7 +319,7 @@ describe('LKE-E Cluster Create', () => { .should('be.visible') .click(); - // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with the flag OFF. + // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with both flags OFF. cy.findByText('IP Stack').should('not.exist'); cy.findByText('IPv4', { exact: true }).should('not.exist'); cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); @@ -218,14 +366,84 @@ describe('LKE-E Cluster Read', () => { beforeEach(() => { mockGetAccount( accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], + capabilities: [ + 'Kubernetes Enterprise', + 'Kubernetes Enterprise BYO VPC', + 'Kubernetes Enterprise Dual Stack', + ], }) ).as('getAccount'); }); - it('Simple Page Check - Phase 2 MTC Flag ON', () => { + it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: false }, + }, + }).as('getFeatureFlags'); + + mockGetClusters([mockCluster]).as('getClusters'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockGetVPC(mockVPC).as('getVPC'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getVPC', '@getNodePools']); + + // Confirm linked VPC is present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('exist'); + cy.findByTestId('assigned-lke-cluster-label').should( + 'contain.text', + mockVPC.label + ); + }); + + // Confirm VPC IP columns are not present in the node table header + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('not.exist'); + cy.contains('th', 'VPC IPv6').should('not.exist'); + }); + }); + + it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => { mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: false, dualStack: true }, + }, + }).as('getFeatureFlags'); + + mockGetClusters([mockCluster]).as('getClusters'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools']); + + // Confirm linked VPC is not present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); + + // Confirm VPC IP columns are present in the node table header + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('be.visible'); + cy.contains('th', 'VPC IPv6').should('be.visible'); + }); + }); + + it('Simple Page Check - Phase 2 MTC Flags Both ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + }, }).as('getFeatureFlags'); mockGetClusters([mockCluster]).as('getClusters'); @@ -252,9 +470,13 @@ describe('LKE-E Cluster Read', () => { }); }); - it('Simple Page Check - Phase 2 MTC Flag OFF', () => { + it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => { mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, phase2Mtc: false }, + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + }, }).as('getFeatureFlags'); mockGetClusters([mockCluster]).as('getClusters'); diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts index 2dc7c4c1cd3..0e644e806a2 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts @@ -13,7 +13,7 @@ import { mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { addNodes } from 'support/util/lke'; +import { lkeClusterCreatePage } from 'support/ui/pages/lke-cluster-create-page'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -21,7 +21,12 @@ describe('LKE Create Cluster', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, postLa: false }, + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: true, + }, }).as('getFeatureFlags'); }); @@ -32,35 +37,60 @@ describe('LKE Create Cluster', () => { }); mockCreateCluster(mockCluster).as('createCluster'); cy.visitWithLogin('/kubernetes/create'); - cy.findByText('Add Node Pools').should('be.visible'); - - cy.findByLabelText('Cluster Label').click(); - cy.focused().type(mockCluster.label); + const mockPlanName = 'Linode 2 GB'; const lkeRegion = chooseRegion({ capabilities: ['Kubernetes'], }); - ui.regionSelect.find().click().type(`${lkeRegion.label}`); - ui.regionSelect.findItemByRegionId(lkeRegion.id).click(); + // Configure an LKE cluster. + lkeClusterCreatePage.setLabel(mockCluster.label); + lkeClusterCreatePage.selectRegionById(lkeRegion.id); + lkeClusterCreatePage.setEnableHighAvailability(true); + lkeClusterCreatePage.selectPlanTab('Shared CPU'); + lkeClusterCreatePage.selectNodePoolPlan(mockPlanName); - cy.findByLabelText('Kubernetes Version').should('be.visible').click(); - cy.findByText('1.32').should('be.visible').click(); - - cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); + // Create a node pool then confirm the order summary UI updates to reflect node pool selection. + cy.findByText('Add Node Pools').should('be.visible'); - cy.findByText('Shared CPU').should('be.visible').click(); - addNodes('Linode 2 GB'); + lkeClusterCreatePage.withinNodePoolDrawer(mockPlanName, () => { + ui.button + .findByTitle('Add Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); - // Confirm change is reflected in checkout bar. - cy.get('[data-testid="kube-checkout-bar"]').within(() => { - cy.findByText('Linode 2 GB Plan').should('be.visible'); - cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + lkeClusterCreatePage.withinOrderSummary(() => { + cy.contains(mockPlanName) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('Linode 2 GB Plan').should('be.visible'); + cy.findByTitle('Remove Linode 2GB Node Pool').should('exist'); + cy.findByText('3 Nodes').should('be.visible'); + cy.findByText('Edit Configuration').should('be.visible').click(); + }); + }); + // Update the node pool's count and confirm warning exists in drawer. + lkeClusterCreatePage.withinNodePoolDrawer(mockPlanName, () => { + cy.get('[data-testid="decrement-button"]').click(); + cy.get('[data-testid="decrement-button"]').click(); cy.get('[data-qa-notice="true"]').within(() => { - cy.findByText(minimumNodeNotice).should('be.visible'); + cy.findByText(minimumNodeNotice).should('exist'); }); + ui.button + .findByTitle('Update Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm warning exists in checkout and submit. + lkeClusterCreatePage.withinOrderSummary(() => { + cy.findByText(minimumNodeNotice).should('exist'); + ui.button .findByTitle('Create Cluster') .should('be.visible') 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 8cfa98bd68e..ae8f63ed9b4 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 @@ -311,7 +311,7 @@ describe('Create Linode with VPCs (Legacy)', () => { .click(); // Check box to assign public IPv4. - cy.findByText('Assign a public IPv4 address for this Linode') + cy.findByText('Allow public IPv4 access (1:1 NAT)') .should('be.visible') .click(); @@ -548,7 +548,7 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { * - 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 (Linode Inteface)', () => { + it('can assign existing VPCs during Linode Create flow (Linode Interface)', () => { const mockSubnet = subnetFactory.build({ id: randomNumber(), ipv4: `${randomIp()}/0`, @@ -820,7 +820,7 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { .click(); // Check box to assign public IPv4. - cy.findByText('Assign a public IPv4 address for this Linode') + cy.findByText('Allow public IPv4 access (1:1 NAT)') .should('be.visible') .click(); @@ -1005,7 +1005,7 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { .click(); // Check box to assign public IPv4. - cy.findByText('Assign a public IPv4 address for this Linode') + cy.findByText('Allow public IPv4 access (1:1 NAT)') .should('be.visible') .click(); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 30f8bf3f87a..6d5cd643b6a 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -727,7 +727,7 @@ describe('Linode Config management', () => { /* * - Tests Linode config edit and VPC interface assignment UI flows using mock API data. * - When the user sets primary interface to eth0, sets eth0 to "Public Internet", and sets eth1 to "VPC", confirm that correct notice appears. - * - When the user sets primary interface to eth0, sets eth0 to "Public Internet", sets eth1 to "VPC", and checks "Assign a public IPv4 address for this Linode", confirm that correct notice appears. + * - When the user sets primary interface to eth0, sets eth0 to "Public Internet", sets eth1 to "VPC", and checks "Allow public IPv4 access (1:1 NAT)", confirm that correct notice appears. * - Confirms that "REBOOT NEEDED" status indicator appears upon creating VPC config. */ it('Creates a new config using non-recommended settings and confirm the informational notices', () => { @@ -823,7 +823,7 @@ describe('Linode Config management', () => { cy.findByText(LINODE_UNREACHABLE_HELPER_TEXT).should('be.visible'); // Sets eth0 to "Public Internet", and sets eth1 to "VPC", - // and checks "Assign a public IPv4 address for this Linode" + // and checks "Allow public IPv4 access (1:1 NAT)" cy.get('[data-qa-textfield-label="VPC"]').scrollIntoView(); cy.get('[data-qa-textfield-label="VPC"]').click(); cy.focused().type(`${mockVPC.label}`); @@ -840,7 +840,7 @@ describe('Linode Config management', () => { .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) .should('be.visible') .click(); - cy.findByText('Assign a public IPv4 address for this Linode') + cy.findByText('Allow public IPv4 access (1:1 NAT)') .should('be.visible') .click(); // Confirm that internet access warning is displayed. diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index b125410b982..8b47a8bb2ed 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -285,8 +285,6 @@ describe('displays kubernetes plans panel based on availability', () => { 'disabled' ); cy.get('[data-qa-plan-row="dedicated-3"]').within(() => { - cy.get('[data-testid="decrement-button"]').should('be.disabled'); - cy.get('[data-testid="increment-button"]').should('be.disabled'); cy.get('[data-testid="button"]') .should( 'have.attr', @@ -378,7 +376,6 @@ describe('displays specific linode plans for GPU', () => { mockAppendFeatureFlags({ gpuv2: { egressBanner: true, - planDivider: true, transferBanner: true, }, }).as('getFeatureFlags'); @@ -428,7 +425,6 @@ describe('displays specific kubernetes plans for GPU', () => { mockAppendFeatureFlags({ gpuv2: { egressBanner: true, - planDivider: true, transferBanner: true, }, }).as('getFeatureFlags'); 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 265617b56e1..9946cf8dfde 100644 --- a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts @@ -17,14 +17,9 @@ describe('Search Linodes', () => { */ 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 }, { waitForBoot: true }) + createTestLinode({ booted: false }, { waitForBoot: false }) ).then((linode: Linode) => { - cy.visitWithLogin('/linodes'); - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.contains('Running').should('be.visible'); - }); + cy.visitWithLogin('/linodes?order=desc&orderBy=created'); // Confirm that linode is listed on the landing page. cy.findByText(linode.label).should('be.visible'); @@ -39,7 +34,24 @@ describe('Search Linodes', () => { // Use the main search bar to search and filter linode by id: pattern ui.mainSearch.find().clear().type(`id:${linode.id}`); + + // Verify the Linode shows as an option in the main search autocomplete ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); + + // Press Enter to go to the search page + cy.focused().type('{enter}'); + + // Verify the search field still has the correct search query that the user typed + ui.mainSearch + .find() + .findByRole('combobox') + .should('have.value', `id:${linode.id}`); + + // Verify we land on the search page + cy.url().should('endWith', `/search?query=id%3A${linode.id}`); + cy.findByText(`Search Results for "id:${linode.id}"`).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 88ab9e12e04..4b26fa9b248 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 @@ -8,7 +8,7 @@ import type { Linode } from '@linode/api-v4'; authenticate(); describe('switch linode state', () => { - before(() => { + beforeEach(() => { cleanUp(['linodes']); cy.tag('method:e2e'); }); @@ -19,7 +19,8 @@ describe('switch linode state', () => { * - Confirms that landing page UI updates to reflect Linode power state. * - Does not wait for Linode to finish being shut down before succeeding. */ - it('powers off a linode from landing page', () => { + it.skip('powers off a linode from landing page', () => { + // TODO M3-10588 - Unskip landing page power off test, evaluate stability. // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is @@ -28,8 +29,11 @@ describe('switch linode state', () => { createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); - cy.get(`[data-qa-linode="${linode.label}"]`) + + cy.findByText(linode.label).scrollIntoView(); + cy.findByText(linode.label) .should('be.visible') + .closest('tr') .within(() => { cy.contains('Running', { timeout: LINODE_CREATE_TIMEOUT }).should( 'be.visible' @@ -54,8 +58,9 @@ describe('switch linode state', () => { .click(); }); - cy.get(`[data-qa-linode="${linode.label}"]`) + cy.findByText(linode.label) .should('be.visible') + .closest('tr') .within(() => { cy.contains('Shutting Down').should('be.visible'); }); @@ -110,12 +115,15 @@ describe('switch linode state', () => { * - Confirms that landing page UI updates to reflect Linode power state. * - Waits for Linode to finish booting up before succeeding. */ - it('powers on a linode from landing page', () => { + it.skip('powers on a linode from landing page', () => { + // TODO M3-10588 - Unskip landing page power on test, evaluate stability. cy.defer(() => createTestLinode({ booted: false })).then( (linode: Linode) => { cy.visitWithLogin('/linodes'); - cy.get(`[data-qa-linode="${linode.label}"]`) + cy.findByText(linode.label).scrollIntoView(); + cy.findByText(linode.label) .should('be.visible') + .closest('tr') .within(() => { cy.contains('Offline', { timeout: LINODE_CREATE_TIMEOUT }).should( 'be.visible' @@ -140,10 +148,14 @@ describe('switch linode state', () => { .click(); }); - cy.get(`[data-qa-linode="${linode.label}"]`) + cy.findByText(linode.label).scrollIntoView(); + cy.findByText(linode.label) .should('be.visible') + .closest('tr') .within(() => { - cy.contains('Booting').should('be.visible'); + cy.contains('Booting', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); cy.contains('Running', { timeout: LINODE_CREATE_TIMEOUT }).should( 'be.visible' ); @@ -196,7 +208,9 @@ describe('switch linode state', () => { * - Confirms that landing page UI updates to reflect Linode power state. * - Does not wait for Linode to finish rebooting before succeeding. */ - it('reboots a linode from landing page', () => { + it.skip('reboots a linode from landing page', () => { + // TODO M3-10588 - Unskip landing page reboot test, evaluate stability. + // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is @@ -205,8 +219,10 @@ describe('switch linode state', () => { createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); - cy.get(`[data-qa-linode="${linode.label}"]`) + cy.findByText(linode.label).scrollIntoView(); + cy.findByText(linode.label) .should('be.visible') + .closest('tr') .within(() => { cy.contains('Running', { timeout: LINODE_CREATE_TIMEOUT }).should( 'be.visible' @@ -231,8 +247,9 @@ describe('switch linode state', () => { .click(); }); - cy.get(`[data-qa-linode="${linode.label}"]`) + cy.findByText(linode.label) .should('be.visible') + .closest('tr') .within(() => { cy.contains('Rebooting').should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts new file mode 100644 index 00000000000..47a0266d53f --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts @@ -0,0 +1,159 @@ +import { linodeFactory, profileFactory } from '@linode/utilities'; +import { mockGetMaintenance } from 'support/intercepts/account'; +import { mockGetNotifications } from 'support/intercepts/events'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinode, mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { accountMaintenanceFactory } from 'src/factories'; + +const mockProfile = profileFactory.build({ + timezone: 'America/New_York', +}); + +const mockLinodes = [ + linodeFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }), + linodeFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }), +]; + +const mockMaintenanceScheduled = accountMaintenanceFactory.build({ + entity: { + id: mockLinodes[0].id, + label: mockLinodes[0].label, + type: 'linode', + url: `/v4/linode/instances/${mockLinodes[0].id}`, + }, + type: 'reboot', + description: 'scheduled', + maintenance_policy_set: 'linode/power_off_on', + status: 'scheduled', + start_time: '2022-01-17T23:45:46.960', +}); + +const mockMaintenanceEmergency = accountMaintenanceFactory.build({ + entity: { + id: mockLinodes[1].id, + label: mockLinodes[1].label, + type: 'linode', + url: `/v4/linode/instances/${mockLinodes[1].id}`, + }, + type: 'cold_migration', + description: 'emergency', + maintenance_policy_set: 'linode/power_off_on', + status: 'scheduled', +}); + +describe('Host & VM maintenance notification banner', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: true, + }, + }).as('getFeatureFlags'); + + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetNotifications([]).as('getNotifications'); + mockGetProfile(mockProfile).as('getProfile'); + }); + + it('maintenance notification banner on landing page for 1 linode', function () { + mockGetMaintenance([mockMaintenanceScheduled], []).as('getMaintenances'); + cy.visitWithLogin('/linodes'); + cy.wait([ + '@getLinodes', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + + cy.contains( + '1 Linode has upcoming scheduled maintenance. For more details, view Account Maintenance.' + ) + .should('be.visible') + .within(() => { + cy.findByText('Account Maintenance').click(); + cy.url().should('endWith', '/maintenance'); + }); + }); + + it('maintenance notification banner on landing page for >1 linodes', function () { + mockGetMaintenance( + [mockMaintenanceEmergency, mockMaintenanceScheduled], + [] + ).as('getMaintenances'); + cy.visitWithLogin('/linodes'); + cy.wait([ + '@getLinodes', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + + cy.contains( + '2 Linodes have upcoming scheduled maintenance. For more details, view Account Maintenance.' + ) + .should('be.visible') + .within(() => { + cy.findByText('Account Maintenance').click(); + cy.url().should('endWith', '/maintenance'); + }); + }); + + it('banner present on details page when linode has pending maintenance', function () { + const mockLinode = mockLinodes[0]; + mockGetLinode(mockLinode.id, mockLinode).as('getLinode'); + mockGetMaintenance([mockMaintenanceScheduled], []).as('getMaintenances'); + + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.wait([ + '@getLinode', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + '@getProfile', + ]); + + cy.contains( + `Linode ${mockLinode.label} scheduled maintenance reboot will begin 01/17/2022 at 18:45. For more details, view Account Maintenance` + ) + .should('be.visible') + .within(() => { + cy.findByText('Account Maintenance').click(); + cy.url().should('endWith', '/maintenance'); + }); + }); + + it('maintenance notification banner not present on landing page if no linodes have pending maintenance', () => { + mockGetMaintenance([], []).as('getMaintenances'); + cy.visitWithLogin('/linodes'); + cy.wait([ + '@getLinodes', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + cy.get('[data-qa-maintenance-banner-v2="true"]').should('not.exist'); + }); + + it('banner not present on details page if no pending maintenance', function () { + const mockLinode = mockLinodes[0]; + mockGetMaintenance([], []).as('getMaintenances'); + mockGetLinode(mockLinode.id, mockLinode).as('getLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.wait([ + '@getLinode', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + cy.get('[data-qa-maintenance-banner="true"]').should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts index 854692a3dea..9331704d64a 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts @@ -14,7 +14,7 @@ import { ui } from 'support/ui'; import { randomIp, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { subnetFactory, vpcFactory, vpcIPFactory } from 'src/factories'; +import { subnetFactory, vpcFactory, vpcIPv4Factory } from 'src/factories'; authenticate(); describe('Create a NodeBalancer with VPCs', () => { @@ -48,7 +48,7 @@ describe('Create a NodeBalancer with VPCs', () => { region: region.id, }); - const mockLinodeVPCIPv4 = vpcIPFactory.build({ + const mockLinodeVPCIPv4 = vpcIPv4Factory.build({ address: '10.0.0.2', vpc_id: mockVPC.id, subnet_id: mockSubnet.id, diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts index ab42ce93398..095cc18619a 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts @@ -18,7 +18,6 @@ describe('Event fetching and polling', () => { */ it('Makes initial fetch to events endpoint', () => { const mockNow = DateTime.now(); - mockGetEvents([]).as('getEvents'); cy.visitWithLogin('/'); @@ -28,10 +27,21 @@ describe('Event fetching and polling', () => { const lastWeekTimestamp = mockNow .minus({ weeks: 1 }) .toUTC() - .startOf('second') // Helps with matching the timestamp at the start of the second + .startOf('second'); // Helps with matching the timestamp at the start of the second + + const ts1 = lastWeekTimestamp.toFormat("yyyy-MM-dd'T'HH:mm:ss"); + const ts2 = lastWeekTimestamp + .plus({ seconds: 1 }) .toFormat("yyyy-MM-dd'T'HH:mm:ss"); - const timestampFilter = `"created":{"+gt":"${lastWeekTimestamp}"`; + const timestampfilter1 = `"created":{"+gt":"${ts1}"`; + const timestampfilter2 = `"created":{"+gt":"${ts2}"`; + + // M3-10512: There is a small delay between between setting + // the clock and the app's filter generation. + // In the event that the delay causes the timestamp to roll + // over to the next second, we should accept either timestamp + // in the test. /* * Confirm that initial fetch request contains filters to achieve @@ -42,7 +52,10 @@ describe('Event fetching and polling', () => { * - Sort events by their created date. * - Only retrieve events created within the past week. */ - expect(filters).to.contain(timestampFilter); + expect(filters).to.satisfy( + (f: string) => + f.includes(timestampfilter1) || f.includes(timestampfilter2) + ); expect(filters).to.contain('"+neq":"profile_update"'); expect(filters).to.contain('"+order_by":"id"'); }); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts index d1bbfc963b8..8ceae07c5a0 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts @@ -109,7 +109,7 @@ describe('QEMU reboot upgrade notification', () => { // Confirm that the notice is visible and contains the expected message cy.findByText(NOTIFICATION_BANNER_TEXT, { exact: false }) .should('be.visible') - .closest('[data-testid="notice-warning"]') + .closest('[data-testid="platform-maintenance-banner"]') .within(() => { cy.get('p').then(($el) => { const noticeText = $el.text(); @@ -321,7 +321,7 @@ describe('QEMU reboot upgrade notification', () => { // Confirm that the notice is visible and contains the expected message cy.findByText(NOTIFICATION_BANNER_TEXT, { exact: false }) .should('be.visible') - .closest('[data-testid="notice-warning"]') + .closest('[data-testid="platform-maintenance-banner"]') .within(() => { cy.get('p').then(($el) => { const noticeText = $el.text(); @@ -329,7 +329,7 @@ describe('QEMU reboot upgrade notification', () => { }); }); cy.findByText(' upcoming', { exact: false }) - .closest('[data-testid="notice-warning"]') + .closest('[data-testid="maintenance-banner"]') .should('be.visible') .within(() => { cy.get('p').then(($el) => { diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts index 0341d722d68..38e6e618872 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts @@ -91,8 +91,13 @@ const assertStatusForUrlAtAlias = ( * * @param filepath - Path to file to upload. * @param filename - Filename to assign to uploaded file. + * @param fileExists - Whether or not the file already exists in the bucket. */ -const uploadFile = (filepath: string, filename: string) => { +const uploadFile = ( + filepath: string, + filename: string, + fileExists: boolean = false +) => { cy.fixture(filepath, null).then((contents) => { cy.get('[data-qa-drop-zone]').attachFile( { @@ -103,6 +108,12 @@ const uploadFile = (filepath: string, filename: string) => { subjectType: 'drag-n-drop', } ); + if (fileExists) { + cy.findByText( + 'This file already exists. Are you sure you want to overwrite it?' + ); + ui.button.findByTitle('Replace').should('be.visible').click(); + } }); }; @@ -208,11 +219,7 @@ describe('Object Storage Multicluster objects', () => { cy.wait('@uploadObject'); // Re-upload file to confirm replace prompt behavior. - uploadFile(bucketFiles[1].path, bucketFiles[1].name); - cy.findByText( - 'This file already exists. Are you sure you want to overwrite it?' - ); - ui.button.findByTitle('Replace').should('be.visible').click(); + uploadFile(bucketFiles[1].path, bucketFiles[1].name, true); cy.wait('@uploadObject'); // Confirm that you cannot delete a bucket with objects in it. diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index 28bfbc2dd53..566894a6d20 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -50,9 +50,10 @@ describe('VPC assign/unassign flows', () => { }); beforeEach(() => { - // TODO - Remove mock once `nodebalancerVpc` feature flag is removed. + // TODO - Remove mock once `nodebalancerVpc` and `vpcIpv6 feature flags are removed. mockAppendFeatureFlags({ nodebalancerVpc: false, + vpcIpv6: false, }).as('getFeatureFlags'); }); @@ -166,9 +167,7 @@ describe('VPC assign/unassign flows', () => { .click(); // Auto-assign IPv4 checkbox checked by default - cy.findByLabelText( - 'Auto-assign a VPC IPv4 address for this Linode' - ).should('be.checked'); + cy.findByLabelText('Auto-assign VPC IPv4 address').should('be.checked'); cy.wait('@getLinodeConfigs'); @@ -305,7 +304,7 @@ describe('VPC assign/unassign flows', () => { .click(); // Uncheck auto-assign checkbox and type in VPC IPv4 - cy.findByLabelText('Auto-assign a VPC IPv4 address for this Linode') + cy.findByLabelText('Auto-assign VPC IPv4 address') .should('be.checked') .click(); cy.findByLabelText('VPC IPv4').should('be.visible').click(); @@ -335,6 +334,7 @@ describe('VPC assign/unassign flows', () => { ui.button .findByTitle('Done') + .scrollIntoView() .should('be.visible') .should('be.enabled') .click(); diff --git a/packages/manager/cypress/support/api/objectStorage.ts b/packages/manager/cypress/support/api/objectStorage.ts index 70f16ddd5bc..eead9dbdc93 100644 --- a/packages/manager/cypress/support/api/objectStorage.ts +++ b/packages/manager/cypress/support/api/objectStorage.ts @@ -99,9 +99,12 @@ export const deleteAllTestBuckets = async () => { const deleteBucketsPromises = buckets.map( async (bucket: ObjectStorageBucket) => { - await deleteAllTestBucketObjects(bucket.cluster, bucket.label); + await deleteAllTestBucketObjects( + bucket.region || bucket.cluster, + bucket.label + ); return deleteBucket({ - cluster: bucket.cluster, + cluster: bucket.region || bucket.cluster, label: bucket.label, }); } diff --git a/packages/manager/cypress/support/constants/alert.ts b/packages/manager/cypress/support/constants/alert.ts index 398ff892ccf..c8e0f7a2f0e 100644 --- a/packages/manager/cypress/support/constants/alert.ts +++ b/packages/manager/cypress/support/constants/alert.ts @@ -11,8 +11,8 @@ export const dimensionOperatorTypeMap: Record< string > = { endswith: 'ends with', - eq: 'equals', - neq: 'not equals', + eq: 'equal', + neq: 'not equal', startswith: 'starts with', in: 'in', }; diff --git a/packages/manager/cypress/support/constants/cloudpulse.ts b/packages/manager/cypress/support/constants/cloudpulse.ts index ab0fd44ceb0..e7c02ccc832 100644 --- a/packages/manager/cypress/support/constants/cloudpulse.ts +++ b/packages/manager/cypress/support/constants/cloudpulse.ts @@ -6,6 +6,8 @@ export const cloudPulseServiceMap: Record = { dbaas: 'Databases', linode: 'Linode', + nodebalancer: 'NodeBalancer', + firewall: 'Firewall', }; /** * Descriptions used in the Create/Edit Alert form to guide users diff --git a/packages/manager/cypress/support/constants/databases.ts b/packages/manager/cypress/support/constants/databases.ts index 19e85cdb1d9..048b10e2494 100644 --- a/packages/manager/cypress/support/constants/databases.ts +++ b/packages/manager/cypress/support/constants/databases.ts @@ -1,3 +1,4 @@ +import { regionAvailabilityFactory } from '@linode/utilities'; import { databaseEngineFactory, databaseTypeFactory } from '@src/factories'; import { randomIp, randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -8,6 +9,7 @@ import type { DatabaseType, Engine, Region, + RegionAvailability, } from '@linode/api-v4'; export interface DatabaseClusterConfiguration { @@ -324,6 +326,115 @@ export const mockDatabaseNodeTypes: DatabaseType[] = [ memory: 98304, vcpus: 20, }), + databaseTypeFactory.build({ + class: 'premium', + id: 'g7-premium-2', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 5.04, + monthly: 3360.0, + }, + quantity: 3, + }, + ], + }, + disk: 59392, + label: 'Premium 4 GB', + memory: 4096, + vcpus: 20, + }), + databaseTypeFactory.build({ + class: 'premium', + id: 'g7-premium-4', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 5.04, + monthly: 3360.0, + }, + quantity: 3, + }, + ], + }, + disk: 133120, + label: 'Premium 8 GB', + memory: 8192, + vcpus: 20, + }), + databaseTypeFactory.build({ + class: 'premium', + id: 'g7-premium-8', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 5.04, + monthly: 3360.0, + }, + quantity: 3, + }, + ], + }, + disk: 286720, + label: 'Premium 16 GB', + memory: 16384, + vcpus: 20, + }), +]; + +// Array of database plans availability in a region used for mocking. +export const databaseRegionAvailability: RegionAvailability[] = [ + regionAvailabilityFactory.build({ + plan: 'g6-nanode-1', + region: '*', + available: true, + }), + regionAvailabilityFactory.build({ + plan: 'g6-standard-8', + region: '*', + available: true, + }), + regionAvailabilityFactory.build({ + plan: 'g6-standard-16', + region: '*', + available: true, + }), + regionAvailabilityFactory.build({ + plan: 'g6-dedicated-8', + region: '*', + available: true, + }), + regionAvailabilityFactory.build({ + plan: 'g6-dedicated-32', + region: '*', + available: true, + }), + regionAvailabilityFactory.build({ + plan: 'g6-dedicated-48', + region: '*', + available: true, + }), + regionAvailabilityFactory.build({ + plan: 'g7-premium-2', + region: '*', + available: true, + }), + regionAvailabilityFactory.build({ + plan: 'g7-premium-4', + region: '*', + available: true, + }), + regionAvailabilityFactory.build({ + plan: 'g7-premium-8', + region: '*', + available: true, + }), ]; // Array of database cluster configurations for which to test creation. @@ -368,6 +479,42 @@ export const databaseConfigurations: DatabaseClusterConfiguration[] = [ version: '13', ip: randomIp(), }, + { + clusterSize: 1, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g7-premium-2', + region: chooseRegion({ + capabilities: ['Managed Databases', 'Premium Plans'], + }), + version: '8', + ip: randomIp(), + }, + { + clusterSize: 2, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g7-premium-4', + region: chooseRegion({ + capabilities: ['Managed Databases', 'Premium Plans'], + }), + version: '8', + ip: randomIp(), + }, + { + clusterSize: 3, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g7-premium-2', + region: chooseRegion({ + capabilities: ['Managed Databases', 'Premium Plans'], + }), + version: '8', + ip: randomIp(), + }, ]; export const databaseConfigurationsResize: DatabaseClusterConfiguration[] = [ @@ -391,6 +538,18 @@ export const databaseConfigurationsResize: DatabaseClusterConfiguration[] = [ version: '5', ip: randomIp(), }, + { + clusterSize: 3, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g7-premium-4', + region: chooseRegion({ + capabilities: ['Managed Databases', 'Premium Plans'], + }), + version: '5', + ip: randomIp(), + }, ]; // Array of database cluster configurations for which to test creation. @@ -415,4 +574,16 @@ export const databaseConfigurationsAdvConfig: DatabaseClusterConfiguration[] = [ version: '13', ip: randomIp(), }, + { + clusterSize: 2, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g7-premium-2', + region: chooseRegion({ + capabilities: ['Managed Databases', 'Premium Plans'], + }), + version: '8', + ip: randomIp(), + }, ]; diff --git a/packages/manager/cypress/support/constants/lke.ts b/packages/manager/cypress/support/constants/lke.ts index 0f36b0e1462..f962b165b5f 100644 --- a/packages/manager/cypress/support/constants/lke.ts +++ b/packages/manager/cypress/support/constants/lke.ts @@ -2,6 +2,8 @@ import { dedicatedTypeFactory, linodeTypeFactory } from '@linode/utilities'; import { getLatestKubernetesVersion } from 'support/util/lke'; import { + kubernetesEnterpriseTierVersionFactory, + kubernetesStandardTierVersionFactory, lkeEnterpriseTypeFactory, lkeHighAvailabilityTypeFactory, } from 'src/factories'; @@ -12,6 +14,28 @@ import type { KubernetesTieredVersion } from '@linode/api-v4'; import type { ExtendedType } from 'src/utilities/extendType'; import type { LkePlanDescription } from 'support/api/lke'; +// TODO M3-10442: Consolidate LKE version mocks and their types if possible. + +/** + * Array of mock standard-tier LKE version objects. + */ +export const mockTieredStandardVersions = + kubernetesStandardTierVersionFactory.buildList(2); + +/** + * Array of mock enterprise-tier LKE version objects. + */ +export const mockTieredEnterpriseVersions = + kubernetesEnterpriseTierVersionFactory.buildList(2); + +/** + * Array of mock standard and enterprise-tier LKE version objects. + */ +export const mockTieredVersions = [ + ...mockTieredStandardVersions, + ...mockTieredEnterpriseVersions, +]; + /** * Kubernetes versions available for cluster creation via Cloud Manager. */ diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts index 35c311bfb16..27a8236571f 100644 --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts @@ -17,7 +17,7 @@ import type { Dashboard, MetricDefinition, NotificationChannel, - ServiceType, + Service, } from '@linode/api-v4'; /** @@ -71,7 +71,7 @@ export const mockGetCloudPulseServices = ( */ export const mockGetCloudPulseServiceByServiceType = ( serviceType: string, - service: ServiceType + service: Service ): Cypress.Chainable => { return cy.intercept( 'GET', @@ -374,7 +374,6 @@ export const mockGetAlertChannels = ( paginateResponse(channel) ); }; - /** * Mocks the API response for creating a new alert definition in the monitoring service. * This function intercepts a POST request to create alert definitions and returns a mock @@ -390,7 +389,6 @@ export const mockGetAlertChannels = ( * * @returns {Cypress.Chainable} - A Cypress chainable object that represents the intercepted request. */ - export const mockCreateAlertDefinition = ( serviceType: string, alert: Alert @@ -578,3 +576,23 @@ export const mockDeleteAlert = ( } ); }; + +/** + * Mocks the API response for a specific CloudPulse service endpoint. + * Intercepts the GET request to `/monitor/services/:serviceType` and returns + * a paginated mock response containing the provided service object. + * + * @param {string} serviceType - The type of the service (e.g., 'dbaas', 'linode'). + * @param {Service} service - The mocked service object to be returned in the response. + * @returns {Cypress.Chainable} - A Cypress chainable used to continue the test flow. + */ +export const mockGetCloudPulseServiceByType = ( + serviceType: string, + service: Service +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`monitor/services/${serviceType}`), + makeResponse(service) + ); +}; \ No newline at end of file diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index f0cdc970dc7..3890968e67d 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -28,6 +28,7 @@ import type { PriceType, } from '@linode/api-v4'; +// TODO M3-10442: Examine `mockGetKubernetesVersions` and consider modifying/adding alternative util that mocks response containing tiered version objects. /** * Intercepts GET request to retrieve Kubernetes versions and mocks response. * diff --git a/packages/manager/cypress/support/ui/pages/index.ts b/packages/manager/cypress/support/ui/pages/index.ts index f08f42cd5a9..e587e71efe8 100644 --- a/packages/manager/cypress/support/ui/pages/index.ts +++ b/packages/manager/cypress/support/ui/pages/index.ts @@ -12,4 +12,5 @@ */ export * from './linode-create-page'; +export * from './lke-cluster-create-page'; export * from './vpc-create-drawer'; diff --git a/packages/manager/cypress/support/ui/pages/lke-cluster-create-page.ts b/packages/manager/cypress/support/ui/pages/lke-cluster-create-page.ts new file mode 100644 index 00000000000..73d512c0dee --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/lke-cluster-create-page.ts @@ -0,0 +1,193 @@ +/** + * @file Page utilities for LKE Cluster create page. + */ +import { ui } from 'support/ui'; + +import type { KubernetesTier, Region } from '@linode/api-v4'; + +export const lkeClusterCreatePage = { + /** + * Sets the LKE cluster's label. + * + * @param clusterLabel - LKE cluster label to set. + */ + setLabel: (clusterLabel: string) => { + cy.findByLabelText('Cluster Label').type(`{selectall}{del}${clusterLabel}`); + }, + + /** + * Selects the LKE cluster tier. + * + * This function assumes that the `lkeEnterprise2` feature flag is enabled or + * is mocked to be enabled. + * + * @param clusterTier - LKE cluster tier; either `'standard'` or `'enterprise'`. + */ + selectClusterTier: (clusterTier: KubernetesTier) => { + const selectCardHeading = + clusterTier === 'standard' ? 'LKE' : 'LKE Enterprise'; + + cy.get(`[data-qa-select-card-heading="${selectCardHeading}"]`) + .should('be.visible') + .click(); + }, + + /** + * Selects the LKE cluster region. + * + * If using mocked regions, you may optionally pass an array of mock region objects. + * + * @param regionId - ID of region to select. + * @param searchRegions - Array of mock region objects from which to find the region by ID. + */ + selectRegionById: (regionId: string, searchRegions?: Region[]) => { + ui.regionSelect.find().type(regionId); + ui.regionSelect.findItemByRegionId(regionId, searchRegions).click(); + }, + + /** + * This function assumes that the `apl` and `aplGeneralAvailability` feature flags are both enabled. + */ + setEnableApl: (enableApl: boolean) => { + cy.findByTestId('application-platform-form').within(() => { + const expectedCheckboxLabel = enableApl + ? 'Yes, enable Akamai App Platform' + : 'No'; + + cy.findByText(expectedCheckboxLabel).should('be.visible').click(); + }); + }, + + /** + * Enables or disables High Availability. + * + * @param enableHighAvailability - Whether or not to enable High Availability for the cluster. + */ + setEnableHighAvailability: (enableHighAvailability: boolean) => { + cy.findByTestId('ha-control-plane-form').within(() => { + enableHighAvailability + ? cy + .contains('Yes, enable HA control plane.') + .should('be.visible') + .should('be.enabled') + .click() + : cy.findByText('No').should('be.visible').should('be.enabled').click(); + }); + }, + + /** + * Enables or disables Control Plane ACL feature. + * + * @param enableAcl - Whether or not to enable Control Plane ACL for the cluster. + */ + setEnableControlPlaneAcl: (enableAcl: boolean) => { + cy.findByTestId('control-plane-ipacl-form').within(() => { + enableAcl + ? cy.findByLabelText('Enable Control Plane ACL').check() + : cy.findByLabelText('Enable Control Plane ACL').uncheck(); + }); + }, + + /** + * Select the Linode plan tab with the given title. + * + * @param tabTitle - Title of the tab to select. + */ + selectPlanTab: (tabTitle: string) => { + ui.tabList.findTabByTitle(tabTitle).should('be.visible').click(); + }, + + /** + * Selects the given node pool plan for configuration. + * + * Assumes that the `lkeEnterprise2.postLa` feature flag is enabled. + * Assumes that the tab for the desired plan has already been selected. See + * also `addNodePoolPlan` to select plans when `postLa` is disabled. + * + * @param planName - Name of the plan to select (as shown in Cloud's UI) . + */ + selectNodePoolPlan: (planName: string) => { + cy.findByText(planName) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Configure Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }, + + // TODO M3-8838: Delete `addNodePool` function once `lkeEnterprise2` feature flag is retired. + /** + * Adds a node pool of the given plan and size. + * + * Assumes that the `lkeEnterprise2.postLa` feature flag is disabled. + * + * @param planName - Name of the plan to select (as shown in Cloud's UI). + * @param size - The desired number of nodes for the node pool. + */ + addNodePool: (planName: string, size: number) => { + cy.findByText(planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('input[name="Quantity"]').type(`{selectall}${size}`); + cy.get('input[name="Quantity"]').should('have.value', `${size}`); + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }, + + /** + * Sets whether or not to bypass ACL IP address requirement. + * + * If `true`, the "Provide an ACL later" checkbox will be checked. Otherwise, + * the checkbox will be unchecked if needed. + * + * Assumes that the user has already enabled Control Plane ACL. + * + * @param bypassAcl - Whether or not to bypass the ACL IP address requirement. + */ + setEnableBypassAcl: (bypassAcl: boolean) => { + const checkboxLabel = + 'Provide an ACL later. The control plane will be unreachable until an ACL is defined.'; + cy.findByText(checkboxLabel).scrollIntoView(); + cy.findByText(checkboxLabel).should('be.visible'); + bypassAcl + ? cy.findByLabelText(checkboxLabel).check() + : cy.findByLabelText(checkboxLabel).uncheck(); + }, + + /** + * Limit Cypress element selection to within the LKE order summary section. + * + * @param cb - Callback to execute where Cypress element selection will be scoped to the LKE order summary section. + */ + withinOrderSummary: (cb: () => void) => { + cy.get('[data-qa-order-summary]') + .closest('[data-qa-paper]') + .within(() => { + cb(); + }); + }, + + /** + * Limit Cypress element selection to within the LKE node pool configuration drawer. + * + * @param planName - Name of plan that node pool drawer is configuring. + * @param cb - Callback to execute where Cypress element selection will be scoped to the node pool configuration drawer. + */ + withinNodePoolDrawer: (planName: string, cb: () => void) => { + ui.drawer + .findByTitle(`Configure Pool: ${planName} Plan`) + .should('be.visible') + .within(() => { + cb(); + }); + }, +}; diff --git a/packages/manager/package.json b/packages/manager/package.json index 3a178ec39d7..273ac535304 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.150.1", + "version": "1.151.0", "private": true, "type": "module", "bugs": { @@ -47,7 +47,7 @@ "@xterm/xterm": "^5.5.0", "akamai-cds-react-components": "0.0.1-alpha.14", "algoliasearch": "^4.14.3", - "axios": "~1.8.3", + "axios": "~1.12.0", "braintree-web": "^3.92.2", "chart.js": "~2.9.4", "copy-to-clipboard": "^3.0.8", @@ -180,7 +180,7 @@ "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", "storybook": "^9.0.12", - "vite": "^6.3.4", + "vite": "^6.3.6", "vite-plugin-svgr": "^3.2.0" }, "browserslist": [ diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index ff2da93ecf1..e64ef845be7 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -1,9 +1,10 @@ -import { useAccountSettings, useGrants, useProfile } from '@linode/queries'; +import { useAccountSettings } from '@linode/queries'; import { Dialog, Select } from '@linode/ui'; import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; +import { usePermissions } from './features/IAM/hooks/usePermissions'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useFlags } from './hooks/useFlags'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; @@ -14,15 +15,12 @@ export const GoTo = React.memo(() => { const navigate = useNavigate(); const { data: accountSettings } = useAccountSettings(); - const { data: grants } = useGrants(); - const { data: profile } = useProfile(); const { iamRbacPrimaryNavChanges } = useFlags(); const isManagedAccount = accountSettings?.managed ?? false; - const hasAccountAccess = - !profile?.restricted || Boolean(grants?.global.account_access); + const { data: permissions } = usePermissions('account', ['is_account_admin']); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); @@ -118,7 +116,7 @@ export const GoTo = React.memo(() => { : [ { display: 'Account', - hide: !hasAccountAccess, + hide: !permissions.is_account_admin, href: '/account/billing', }, ]), @@ -132,7 +130,8 @@ export const GoTo = React.memo(() => { }, ], [ - hasAccountAccess, + permissions.is_account_admin, + isDatabasesEnabled, isManagedAccount, isPlacementGroupsEnabled, iamRbacPrimaryNavChanges, @@ -168,7 +167,10 @@ export const GoTo = React.memo(() => { { display: 'none', }, - height: '80%', + '& .MuiPaper-root': { + boxShadow: 'none', + }, + height: '60%', minHeight: '50%', minWidth: 'auto !important', padding: '0 !important', diff --git a/packages/manager/src/__data__/productionRegionsData.ts b/packages/manager/src/__data__/productionRegionsData.ts index b1ef94ca7ff..95e4e07c0b3 100644 --- a/packages/manager/src/__data__/productionRegionsData.ts +++ b/packages/manager/src/__data__/productionRegionsData.ts @@ -100,9 +100,11 @@ export const productionRegions: Region[] = [ 'Block Storage', 'Object Storage', 'Kubernetes', + 'Kubernetes Enterprise', 'Cloud Firewall', 'Vlans', 'VPCs', + 'VPC Dual Stack', 'Managed Databases', 'Metadata', 'Premium Plans', @@ -132,6 +134,7 @@ export const productionRegions: Region[] = [ 'Object Storage', 'GPU Linodes', 'Kubernetes', + 'Kubernetes Enterprise', 'Cloud Firewall', 'Vlans', 'VPCs', diff --git a/packages/manager/src/assets/icons/group-by.svg b/packages/manager/src/assets/icons/group-by.svg new file mode 100644 index 00000000000..4035c0e3b72 --- /dev/null +++ b/packages/manager/src/assets/icons/group-by.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx index 1f8f477c99b..aff6f8ab72b 100644 --- a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx +++ b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx @@ -1,4 +1,4 @@ -import { Box, ErrorState, StyledLinkButton, Typography } from '@linode/ui'; +import { Box, ErrorState, LinkButton, Typography } from '@linode/ui'; import Warning from '@mui/icons-material/CheckCircle'; import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -59,9 +59,9 @@ export const AccountActivationLanding = () => { Thanks for signing up! You’ll receive an email from us once our review is complete, so hang tight. If you have questions during this process{' '} - toggleSupportDrawer(true)}> + toggleSupportDrawer(true)}> please open a Support ticket - + . ; - -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 deleted file mode 100644 index e051d160ec5..00000000000 --- a/packages/manager/src/components/DatePicker/DatePicker.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 25fb95ff048..00000000000 --- a/packages/manager/src/components/DatePicker/DatePicker.tsx +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 7fe5cd724e3..00000000000 --- a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import * as React from 'react'; -import { action } from 'storybook/actions'; - -import { DateTimePicker } from './DateTimePicker'; - -import type { Meta, StoryObj } from '@storybook/react-vite'; -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 deleted file mode 100644 index 12f1795a747..00000000000 --- a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index c4ab9722276..00000000000 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { ActionsPanel, InputAdornment, TextField } from '@linode/ui'; -import { Divider } from '@linode/ui'; -import { Box } from '@linode/ui'; -import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; -import { GridLegacy, 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 { timezones } from 'src/assets/timezones/timezones'; - -import { TimeZoneSelect } from './TimeZoneSelect'; - -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>; - disabledTimeZone?: boolean; - /** Error text for the date picker field */ - errorText?: string; - /** Format for displaying the date-time */ - format?: string; - /** Label for the input field */ - label?: string; - /** Minimum date-time before which all date-time will be disabled */ - minDate?: DateTime; - /** 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 = {}, - disabledTimeZone = false, - errorText = '', - format = 'yyyy-MM-dd HH:mm', - label = 'Select Date and Time', - minDate, - 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 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 && !newTime.invalidReason) { - 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' }, - }} - label={label} - noMarginTop - onClick={(event) => setAnchorEl(event.currentTarget)} - placeholder={placeholder} - value={ - selectedDateTime - ? `${selectedDateTime.toFormat(format)}${generateTimeZone( - selectedTimeZone - )}` - : '' - } - /> - - - - ({ - '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { - justifyContent: 'space-between', - }, - '& .MuiDayCalendar-weekDayLabel': { - fontSize: '0.875rem', - }, - '& .MuiPickersCalendarHeader-label': { - font: 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`, - }, - }), - }, - }} - sx={{ - marginTop: 0, - }} - value={selectedDateTime || null} - /> - - )} - {showTimeZone && ( - - - - )} - - - - - ({ - marginBottom: theme.spacing(1), - marginRight: theme.spacing(2), - })} - /> - - - - ); -}; - -const generateTimeZone = (selectedTimezone: null | string): string => { - const offset = timezones.find( - (zone) => zone.name === selectedTimezone - )?.offset; - if (!offset) { - return ''; - } - const minutes = (Math.abs(offset * 60) % 60).toLocaleString(undefined, { - minimumIntegerDigits: 2, - useGrouping: false, - }); - const hours = Math.floor(Math.abs(offset)); - const isPositive = Math.abs(offset) === offset ? '+' : '-'; - - return ` (GMT${isPositive}${hours}:${minutes})`; -}; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx deleted file mode 100644 index 968e577a1ea..00000000000 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { DateTime } from 'luxon'; -import * as React from 'react'; -import { action } from 'storybook/actions'; - -import { DateTimeRangePicker } from './DateTimeRangePicker'; - -import type { Meta, StoryObj } from '@storybook/react-vite'; - -type Story = StoryObj; - -export const Default: Story = { - args: { - enablePresets: true, - endDateProps: { - label: 'End Date and Time', - placeholder: '', - showTimeZone: false, - value: null, - }, - - format: 'yyyy-MM-dd HH:mm', - onChange: action('DateTime range changed'), - presetsProps: { - defaultValue: '', - label: '', - placeholder: '', - }, - startDateProps: { - errorMessage: '', - label: 'Start Date and Time', - placeholder: '', - showTimeZone: true, - timeZoneValue: null, - value: null, - }, - sx: {}, - }, - render: (args) => , -}; - -export const WithInitialValues: Story = { - args: { - enablePresets: true, - endDateProps: { - label: 'End Date and Time', - showTimeZone: true, - value: DateTime.now(), - }, - - format: 'yyyy-MM-dd HH:mm', - onChange: action('DateTime range changed'), - presetsProps: { - defaultValue: '7days', - 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: { - enablePresets: true, - endDateProps: { - 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: '', - 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 deleted file mode 100644 index 1ebd94a0cce..00000000000 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ /dev/null @@ -1,341 +0,0 @@ -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 = { - enablePresets: true, - endDateProps: { - label: 'End Date and Time', - }, - onChange: onChangeMock, - presetsProps: { - 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' })); - - // Check if the onChange function is called - expect(onChangeMock).toHaveBeenCalled(); - }); - - it('should disable the end date-time which is before the selected 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); - - expect(screen.getByRole('gridcell', { name: '10' })).toBeDisabled(); - }); - - it('should show error when start date-time is after end date-time', async () => { - const updateProps = { - ...Props, - enablePresets: false, - presetsProps: { ...Props.presetsProps }, - }; - renderWithTheme(); - const now = DateTime.now().set({ second: 0 }); - // 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: now.day.toString() }) - ); - 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: (now.day + 1).toString() }) - ); // 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, - enablePresets: false, - 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(); - const now = DateTime.now().set({ second: 0 }); - // 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: now.day.toString() }) - ); - 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: (now.day + 1).toString() }) - ); // 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(); - const now = DateTime.now().set({ second: 0 }); - // 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 = now.minus({ hours: 24 }).toISO(); // 2024-12-17T00:28:27.071-06:00 - const expectedEndDateISO = 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(); - const now = DateTime.now().set({ second: 0 }); - // 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 = now.minus({ days: 7 }).toISO(); - const expectedEndDateISO = 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(); - const now = DateTime.now().set({ second: 0 }); - // 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 = now.minus({ days: 30 }).toISO(); - const expectedEndDateISO = 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(); - const now = DateTime.now(); - // 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 = now.startOf('month').toISO(); - const expectedEndDateISO = now.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().set({ second: 0 }).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 30 min difference values when the "Custom Range" preset is selected', async () => { - const timezone = 'Asia/Kolkata'; - 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); - const format = 'yyyy-MM-dd HH:mm'; - const now = DateTime.now().set({ second: 0 }); - const start = now.minus({ minutes: 30 }); - - // Verify the input fields display the correct values - expect( - screen.getByRole('textbox', { name: 'Start Date and Time' }) - ).toHaveValue(`${start.toFormat(format)} (GMT+5:30)`); - - expect( - screen.getByRole('textbox', { name: 'End Date and Time' }) - ).toHaveValue(`${now.toFormat(format)} (GMT+5:30)`); - 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: '17' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // 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 deleted file mode 100644 index f1912c77917..00000000000 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ /dev/null @@ -1,329 +0,0 @@ -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 { - /** If true, disable the timezone drop down */ - disabledTimeZone?: boolean; - - /** If true, shows the date presets field instead of the date pickers */ - enablePresets?: boolean; - - /** Properties for the end date field */ - endDateProps?: { - /** 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?: string; - /** 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 = - | '1hour' - | '7days' - | '12hours' - | '24hours' - | '30days' - | '30minutes' - | 'custom_range' - | 'last_month' - | 'this_month'; - -const presetsOptions: { label: string; value: DatePresetType }[] = [ - { label: 'Last 30 Minutes', value: '30minutes' }, - { label: 'Last 1 Hour', value: '1hour' }, - { label: 'Last 12 Hours', value: '12hours' }, - { 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 { - disabledTimeZone = false, - - enablePresets = false, - - endDateProps: { - label: endLabel = 'End Date and Time', - placeholder: endDatePlaceholder, - showTimeZone: showEndTimeZone = false, - value: endDateTimeValue = null, - } = {}, - format = 'yyyy-MM-dd HH:mm', - onChange, - presetsProps: { - defaultValue: presetsDefaultValue = presetsOptions[0].value, - 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 ?? - DateTime.now().set({ second: 0 }).minus({ minutes: 30 }) - ); - const [endDateTime, setEndDateTime] = useState( - endDateTimeValue ?? DateTime.now().set({ second: 0 }) - ); - const [presetValue, setPresetValue] = useState< - | undefined - | { - label: string; - value: string; - } - >( - presetsOptions.find((option) => option.value === presetsDefaultValue) ?? - presetsOptions[0] - ); - const [startTimeZone, setStartTimeZone] = useState( - startTimeZoneValue - ); - const [startDateError, setStartDateError] = useState(null); - const [showPresets, setShowPresets] = useState( - presetsDefaultValue - ? presetsDefaultValue !== 'custom_range' && enablePresets - : 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 && source === 'start' && start > end) { - setStartDateError(startDateErrorMessage); - return; - } - // Reset validation errors - setStartDateError(null); - }; - - const handlePresetSelection = (value: DatePresetType) => { - const now = DateTime.now().set({ second: 0 }); - let newStartDateTime: DateTime | null = null; - let newEndDateTime: DateTime | null = now; - - switch (value) { - case '1hour': - newStartDateTime = now.minus({ hours: 1 }); - break; - case '7days': - newStartDateTime = now.minus({ days: 7 }); - break; - case '12hours': - newStartDateTime = now.minus({ hours: 12 }); - break; - case '24hours': - newStartDateTime = now.minus({ hours: 24 }); - break; - case '30days': - newStartDateTime = now.minus({ days: 30 }); - break; - case '30minutes': - newStartDateTime = now.minus({ minutes: 30 }); - break; - case 'custom_range': - newStartDateTime = startDateTime; - newEndDateTime = endDateTime; - break; - case 'last_month': - const lastMonth = DateTime.now().minus({ months: 1 }); - newStartDateTime = lastMonth.startOf('month'); - newEndDateTime = lastMonth.endOf('month'); - break; - - case 'this_month': - newEndDateTime = DateTime.now(); - newStartDateTime = newEndDateTime.startOf('month'); - break; - default: - return; - } - - setStartDateTime(newStartDateTime); - setEndDateTime(newEndDateTime?.set({ second: 0 }) ?? null); - setPresetValue( - presetsOptions.find((option) => option.value === value) ?? - presetsOptions[0] - ); - - 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); - } - }} - options={presetsOptions} - placeholder={presetsPlaceholder} - value={presetValue} - /> - ) : ( - - setStartTimeZone(value), - value: startTimeZone, - }} - value={startDateTime} - /> - - - { - setShowPresets(true); - setPresetValue(undefined); - setStartDateError(null); - }} - variant="text" - > - Presets - - - - )} - - ); -}; diff --git a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx deleted file mode 100644 index 813ba76478c..00000000000 --- a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 ( - onChange(option?.value || '')} - options={timezoneOptions} - placeholder="Choose a Timezone" - value={ - timezoneOptions.find((option) => option.value === value) ?? undefined - } - /> - ); -}; diff --git a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx index fed5fef82ce..45b121894c1 100644 --- a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx +++ b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx @@ -1,4 +1,4 @@ -import { Button, StyledLinkButton } from '@linode/ui'; +import { Button, LinkButton } from '@linode/ui'; import * as React from 'react'; import { CSVLink } from 'react-csv'; @@ -42,10 +42,10 @@ export const DownloadCSV = ({ }: DownloadCSVProps) => { const renderButton = buttonType === 'styledLink' ? ( - + {text} - + ) : ( + + + + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.test.tsx b/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.test.tsx new file mode 100644 index 00000000000..cac5537d38a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.test.tsx @@ -0,0 +1,141 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { dashboardFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { GlobalFilterGroupByRenderer } from './GlobalFilterGroupByRenderer'; + +import type { GroupByOption } from './CloudPulseGroupByDrawer'; + +const handleChange = vi.fn(); +const dashboard = dashboardFactory.build(); + +const mocks = vi.hoisted(() => ({ + useGlobalDimensions: vi.fn(), +})); + +const mockGroupByOptions: GroupByOption[] = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, +]; + +vi.mock('./utils', async () => { + const actual = await vi.importActual('./utils'); + + return { + ...actual, + useGlobalDimensions: mocks.useGlobalDimensions, + }; +}); + +describe('Global Group By Renderer Component', () => { + it('should render group by icon in disabled mode on undefined dashboard', () => { + mocks.useGlobalDimensions.mockReturnValue({ + isLoading: true, + options: [], + defaultValue: [], + }); + renderWithTheme( + + ); + + const groupByIcon = screen.getByTestId('group-by'); + expect(groupByIcon).toBeDisabled(); + }); + + it('should render group by icon in disabled mode on data loading', () => { + mocks.useGlobalDimensions.mockReturnValue({ + isLoading: true, + options: [], + defaultValue: [], + }); + renderWithTheme( + + ); + + const groupByIcon = screen.getByTestId('group-by'); + expect(groupByIcon).toBeDisabled(); + }); + + it('Should render group by icon as enabled and open drawer on click', async () => { + mocks.useGlobalDimensions.mockReturnValue({ + isLoading: false, + options: mockGroupByOptions, + defaultValue: [], + }); + renderWithTheme( + + ); + + const groupByIcon = screen.getByTestId('group-by'); + expect(groupByIcon).toBeEnabled(); + + await groupByIcon.click(); + + const drawer = screen.getByTestId('drawer'); + expect(drawer).toBeInTheDocument(); + + expect(handleChange).toHaveBeenCalledWith([]); + }); + + it('Should not open drawer but group by icon should be enabled', async () => { + mocks.useGlobalDimensions.mockReturnValue({ + isLoading: false, + options: mockGroupByOptions, + defaultValue: [], + }); + renderWithTheme( + + ); + + const groupByIcon = screen.getByTestId('group-by'); + expect(groupByIcon).toBeEnabled(); + + const drawer = screen.queryByTestId('drawer'); + expect(drawer).not.toBeInTheDocument(); + }); + + it('Should open drawer on group by icon click and have default selected values', async () => { + const defaultValue = [mockGroupByOptions[0]]; + + mocks.useGlobalDimensions.mockReturnValue({ + isLoading: false, + options: mockGroupByOptions, + defaultValue, + }); + renderWithTheme( + + ); + + const groupByIcon = screen.getByTestId('group-by'); + + await groupByIcon.click(); + + const drawer = screen.getByTestId('drawer'); + expect(drawer).toBeInTheDocument(); + + expect(handleChange).toHaveBeenCalledWith([defaultValue[0].value]); + + defaultValue.forEach((value) => { + const option = screen.getByRole('button', { name: value.label }); + expect(option).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.tsx b/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.tsx new file mode 100644 index 00000000000..193ee4480f6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.tsx @@ -0,0 +1,90 @@ +import { IconButton } from '@linode/ui'; +import React from 'react'; + +import GroupByIcon from 'src/assets/icons/group-by.svg'; + +import { CloudPulseTooltip } from '../shared/CloudPulseTooltip'; +import { CloudPulseGroupByDrawer } from './CloudPulseGroupByDrawer'; +import { GLOBAL_GROUP_BY_MESSAGE } from './constants'; +import { useGlobalDimensions } from './utils'; + +import type { GroupByOption } from './CloudPulseGroupByDrawer'; +import type { Dashboard } from '@linode/api-v4'; + +interface GlobalFilterGroupByRendererProps { + /** + * Callback to handle the selected values + */ + handleChange: (selectedValue: string[]) => void; + /** + * Currently selected dashboard + */ + selectedDashboard?: Dashboard; +} + +export const GlobalFilterGroupByRenderer = ( + props: GlobalFilterGroupByRendererProps +) => { + const { selectedDashboard, handleChange } = props; + const [isSelected, setIsSelected] = React.useState(false); + + const { options, defaultValue, isLoading } = useGlobalDimensions( + selectedDashboard?.id, + selectedDashboard?.service_type + ); + + const [open, setOpen] = React.useState(false); + + const onApply = React.useCallback( + (selectedValue: GroupByOption[]) => { + if (selectedValue.length === 0) { + setIsSelected(false); + } else { + setIsSelected(true); + } + handleChange(selectedValue.map(({ value }) => value)); + setOpen(false); + }, + [handleChange] + ); + + const onCancel = React.useCallback(() => { + setOpen(false); + }, []); + return ( + <> + + setOpen(true)} + size="small" + sx={(theme) => ({ + marginBlockEnd: 'auto', + marginTop: { md: theme.spacingFunction(28) }, + color: isSelected ? theme.color.buttonPrimaryHover : 'inherit', + })} + > + + + + + {!isLoading && selectedDashboard && ( + + )} + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.test.tsx b/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.test.tsx new file mode 100644 index 00000000000..1b820c0aa08 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.test.tsx @@ -0,0 +1,130 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { WidgetFilterGroupByRenderer } from './WidgetFilterGroupByRenderer'; + +import type { GroupByOption } from './CloudPulseGroupByDrawer'; +import type { CloudPulseServiceType } from '@linode/api-v4'; + +const mocks = vi.hoisted(() => ({ + useGlobalDimensions: vi.fn(), + useWidgetDimension: vi.fn(), +})); + +vi.mock('./utils', () => { + return { + ...mocks, + }; +}); +const handleChange = vi.fn(); +const props = { + dashboardId: 1, + serviceType: 'linode' as CloudPulseServiceType, + label: 'Label 1', + metric: 'metric-1', + handleChange, +}; + +const globalDimension = { + isLoading: false, + options: [], + defaultValue: [], +}; + +const widgetGroupBy: GroupByOption[] = [ + { value: 'value-1', label: 'Value 1' }, + { value: 'value-2', label: 'Value 2' }, + { value: 'value-3', label: 'Value 3' }, +]; + +const component = ; + +describe('Widget Group By Renderer', () => { + beforeAll(() => { + mocks.useGlobalDimensions.mockRejectedValue(globalDimension); + }); + it('Should render group by icon in disabled mode', async () => { + mocks.useWidgetDimension.mockReturnValue({ + isLoading: true, + options: [], + defaultValue: [], + }); + renderWithTheme(component); + + const groupByIcon = screen.getByTestId('group-by'); + expect(groupByIcon).toBeInTheDocument(); + expect(groupByIcon).toBeDisabled(); + + await groupByIcon.click(); + + const drawer = screen.queryByTestId('drawer'); + expect(drawer).not.toBeInTheDocument(); + }); + + it('Should open drawer on click of group by icon', async () => { + mocks.useWidgetDimension.mockReturnValue({ + isLoading: false, + options: widgetGroupBy, + defaultValue: [], + }); + + renderWithTheme(component); + + const groupByIcon = screen.getByTestId('group-by'); + + await groupByIcon.click(); + + const drawer = screen.getByTestId('drawer'); + expect(drawer).toBeInTheDocument(); + + const labelText = screen.getByText('Label 1'); + expect(labelText).toBeInTheDocument(); + + const title = screen.getByText('Group By'); + expect(title).toBeInTheDocument(); + + expect(handleChange).toHaveBeenCalledWith([]); + }); + + it('Should not open drawer but group by icon should be enabled', async () => { + mocks.useWidgetDimension.mockReturnValue({ + isLoading: false, + options: widgetGroupBy, + defaultValue: [], + }); + renderWithTheme(component); + + const groupByIcon = screen.getByTestId('group-by'); + expect(groupByIcon).toBeEnabled(); + + const drawer = screen.queryByTestId('drawer'); + expect(drawer).not.toBeInTheDocument(); + }); + + it('Should open drawer on group by icon click and have default selected values', async () => { + const defaultValue = [widgetGroupBy[0]]; + + mocks.useWidgetDimension.mockReturnValue({ + isLoading: false, + options: widgetGroupBy, + defaultValue, + }); + renderWithTheme(component); + + const groupByIcon = screen.getByTestId('group-by'); + + await groupByIcon.click(); + + const drawer = screen.getByTestId('drawer'); + expect(drawer).toBeInTheDocument(); + + expect(handleChange).toHaveBeenCalledWith([defaultValue[0].value]); + + defaultValue.forEach((value) => { + const option = screen.getByRole('button', { name: value.label }); + expect(option).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.tsx b/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.tsx new file mode 100644 index 00000000000..8d892d5bec8 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.tsx @@ -0,0 +1,110 @@ +import { IconButton } from '@linode/ui'; +import React from 'react'; + +import GroupByIcon from 'src/assets/icons/group-by.svg'; + +import { CloudPulseTooltip } from '../shared/CloudPulseTooltip'; +import { CloudPulseGroupByDrawer } from './CloudPulseGroupByDrawer'; +import { WIDGET_GROUP_BY_MESSAGE } from './constants'; +import { useGlobalDimensions, useWidgetDimension } from './utils'; + +import type { GroupByOption } from './CloudPulseGroupByDrawer'; +import type { CloudPulseServiceType } from '@linode/api-v4'; + +interface WidgetFilterGroupByRendererProps { + /** + * Id of the selected dashboard + */ + dashboardId: number; + /** + * Callback function to handle the selected values + */ + handleChange: (selectedValue: string[]) => void; + /** + * Label for the widget metric + */ + label: string; + /** + * Name of the metric + */ + metric: string; + /** + * Service type of the selected dashboard + */ + serviceType: CloudPulseServiceType; +} + +export const WidgetFilterGroupByRenderer = ( + props: WidgetFilterGroupByRendererProps +) => { + const { metric, dashboardId, serviceType, label, handleChange } = props; + const [isSelected, setIsSelected] = React.useState(false); + + const { isLoading: globalDimensionLoading, options: globalDimensions } = + useGlobalDimensions(dashboardId, serviceType); + const { + isLoading: widgetDimensionLoading, + options: widgetDimensions, + defaultValue, + } = useWidgetDimension(dashboardId, serviceType, globalDimensions, metric); + const [open, setOpen] = React.useState(false); + const onCancel = React.useCallback(() => { + setOpen(false); + }, []); + const onApply = React.useCallback( + (selectedValue: GroupByOption[]) => { + if (selectedValue.length === 0) { + setIsSelected(false); + } else { + setIsSelected(true); + } + handleChange(selectedValue.map(({ value }) => value)); + setOpen(false); + }, + [handleChange] + ); + + const isDisabled = + globalDimensionLoading || + widgetDimensionLoading || + widgetDimensions.length === 0; + + return ( + <> + + setOpen(true)} + size="small" + sx={(theme) => ({ + marginBlockEnd: 'auto', + color: isSelected + ? theme.tokens.component.Button.Primary.Hover.Background + : 'inherit', + padding: 0, + })} + > + + + + + {!isDisabled && ( + + )} + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/GroupBy/constants.ts b/packages/manager/src/features/CloudPulse/GroupBy/constants.ts new file mode 100644 index 00000000000..33895b8bb99 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/GroupBy/constants.ts @@ -0,0 +1,7 @@ +export const GLOBAL_GROUP_BY_MESSAGE = + 'Use Global Group by to visually split data into separate time series lines by dimension values. This setting applies to all widgets, and only dimensions supported by all widgets are available. You can group by additional dimensions at the widget level. Dimensions are applied and displayed in the order selected.'; + +export const GROUP_BY_SELECTION_LIMIT = 3; + +export const WIDGET_GROUP_BY_MESSAGE = + 'The Widget-level Group By setting applies only to this widget. All dimensions available for this widget can be selected, including those not supported globally. Dimensions are applied and displayed in the order they are selected.'; diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts new file mode 100644 index 00000000000..9d648715557 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts @@ -0,0 +1,238 @@ +import { dashboardFactory } from 'src/factories'; + +import { + defaultOption, + getCommonDimensions, + getCommonGroups, + getMetricDimensions, + useGlobalDimensions, + useWidgetDimension, +} from './utils'; + +import type { MetricDefinition } from '@linode/api-v4'; + +const metricDefinitions: MetricDefinition[] = [ + { + metric: 'Metric 1', + dimensions: [ + { label: 'Dim 1', dimension_label: 'Dim 1', values: [] }, + { label: 'Dim 2', dimension_label: 'Dim 2', values: [] }, + ], + available_aggregate_functions: [], + is_alertable: false, + label: '', + metric_type: '', + scrape_interval: '', + unit: '', + }, + { + metric: 'Metric 2', + dimensions: [ + { label: 'Dim 2', dimension_label: 'Dim 2', values: [] }, + { label: 'Dim 3', dimension_label: 'Dim 3', values: [] }, + ], + available_aggregate_functions: [], + is_alertable: false, + label: '', + metric_type: '', + scrape_interval: '', + unit: '', + }, + { + metric: 'Metric 3', + dimensions: [ + { label: 'Dim 1', dimension_label: 'Dim 1', values: [] }, + { label: 'Dim 2', dimension_label: 'Dim 2', values: [] }, + { label: 'Dim 3', dimension_label: 'Dim 3', values: [] }, + ], + available_aggregate_functions: [], + is_alertable: false, + label: '', + metric_type: '', + scrape_interval: '', + unit: '', + }, +]; + +const queryMocks = vi.hoisted(() => ({ + useCloudPulseDashboardByIdQuery: vi.fn().mockReturnValue({}), + useGetCloudPulseMetricDefinitionsByServiceType: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/dashboards', async (importActual) => ({ + ...importActual(), + useCloudPulseDashboardByIdQuery: queryMocks.useCloudPulseDashboardByIdQuery, +})); + +vi.mock('src/queries/cloudpulse/services', async (importActual) => ({ + ...importActual(), + useGetCloudPulseMetricDefinitionsByServiceType: + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType, +})); + +describe('useGlobalDimensions method test', () => { + it('should return loading state when data is being fetched', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: null, + isLoading: true, + }); + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: null, + isLoading: true, + }); + + const result = useGlobalDimensions(1, 'linode'); + expect(result).toEqual({ options: [], defaultValue: [], isLoading: true }); + }); + + it('should return empty options and defaultValue if no common dimensions', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: dashboardFactory.build(), + isLoading: false, + }); + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: { + data: metricDefinitions, + }, + isLoading: false, + }); + const result = useGlobalDimensions(1, 'linode'); + expect(result).toEqual({ + options: [defaultOption, { label: 'Dim 2', value: 'Dim 2' }], + defaultValue: [], + isLoading: false, + }); + }); +}); + +describe('useWidgetDimension method test', () => { + it('should return empty options and defaultValue', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: dashboardFactory.build(), + isLoading: false, + }); + + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: { + data: metricDefinitions, + }, + isLoading: false, + }); + + const result = useWidgetDimension( + 1, + 'linode', + [ + { label: 'Dim 1', value: 'Dim 1' }, + { label: 'Dim 2', value: 'Dim 2' }, + ], + 'Metric 1' + ); + + expect(result.options).toHaveLength(0); + expect(result.defaultValue).toHaveLength(0); + expect(result.isLoading).toBe(false); + }); + + it('should return non-empty options and empty defaultValue', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: dashboardFactory.build(), + isLoading: false, + }); + + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: { + data: metricDefinitions, + }, + isLoading: false, + }); + + const result = useWidgetDimension( + 1, + 'linode', + [{ label: 'Dim 1', value: 'Dim 1' }], + 'Metric 1' + ); + + expect(result.options).toHaveLength(1); + expect(result.defaultValue).toHaveLength(0); + expect(result.isLoading).toBe(false); + }); +}); +describe('getCommonGroups method test', () => { + it('should return empty list if groups or commonDimensions are empty', () => { + const result = getCommonGroups([], []); + expect(result).toHaveLength(0); + }); + + it('should return common groups', () => { + const groups: string[] = ['Group 1', 'Group 2']; + const commonDimensions = [ + { label: 'Group 1', value: 'Group 1' }, + { label: 'Group 2', value: 'Group 2' }, + { label: 'Group 3', value: 'Group 3' }, + ]; + const result = getCommonGroups(groups, commonDimensions); + expect(result).toHaveLength(2); + expect(result).toEqual([ + { label: 'Group 1', value: 'Group 1' }, + { label: 'Group 2', value: 'Group 2' }, + ]); + }); +}); + +describe('getMetricDimensions method test', () => { + it('should return empty object if metric definitions are empty', () => { + const result = getMetricDimensions([]); + expect(result).toEqual({}); + }); + + it('should return unique dimensions from metric definitions', () => { + const result = getMetricDimensions(metricDefinitions); + expect(result).toEqual({ + 'Metric 1': [ + { label: 'Dim 1', dimension_label: 'Dim 1', values: [] }, + { label: 'Dim 2', dimension_label: 'Dim 2', values: [] }, + ], + 'Metric 2': [ + { label: 'Dim 2', dimension_label: 'Dim 2', values: [] }, + { label: 'Dim 3', dimension_label: 'Dim 3', values: [] }, + ], + 'Metric 3': [ + { label: 'Dim 1', dimension_label: 'Dim 1', values: [] }, + { label: 'Dim 2', dimension_label: 'Dim 2', values: [] }, + { label: 'Dim 3', dimension_label: 'Dim 3', values: [] }, + ], + }); + }); + + describe('getCommonDimensions method test', () => { + it('should return empty list if metricDimensions is empty', () => { + const result = getCommonDimensions({}); + expect(result).toHaveLength(0); + }); + + it('should return common dimensions across all metrics', () => { + const metricDimensions = { + 'Metric 1': [ + { label: 'Dim 1', dimension_label: 'Dim 1', values: [] }, + { label: 'Dim 2', dimension_label: 'Dim 2', values: [] }, + { label: 'Dim 3', dimension_label: 'Dim 3', values: [] }, + ], + 'Metric 2': [ + { label: 'Dim 2', dimension_label: 'Dim 2', values: [] }, + { label: 'Dim 3', dimension_label: 'Dim 3', values: [] }, + { label: 'Dim 4', dimension_label: 'Dim 4', values: [] }, + ], + 'Metric 3': [ + { label: 'Dim 1', dimension_label: 'Dim 1', values: [] }, + { label: 'Dim 3', dimension_label: 'Dim 3', values: [] }, + { label: 'Dim 4', dimension_label: 'Dim 4', values: [] }, + ], + }; + const result = getCommonDimensions(metricDimensions); + expect(result).toHaveLength(1); + expect(result).toEqual([{ label: 'Dim 3', value: 'Dim 3' }]); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts new file mode 100644 index 00000000000..4b642e1a5ef --- /dev/null +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts @@ -0,0 +1,191 @@ +import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; +import { useGetCloudPulseMetricDefinitionsByServiceType } from 'src/queries/cloudpulse/services'; + +import type { GroupByOption } from './CloudPulseGroupByDrawer'; +import type { + CloudPulseServiceType, + Dimension, + MetricDefinition, +} from '@linode/api-v4'; + +export const defaultOption: GroupByOption = { + label: 'Entity Id', + value: 'entity_id', +}; + +interface GroupByDimension { + /** + * The default grouping options to use + */ + defaultValue: GroupByOption[]; + /** + * Indicates if the grouping options are currently loading + */ + isLoading: boolean; + /** + * Available grouping options + */ + options: GroupByOption[]; +} + +interface MetricDimension { + [metric: string]: Dimension[]; +} + +/** + * + * @param dashboardId The ID of the dashboard being queried + * @param serviceType The type of cloud service (e.g., 'linode', 'dbaas') + * @returns A GroupByDimension object containing available options, default values, and loading state + */ +export const useGlobalDimensions = ( + dashboardId: number | undefined, + serviceType: CloudPulseServiceType | undefined +): GroupByDimension => { + const { data: dashboard, isLoading: dashboardLoading } = + useCloudPulseDashboardByIdQuery(dashboardId); + const { data: metricDefinition, isLoading: metricLoading } = + useGetCloudPulseMetricDefinitionsByServiceType( + serviceType, + serviceType !== undefined + ); + + if (metricLoading || dashboardLoading) { + return { options: [], defaultValue: [], isLoading: true }; + } + const metricDimensions = getMetricDimensions(metricDefinition?.data ?? []); + const commonDimensions = [ + defaultOption, + ...getCommonDimensions(metricDimensions), + ]; + + const commonGroups = getCommonGroups( + dashboard?.group_by ?? [], + commonDimensions + ); + return { + options: commonDimensions, + defaultValue: commonGroups, + isLoading: false, + }; +}; + +/** + * + * @param groupBy Default group by list from dashboard + * @param commonDimensions The available common dimensions across all metrics + * @returns An array of GroupByOption objects that exist in both the dashboard config and common dimensions + */ +export const getCommonGroups = ( + groupBy: string[], + commonDimensions: GroupByOption[] +): GroupByOption[] => { + if (groupBy.length === 0 || commonDimensions.length === 0) return []; + + return commonDimensions.filter((group) => { + return groupBy.includes(group.value); + }); +}; + +/** + * + * @param dashboardId The ID of the dashboard being queried + * @param serviceType The type of cloud service (e.g., 'linode', 'dbaas') + * @param globalDimensions - Common dimensions that are already selected at the dashboard level + * @param metric - The specific metric for which to retrieve available dimensions + * @returns A GroupByDimension object containing available options, default values, and loading state + */ +export const useWidgetDimension = ( + dashboardId: number | undefined, + serviceType: CloudPulseServiceType | undefined, + globalDimensions: GroupByOption[], + metric: string | undefined +): GroupByDimension => { + const { data: dashboard, isLoading: dashboardLoading } = + useCloudPulseDashboardByIdQuery(dashboardId); + const { data: metricDefinition, isLoading: metricLoading } = + useGetCloudPulseMetricDefinitionsByServiceType( + serviceType, + serviceType !== undefined + ); + + if (metricLoading || dashboardLoading) { + return { options: [], defaultValue: [], isLoading: true }; + } + + const metricDimensions: GroupByOption[] = + metricDefinition?.data + .find((def) => def.metric === metric) + ?.dimensions?.map(({ label, dimension_label }) => ({ + label, + value: dimension_label, + })) ?? []; + const defaultGroupBy = + dashboard?.widgets.find((widget) => widget.metric === metric)?.group_by ?? + []; + const options = metricDimensions.filter( + (metricDimension) => + !globalDimensions.some( + (dimension) => dimension.label === metricDimension.label + ) + ); + + const defaultValue = options.filter((options) => + defaultGroupBy.includes(options.value) + ); + + return { + options, + defaultValue, + isLoading: false, + }; +}; + +/** + * + * @param metricDefinition List of metric definitions, each containing a metric name and its associated dimensions. + * @returns transform dimension object with metric as key and dimensions as value + */ +export const getMetricDimensions = ( + metricDefinition: MetricDefinition[] +): MetricDimension => { + return metricDefinition.reduce((acc, { metric, dimensions }) => { + return { + ...acc, + [metric]: dimensions, + }; + }, {}); +}; + +/** + * + * @param metricDimensions An object where keys are metric names and values are arrays of dimensions associated with those metrics. + * @returns list of common dimensions across all metrics + */ +export const getCommonDimensions = ( + metricDimensions: MetricDimension +): GroupByOption[] => { + const metrics = Object.keys(metricDimensions); + if (metrics.length === 0) { + return []; + } + + // Get dimensions from first metric + const firstMetricDimensions = metricDimensions[metrics[0]]; + + // filter dimensions that exist in all metrics + return firstMetricDimensions + .filter(({ dimension_label: queried }) => { + return metrics.every((metric) => { + return metricDimensions[metric].some( + ({ dimension_label }) => dimension_label === queried + ); + }); + }) + .map(({ label, dimension_label }) => { + return { + label, + value: dimension_label, + }; + }); +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 8294202ac6c..43f00e898e8 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -380,7 +380,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { showDot showLegend={data.length !== 0} timezone={timezone} - unit={currentUnit} + unit={`${currentUnit}${unit.endsWith('ps') ? '/s' : ''}`} variant={variant} xAxis={{ tickFormat, tickGap: 60 }} /> diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx deleted file mode 100644 index 60943a5a9ef..00000000000 --- a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { destinationType } from '@linode/api-v4'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import { DestinationCreate } from './DestinationCreate'; - -describe('DestinationCreate', () => { - it('should render disabled Destination Type input with proper selection', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - - const destinationTypeAutocomplete = - screen.getByLabelText('Destination Type'); - - expect(destinationTypeAutocomplete).toBeDisabled(); - expect(destinationTypeAutocomplete).toHaveValue('Linode Object Storage'); - }); - - it('should render Destination Name input', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { label: '' }, - }, - }, - }); - - // Type the test value inside the input - const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test'); - - expect(destinationNameInput).toHaveValue('Test'); - }); - - it('should render Host input after adding a new destination name and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - - // Type the test value inside the input - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'Test'); - - expect(hostInput).toHaveValue('Test'); - }); - - it('should render Bucket input after adding a new destination name and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - - // Type the test value inside the input - const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); - - expect(bucketInput).toHaveValue('Test'); - }); - - it('should render Region input after adding a new destination name and allow to select an option', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - - const regionAutocomplete = screen.getByLabelText('Region'); - - // Open the dropdown - await userEvent.click(regionAutocomplete); - await userEvent.type(regionAutocomplete, 'US, Chi'); - - // Select the "US, Chicago, IL (us-ord)" option - const chicagoRegion = await screen.findByText('US, Chicago, IL (us-ord)'); - await userEvent.click(chicagoRegion); - - expect(regionAutocomplete).toHaveValue('US, Chicago, IL (us-ord)'); - }); - - it('should render Access Key ID input after adding a new destination name and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - - // Type the test value inside the input - const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); - - expect(accessKeyIDInput).toHaveValue('Test'); - }); - - it('should render Secret Access Key input after adding a new destination name and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - - // Type the test value inside the input - const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); - - expect(secretAccessKeyInput).toHaveValue('Test'); - }); - - it('should render Log Path Prefix input after adding a new destination name and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - - // Type the test value inside the input - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); - await userEvent.type(logPathPrefixInput, 'Test'); - - expect(logPathPrefixInput).toHaveValue('Test'); - }); -}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx deleted file mode 100644 index 1a49f6e87c4..00000000000 --- a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { destinationType } from '@linode/api-v4'; -import { useCreateDestinationMutation } from '@linode/queries'; -import { destinationSchema } from '@linode/validation'; -import { useNavigate } from '@tanstack/react-router'; -import * as React from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { LandingHeader } from 'src/components/LandingHeader'; -import { DestinationForm } from 'src/features/DataStream/Destinations/DestinationForm/DestinationForm'; - -import type { LandingHeaderProps } from 'src/components/LandingHeader'; -import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; - -export const DestinationCreate = () => { - const { mutateAsync: createDestination } = useCreateDestinationMutation(); - const navigate = useNavigate(); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/datastream/destinations/create', - crumbOverrides: [ - { - label: 'DataStream', - linkTo: '/datastream/destinations', - position: 1, - }, - ], - }, - removeCrumbX: 2, - title: 'Create Destination', - }; - - const form = useForm({ - defaultValues: { - type: destinationType.LinodeObjectStorage, - details: { - region: '', - }, - }, - mode: 'onBlur', - resolver: yupResolver(destinationSchema), - }); - - const onSubmit = () => { - const payload = form.getValues(); - createDestination(payload).then(() => { - navigate({ to: '/datastream/destinations' }); - }); - }; - - return ( - <> - - - - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx deleted file mode 100644 index f78db66ab0c..00000000000 --- a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { - screen, - waitFor, - waitForElementToBeRemoved, -} from '@testing-library/react'; -import React from 'react'; -import { describe } from 'vitest'; - -import { destinationFactory } from 'src/factories/datastream'; -import { DestinationEdit } from 'src/features/DataStream/Destinations/DestinationForm/DestinationEdit'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -const loadingTestId = 'circle-progress'; -const destinationId = 123; -const mockDestination = destinationFactory.build({ - id: destinationId, - label: `Destination ${destinationId}`, -}); - -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useParams: vi.fn().mockReturnValue({ destinationId: 123 }), - }; -}); - -describe('DestinationEdit', () => { - const assertInputHasValue = (inputLabel: string, inputValue: string) => { - expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); - }; - - it('should render edited destination when destination fetched properly', async () => { - server.use( - http.get(`*/monitor/streams/destinations/${destinationId}`, () => { - return HttpResponse.json(mockDestination); - }) - ); - - renderWithThemeAndHookFormContext({ - component: , - }); - - const loadingElement = screen.queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } - - assertInputHasValue('Destination Type', 'Linode Object Storage'); - await waitFor(() => { - assertInputHasValue('Destination Name', 'Destination 123'); - }); - assertInputHasValue('Host', '3000'); - assertInputHasValue('Bucket', 'Bucket Name'); - await waitFor(() => { - assertInputHasValue('Region', 'US, Chicago, IL (us-ord)'); - }); - assertInputHasValue('Access Key ID', 'Access Id'); - assertInputHasValue('Secret Access Key', 'Access Secret'); - assertInputHasValue('Log Path Prefix', 'file'); - }); -}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx deleted file mode 100644 index c1288dc2c06..00000000000 --- a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { destinationType } from '@linode/api-v4'; -import { Autocomplete, Box, Button, Paper, TextField } from '@linode/ui'; -import { useTheme } from '@mui/material/styles'; -import * as React from 'react'; -import type { SubmitHandler } from 'react-hook-form'; -import { useFormContext } from 'react-hook-form'; -import { Controller, useWatch } from 'react-hook-form'; - -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; -import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; -import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; - -import type { - DestinationFormType, - FormMode, -} from 'src/features/DataStream/Shared/types'; - -type DestinationFormProps = { - destinationId?: string; - mode: FormMode; - onSubmit: SubmitHandler; -}; - -export const DestinationForm = (props: DestinationFormProps) => { - const { mode, onSubmit, destinationId } = props; - const theme = useTheme(); - - const { control, handleSubmit } = useFormContext(); - - const selectedDestinationType = useWatch({ - control, - name: 'type', - }); - - return ( - <> - -
- {destinationId && ( - - )} - ( - { - field.onChange(value); - }} - options={destinationTypeOptions} - value={getDestinationTypeOption(field.value)} - /> - )} - rules={{ required: true }} - /> - ( - { - field.onChange(value); - }} - placeholder="Destination Name..." - value={field.value} - /> - )} - rules={{ required: true }} - /> - {selectedDestinationType === destinationType.LinodeObjectStorage && ( - - )} - -
- - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx deleted file mode 100644 index df6554a590b..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { destinationType, streamType } from '@linode/api-v4'; -import { screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect } from 'vitest'; - -import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import type { FormMode } from 'src/features/DataStream/Shared/types'; - -describe('StreamFormSubmitBar', () => { - const createStream = () => {}; - - const renderComponent = (mode: FormMode) => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - stream: { - type: streamType.AuditLogs, - details: {}, - }, - destination: { - type: destinationType.LinodeObjectStorage, - details: { - region: '', - }, - }, - }, - }, - }); - }; - - describe('when in create mode', () => { - it('should render checkout bar with enabled Create Stream button', async () => { - renderComponent('create'); - const submitButton = screen.getByText('Create Stream'); - - expect(submitButton).toBeEnabled(); - }); - }); - - describe('when in edit mode', () => { - it('should render checkout bar with enabled Edit Stream button', async () => { - renderComponent('edit'); - const submitButton = screen.getByText('Edit Stream'); - - expect(submitButton).toBeEnabled(); - }); - }); - - it('should render Delivery summary with destination type', () => { - renderComponent('create'); - const deliveryTitle = screen.getByText('Delivery'); - const deliveryType = screen.getByText('Linode Object Storage'); - - expect(deliveryTitle).toBeInTheDocument(); - expect(deliveryType).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx deleted file mode 100644 index 74d5eb83e2f..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Box, Button, Divider, Paper, Stack, Typography } from '@linode/ui'; -import * as React from 'react'; -import { useFormContext, useWatch } from 'react-hook-form'; - -import { - getDestinationTypeOption, - isFormInEditMode, -} from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; - -import type { FormMode } from 'src/features/DataStream/Shared/types'; -import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; - -type StreamFormSubmitBarProps = { - mode: FormMode; - onSubmit: () => void; -}; - -export const StreamFormSubmitBar = (props: StreamFormSubmitBarProps) => { - const { onSubmit, mode } = props; - - const { control } = useFormContext(); - const destinationType = useWatch({ control, name: 'destination.type' }); - - return ( - - - Stream Summary - - - Delivery - - {getDestinationTypeOption(destinationType)?.label ?? ''} - - - - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx deleted file mode 100644 index c5fa4ee0007..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { destinationType, streamType } from '@linode/api-v4'; -import { useCreateStreamMutation } from '@linode/queries'; -import { streamAndDestinationFormSchema } from '@linode/validation'; -import { useNavigate } from '@tanstack/react-router'; -import * as React from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { - LandingHeader, - type LandingHeaderProps, -} from 'src/components/LandingHeader'; -import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; -import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; -import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; - -import type { CreateStreamPayload } from '@linode/api-v4'; -import type { - StreamAndDestinationFormType, - StreamFormType, -} from 'src/features/DataStream/Streams/StreamForm/types'; - -export const StreamCreate = () => { - const { mutateAsync: createStream } = useCreateStreamMutation(); - const navigate = useNavigate(); - - const form = useForm({ - defaultValues: { - stream: { - type: streamType.AuditLogs, - details: {}, - }, - destination: { - type: destinationType.LinodeObjectStorage, - details: { - region: '', - }, - }, - }, - mode: 'onBlur', - resolver: yupResolver(streamAndDestinationFormSchema), - }); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/datastream/streams/create', - crumbOverrides: [ - { - label: 'DataStream', - linkTo: '/datastream/streams', - position: 1, - }, - ], - }, - removeCrumbX: 2, - title: 'Create Stream', - }; - - const onSubmit = () => { - const { - stream: { label, type, destinations, details }, - } = form.getValues(); - const payload: StreamFormType = { - label, - type, - destinations, - details: getStreamPayloadDetails(type, details), - }; - - createStream(payload as CreateStreamPayload).then(() => { - sendCreateStreamEvent('Stream Create Page'); - navigate({ to: '/datastream/streams' }); - }); - }; - - return ( - <> - - - - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx deleted file mode 100644 index 41699fa8205..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { - screen, - waitFor, - waitForElementToBeRemoved, -} from '@testing-library/react'; -import React from 'react'; -import { describe } from 'vitest'; - -import { destinationFactory, streamFactory } from 'src/factories/datastream'; -import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -const loadingTestId = 'circle-progress'; -const streamId = 123; -const mockDestinations = [destinationFactory.build({ id: 1 })]; -const mockStream = streamFactory.build({ - id: streamId, - label: `Data Stream ${streamId}`, - destinations: mockDestinations, -}); - -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useParams: vi.fn().mockReturnValue({ streamId: 123 }), - }; -}); - -describe('StreamEdit', () => { - const assertInputHasValue = (inputLabel: string, inputValue: string) => { - expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); - }; - - it('should render edited stream when stream fetched properly', async () => { - server.use( - http.get(`*/monitor/streams/${streamId}`, () => { - return HttpResponse.json(mockStream); - }), - http.get('*/monitor/streams/destinations', () => { - return HttpResponse.json(makeResourcePage(mockDestinations)); - }) - ); - - renderWithThemeAndHookFormContext({ - component: , - }); - - const loadingElement = screen.queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } - - await waitFor(() => { - assertInputHasValue('Name', 'Data Stream 123'); - }); - assertInputHasValue('Stream Type', 'Audit Logs'); - await waitFor(() => { - assertInputHasValue('Destination Type', 'Linode Object Storage'); - }); - assertInputHasValue('Destination Name', 'Destination 1'); - - // Host: - expect(screen.getByText('3000')).toBeVisible(); - // Bucket: - expect(screen.getByText('Bucket Name')).toBeVisible(); - // Region: - await waitFor(() => { - expect(screen.getByText('US, Chicago, IL')).toBeVisible(); - }); - // Access Key ID: - expect(screen.getByTestId('access-key-id')).toHaveTextContent( - '*****************' - ); - // Secret Access Key: - expect(screen.getByTestId('secret-access-key')).toHaveTextContent( - '*****************' - ); - // Log Path: - expect(screen.getByText('file')).toBeVisible(); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx deleted file mode 100644 index 9ec617576de..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { streamType } from '@linode/api-v4'; -import { Stack } from '@linode/ui'; -import Grid from '@mui/material/Grid'; -import * as React from 'react'; -import type { SubmitHandler } from 'react-hook-form'; -import { useFormContext, useWatch } from 'react-hook-form'; - -import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; -import { StreamFormDelivery } from 'src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery'; - -import { StreamFormClusters } from './StreamFormClusters'; -import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; - -import type { FormMode } from 'src/features/DataStream/Shared/types'; -import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; - -type StreamFormProps = { - mode: FormMode; - onSubmit: SubmitHandler; - streamId?: string; -}; - -export const StreamForm = (props: StreamFormProps) => { - const { mode, onSubmit, streamId } = props; - - const { control, handleSubmit } = - useFormContext(); - - const selectedStreamType = useWatch({ - control, - name: 'stream.type', - }); - - return ( -
- - - - - {selectedStreamType === streamType.LKEAuditLogs && ( - - )} - - - - - - - -
- ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx deleted file mode 100644 index b04eb9c9753..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { Box, Checkbox, Notice, Paper, Typography } from '@linode/ui'; -import { isNotNullOrUndefined, usePrevious } from '@linode/utilities'; -import React, { useEffect, useState } from 'react'; -import type { ControllerRenderProps } from 'react-hook-form'; -import { useWatch } from 'react-hook-form'; -import { Controller, useFormContext } from 'react-hook-form'; - -import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { TableCell } from 'src/components/TableCell'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow'; -import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableSortCell } from 'src/components/TableSortCell'; -import { clusters } from 'src/features/DataStream/Streams/StreamForm/StreamFormClustersData'; - -import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; - -// TODO: remove type after fetching the clusters will be done -export type Cluster = { - id: number; - label: string; - logGeneration: boolean; - region: string; -}; - -type OrderByKeys = 'label' | 'logGeneration' | 'region'; - -export const StreamFormClusters = () => { - const { control, setValue, formState } = - useFormContext(); - - const [order, setOrder] = useState<'asc' | 'desc'>('asc'); - const [orderBy, setOrderBy] = useState('label'); - const [searchText, setSearchText] = useState(''); - - const idsWithLogGenerationEnabled = clusters - .filter(({ logGeneration }) => logGeneration) - .map(({ id }) => id); - - const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ - control, - name: [ - 'stream.details.is_auto_add_all_clusters_enabled', - 'stream.details.cluster_ids', - ], - }); - const previousIsAutoAddAllClustersEnabled = usePrevious( - isAutoAddAllClustersEnabled - ); - - useEffect(() => { - setValue( - 'stream.details.cluster_ids', - isAutoAddAllClustersEnabled - ? idsWithLogGenerationEnabled - : clusterIds || [] - ); - }, []); - - useEffect(() => { - if ( - isNotNullOrUndefined(previousIsAutoAddAllClustersEnabled) && - isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled - ) { - setValue( - 'stream.details.cluster_ids', - isAutoAddAllClustersEnabled ? idsWithLogGenerationEnabled : [] - ); - } - }, [ - isAutoAddAllClustersEnabled, - idsWithLogGenerationEnabled, - previousIsAutoAddAllClustersEnabled, - setValue, - ]); - - const handleOrderChange = (newOrderBy: OrderByKeys) => { - if (orderBy === newOrderBy) { - setOrder(order === 'asc' ? 'desc' : 'asc'); - } else { - setOrderBy(newOrderBy); - setOrder('asc'); - } - }; - - const getTableContent = ( - field: ControllerRenderProps< - StreamAndDestinationFormType, - 'stream.details.cluster_ids' - > - ) => { - const selectedIds = field.value || []; - - const isAllSelected = - selectedIds.length === idsWithLogGenerationEnabled.length; - const isIndeterminate = selectedIds.length > 0 && !isAllSelected; - - const toggleAllClusters = () => - field.onChange(isAllSelected ? [] : idsWithLogGenerationEnabled); - - const toggleCluster = (toggledId: number) => { - const updatedClusterIds = selectedIds.includes(toggledId) - ? selectedIds.filter((selectedId) => selectedId !== toggledId) - : [...selectedIds, toggledId]; - - field.onChange(updatedClusterIds); - }; - - const filteredAndSortedClusters = clusters - .filter(({ label, region, logGeneration }) => { - const search = searchText.toLowerCase(); - const logStatus = logGeneration ? 'enabled' : 'disabled'; - - return ( - label.toLowerCase().includes(search) || - region.toLowerCase().includes(search) || - logStatus.includes(search) - ); - }) - .sort((a, b) => { - const asc = order === 'asc'; - - if (orderBy === 'label' || orderBy === 'region') { - const aValue = a[orderBy].toLowerCase(); - const bValue = b[orderBy].toLowerCase(); - if (aValue === bValue) return 0; - return asc ? (aValue < bValue ? -1 : 1) : aValue > bValue ? -1 : 1; - } - - if (orderBy === 'logGeneration') { - const aLogGenerationNumber = Number(a.logGeneration); - const bLogGenerationNumber = Number(b.logGeneration); - return asc - ? bLogGenerationNumber - aLogGenerationNumber - : aLogGenerationNumber - bLogGenerationNumber; - } - - return 0; - }); - - return ( - <> - - - - {!!filteredAndSortedClusters.length && ( - - )} - - handleOrderChange('label')} - label="label" - sx={{ width: '35%' }} - > - Cluster Name - - handleOrderChange('region')} - label="region" - sx={{ width: '35%' }} - > - Region - - handleOrderChange('logGeneration')} - label="logGeneration" - sx={{ width: '25%' }} - > - Log Generation - - - - - {filteredAndSortedClusters.length ? ( - filteredAndSortedClusters.map( - ({ label, region, id, logGeneration }) => ( - - - toggleCluster(id)} - /> - - {label} - {region} - - - - {logGeneration ? 'Enabled' : 'Disabled'} - - - - ) - ) - ) : ( - - )} - - - ); - }; - - return ( - - Clusters - - Disabling this option allows you to manually define which clusters will - be included in the stream. Stream will not be updated automatically with - newly configured clusters. - - ( - field.onChange(checked)} - sxFormLabel={{ ml: -1 }} - text="Automatically include all existing and recently configured clusters." - /> - )} - /> - setSearchText(value)} - placeholder="Search" - value={searchText} - /> - - - getTableContent(field)} - /> -
- {!isAutoAddAllClustersEnabled && - formState.errors.stream?.details?.cluster_ids?.message && ( - - )} -
-
- ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts deleted file mode 100644 index 246a5b189e6..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Cluster } from 'src/features/DataStream/Streams/StreamForm/StreamFormClusters'; - -export const clusters: Cluster[] = [ - { - label: 'prod-cluster-eu', - id: 1, - region: 'NL, Amsterdam', - logGeneration: true, - }, - { - label: 'gke-prod-europe-west1', - id: 2, - region: 'US, Atalanta, GA', - logGeneration: false, - }, - { - label: 'metrics-stream-cluster', - id: 3, - region: 'US, Chicago, IL', - logGeneration: true, - }, -]; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts deleted file mode 100644 index f028763eb85..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CreateStreamPayload } from '@linode/api-v4'; -import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; - -export interface StreamFormType - extends Omit { - destinations: (number | undefined)[]; -} - -export interface StreamAndDestinationFormType { - destination: DestinationFormType; - stream: StreamFormType; -} diff --git a/packages/manager/src/features/DataStream/dataStreamLandingLazyRoute.ts b/packages/manager/src/features/DataStream/dataStreamLandingLazyRoute.ts deleted file mode 100644 index 4cc2a268043..00000000000 --- a/packages/manager/src/features/DataStream/dataStreamLandingLazyRoute.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createLazyRoute } from '@tanstack/react-router'; - -import { DataStreamLanding } from 'src/features/DataStream/DataStreamLanding'; - -export const dataStreamLandingLazyRoute = createLazyRoute('/datastream')({ - component: DataStreamLanding, -}); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx index 74978b885b6..6672f518199 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx @@ -147,8 +147,8 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { loading={isLoading} noOptionsText="There are no VPCs in the selected region." onChange={(e, value) => { + onChange('private_network.subnet_id', null); // Always reset subnet selection when VPC changes if (!value) { - onChange('private_network.subnet_id', null); onChange('private_network.public_access', false); } onConfigurationChange?.(value ?? null); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts index 489da641902..d3cf4debf14 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts @@ -1,16 +1,6 @@ import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { DateCalendar } from '@mui/x-date-pickers'; -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()(() => ({ - timeAutocomplete: { - '.MuiBox-root': { - marginTop: '0', - }, - width: '140px', - }, -})); export const StyledDateCalendar = styled(DateCalendar, { label: 'StyledDateCalendar', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 18508468a8b..8565492ed79 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -1,11 +1,11 @@ import { useDatabaseQuery } from '@linode/queries'; import { - Autocomplete, Box, Button, Divider, Notice, Paper, + TimePicker, Typography, } from '@linode/ui'; import { @@ -24,7 +24,6 @@ import * as React from 'react'; import { StyledDateCalendar, StyledTypography, - useStyles, } from 'src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style'; import { isDateOutsideBackup, @@ -32,46 +31,26 @@ import { useIsDatabasesEnabled, } from 'src/features/Databases/utilities'; +import { + BACKUPS_INVALID_TIME_VALIDATON_TEXT, + BACKUPS_MAX_TIME_EXCEEDED_VALIDATON_TEXT, + BACKUPS_MIN_TIME_EXCEEDED_VALIDATON_TEXT, + BACKUPS_UNABLE_TO_RESTORE_TEXT, +} from '../../constants'; import { useDatabaseDetailContext } from '../DatabaseDetailContext'; import DatabaseBackupsDialog from './DatabaseBackupsDialog'; import DatabaseBackupsLegacy from './legacy/DatabaseBackupsLegacy'; +import type { TimeValidationError } from '@mui/x-date-pickers'; + export interface TimeOption { label: string; value: number; } -const TIME_OPTIONS: TimeOption[] = [ - { label: '00:00', value: 0 }, - { label: '01:00', value: 1 }, - { label: '02:00', value: 2 }, - { label: '03:00', value: 3 }, - { label: '04:00', value: 4 }, - { label: '05:00', value: 5 }, - { label: '06:00', value: 6 }, - { label: '07:00', value: 7 }, - { label: '08:00', value: 8 }, - { label: '09:00', value: 9 }, - { label: '10:00', value: 10 }, - { label: '11:00', value: 11 }, - { label: '12:00', value: 12 }, - { label: '13:00', value: 13 }, - { label: '14:00', value: 14 }, - { label: '15:00', value: 15 }, - { label: '16:00', value: 16 }, - { label: '17:00', value: 17 }, - { label: '18:00', value: 18 }, - { label: '19:00', value: 19 }, - { label: '20:00', value: 20 }, - { label: '21:00', value: 21 }, - { label: '22:00', value: 22 }, - { label: '23:00', value: 23 }, -]; - export type VersionOption = 'dateTime' | 'newest'; export const DatabaseBackups = () => { - const { classes } = useStyles(); const { disabled } = useDatabaseDetailContext(); const { databaseId, engine } = useParams({ from: '/databases/$engine/$databaseId', @@ -80,12 +59,11 @@ export const DatabaseBackups = () => { const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false); const [selectedDate, setSelectedDate] = React.useState(null); - const [selectedTime, setSelectedTime] = React.useState( - null - ); + const [selectedTime, setSelectedTime] = React.useState(null); const [versionOption, setVersionOption] = React.useState( isDatabasesV2GA ? 'newest' : 'dateTime' ); + const [timePickerError, setTimePickerError] = React.useState(''); const { data: database, @@ -96,37 +74,84 @@ export const DatabaseBackups = () => { const isDefaultDatabase = database?.platform === 'rdbms-default'; const oldestBackup = database?.oldest_restore_time - ? DateTime.fromISO(`${database.oldest_restore_time}Z`) + ? DateTime.fromISO(`${database.oldest_restore_time}`, { zone: 'utc' }) // Backend uses UTC, so we explicitly set this as the timezone : null; const unableToRestoreCopy = !oldestBackup - ? 'You can restore a backup after the first backup is completed.' + ? BACKUPS_UNABLE_TO_RESTORE_TEXT : ''; const onRestoreDatabase = () => { setIsRestoreDialogOpen(true); }; - const handleDateChange = (newDate: DateTime) => { - const isSelectedTimeInvalid = isTimeOutsideBackup( - selectedTime?.value, - newDate, - oldestBackup! - ); - // If the user has selcted a time then changes the date, - // that date + time might now be outside of the backup timeframe. - // Reset selectedTime to null so user can select a valid time. - if (isSelectedTimeInvalid) { - setSelectedTime(null); + /** + * Check whether date and time are within the valid range of available backups by providing the selected date and time. + * When the date and time selections are valid, clear any existing error messages for the time picker. + */ + const validateDateTime = (date: DateTime | null, time: DateTime | null) => { + if (date && time) { + const isSelectedTimeInvalid = isTimeOutsideBackup( + time, + date, + oldestBackup! + ); + + if (!isSelectedTimeInvalid) { + setTimePickerError(''); + } } + }; + const handleOnError = (error: TimeValidationError) => { + if (error) { + switch (error) { + case 'maxTime': + setTimePickerError(BACKUPS_MAX_TIME_EXCEEDED_VALIDATON_TEXT); + break; + case 'minTime': + setTimePickerError(BACKUPS_MIN_TIME_EXCEEDED_VALIDATON_TEXT); + break; + case 'invalidDate': + setSelectedTime(null); + setTimePickerError(BACKUPS_INVALID_TIME_VALIDATON_TEXT); + } + } + }; + + /** Stores changes to the year, month, and day of the DateTime object provided by the calendar */ + const handleDateChange = (newDate: DateTime) => { + validateDateTime(newDate, selectedTime); setSelectedDate(newDate); }; - const handleOnVersionOptionChange = (_: any, value: string) => { - setVersionOption(value as VersionOption); + /** Stores changes to the hours, minutes, and seconds of the DateTime object provided by the time picker */ + const handleTimeChange = (newTime: DateTime | null) => { + validateDateTime(selectedDate, newTime); + setSelectedTime(newTime); + }; + + const configureMinTime = () => { + const canApplyMinTime = !!oldestBackup && !!selectedDate; + const isOnMinDate = selectedDate?.day === oldestBackup?.day; + return canApplyMinTime && isOnMinDate ? oldestBackup : undefined; + }; + + const configureMaxTime = () => { + const today = DateTime.utc(); + const isOnMaxDate = today.day === selectedDate?.day; + return isOnMaxDate ? today : undefined; + }; + + const handleOnVersionOptionChange = ( + _: React.ChangeEvent, + value: VersionOption + ) => { + setVersionOption(value); setSelectedDate(null); + // Resetting state used for time picker setSelectedTime(null); + setTimePickerError(''); }; if (isDefaultDatabase) { @@ -202,43 +227,34 @@ export const DatabaseBackups = () => { - Time (UTC) - {/* TODO: Replace Time Select to the own custom date-time picker component when it's ready */} - Time (UTC) + - isTimeOutsideBackup( - option.value, - selectedDate!, - oldestBackup! - ) + errorText={ + versionOption === 'dateTime' && selectedDate + ? timePickerError + : undefined } - isOptionEqualToValue={(option, value) => - option.value === value.value + format="HH:mm:ss" + key={ + versionOption === 'dateTime' + ? 'time-picker-active' + : 'time-picker-disabled' } label="" - onChange={(_, newTime) => setSelectedTime(newTime ?? null)} - options={TIME_OPTIONS} - placeholder="Choose a time" - renderOption={(props, option) => { - const { key, ...rest } = props; - return ( -
  • - {option.label} -
  • - ); - }} - textFieldProps={{ - dataAttrs: { - 'data-qa-time-select': true, - }, + maxTime={configureMaxTime()} + minTime={configureMinTime()} + onChange={handleTimeChange} + onError={handleOnError} + sx={{ + width: '220px', }} - value={selectedTime ?? null} + timeSteps={{ hours: 1, minutes: 1, seconds: 1 }} + value={selectedTime} + views={['hours', 'minutes', 'seconds']} />
    @@ -249,7 +265,8 @@ export const DatabaseBackups = () => { buttonType="primary" data-qa-settings-button="restore" disabled={ - versionOption === 'dateTime' && (!selectedDate || !selectedTime) + versionOption === 'dateTime' && + (!selectedDate || !selectedTime || !!timePickerError) } onClick={onRestoreDatabase} > @@ -263,7 +280,7 @@ export const DatabaseBackups = () => { onClose={() => setIsRestoreDialogOpen(false)} open={isRestoreDialogOpen} selectedDate={selectedDate} - selectedTime={selectedTime?.value} + selectedTime={selectedTime} /> )} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx index c17356bc6ef..58e1875727a 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { toDatabaseFork, toFormatedDate } from '../../utilities'; +import { toDatabaseFork, toFormattedDate } from '../../utilities'; import type { Database } from '@linode/api-v4/lib/databases'; import type { DialogProps } from '@linode/ui'; @@ -18,7 +18,7 @@ interface Props extends Omit { onClose: () => void; open: boolean; selectedDate?: DateTime | null; - selectedTime?: number; + selectedTime?: DateTime | null; } export const DatabaseBackupDialog = (props: Props) => { @@ -27,7 +27,7 @@ export const DatabaseBackupDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const [isRestoring, setIsRestoring] = useState(false); - const formatedDate = toFormatedDate(selectedDate, selectedTime); + const formattedDate = toFormattedDate(selectedDate, selectedTime); const { error, mutateAsync: restore } = useRestoreFromBackupMutation( database.engine, @@ -55,10 +55,10 @@ export const DatabaseBackupDialog = (props: Props) => { - ({ marginBottom: theme.spacing(4) })}> + ({ marginBottom: theme.spacingFunction(32) })}> Restoring a backup creates a fork from this backup. If you proceed and the fork is created successfully, you should remove the original database cluster. Failing to do so will lead to additional billing diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx index a978da8d6c5..127882110f1 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx @@ -71,6 +71,8 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { initialValues, onSubmit: submitForm, validationSchema: updatePrivateNetworkSchema, + validateOnChange: true, + validateOnBlur: true, }); // TODO (UIE-8903): Replace deprecated Formik with React Hook Form const hasVPCConfigured = !!database?.private_network?.vpc_id; @@ -79,8 +81,12 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { values.private_network.subnet_id !== database?.private_network?.subnet_id || values.private_network.public_access !== database?.private_network?.public_access; + const hasValidSelection = + !!values.private_network.vpc_id && + !!values.private_network.subnet_id && + hasConfigChanged; - const isSaveDisabled = !dirty || !isValid || !hasConfigChanged; + const isSaveDisabled = !dirty || !isValid || !hasValidSelection; const { error: manageNetworkingError, @@ -95,6 +101,13 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { resetMutation?.(); }; + /** Resets the form after opening the unassign VPC dialog */ + const handleOnUnassign = () => { + onUnassign(); + resetForm(); + resetMutation?.(); + }; + return ( {manageNetworkingError && ( @@ -124,7 +137,7 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { buttonType="outlined" disabled={!hasVPCConfigured} loading={false} - onClick={onUnassign} + onClick={handleOnUnassign} > Unassign VPC diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx index 39b71128aa5..7d06322fc06 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx @@ -35,7 +35,7 @@ vi.mock('@linode/queries', async () => { beforeAll(() => mockMatchMedia()); const loadingTestId = 'circle-progress'; -const accountEndpoint = '*/v4/account'; +const accountEndpoint = '*/v4*/account'; const databaseInstancesEndpoint = '*/databases/instances'; const managedDBBetaCapability = 'Managed Databases Beta'; diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts index 597dfa0c67f..f6b47b107d3 100644 --- a/packages/manager/src/features/Databases/constants.ts +++ b/packages/manager/src/features/Databases/constants.ts @@ -41,6 +41,18 @@ export const RESIZE_DISABLED_DEDICATED_SHARED_PLAN_TABS_TEXT = export const RESIZE_DISABLED_SHARED_PLAN_TAB_LEGACY_TEXT = 'Resizing a 2-node cluster is only allowed with Dedicated plans.'; +export const BACKUPS_MAX_TIME_EXCEEDED_VALIDATON_TEXT = + 'Select a time from the past.'; + +export const BACKUPS_MIN_TIME_EXCEEDED_VALIDATON_TEXT = + 'No backup available for this point in time. Select a later time.'; + +export const BACKUPS_INVALID_TIME_VALIDATON_TEXT = + 'Specify the exact time in the format: hh:mm:ss.'; + +export const BACKUPS_UNABLE_TO_RESTORE_TEXT = + 'You can restore a backup after the first backup is completed.'; + // Links export const LEARN_MORE_LINK_LEGACY = 'https://techdocs.akamai.com/cloud-computing/docs/manage-access-controls'; diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index e9603762d48..42cf2343626 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -14,7 +14,7 @@ import { isDefaultDatabase, isLegacyDatabase, isTimeOutsideBackup, - toFormatedDate, + toFormattedDate, toISOString, upgradableVersions, useIsDatabasesEnabled, @@ -28,13 +28,12 @@ import type { Engine, PendingUpdates, } from '@linode/api-v4'; -import type { TimeOption } from 'src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups'; const setup = (capabilities: AccountCapability[], flags: any) => { const account = accountFactory.build({ capabilities }); server.use( - http.get('*/v4/account', () => { + http.get('*/v4*/account', () => { return HttpResponse.json(account); }) ); @@ -158,7 +157,7 @@ describe('useIsDatabasesEnabled', () => { it('should return correctly for V1 restricted user non-beta', async () => { server.use( - http.get('*/v4/account', () => { + http.get('*/v4*/account', () => { return HttpResponse.json({}, { status: 403 }); }) ); @@ -203,7 +202,7 @@ describe('useIsDatabasesEnabled', () => { it('should return correctly for V1 & V2 restricted user existing beta', async () => { server.use( - http.get('*/v4/account', () => { + http.get('*/v4*/account', () => { return HttpResponse.json({}, { status: 403 }); }) ); @@ -248,7 +247,7 @@ describe('useIsDatabasesEnabled', () => { it('should return correctly for V2 restricted user new beta', async () => { server.use( - http.get('*/v4/account', () => { + http.get('*/v4*/account', () => { return HttpResponse.json({}, { status: 403 }); }) ); @@ -293,7 +292,7 @@ describe('useIsDatabasesEnabled', () => { it('should return correctly for V2 restricted user GA', async () => { server.use( - http.get('*/v4/account', () => { + http.get('*/v4*/account', () => { return HttpResponse.json({}, { status: 403 }); }) ); @@ -363,34 +362,64 @@ describe('isDateOutsideBackup', () => { }); describe('isTimeOutsideBackup', () => { - it('should return true when hour + selected date is before oldest backup', () => { + it('should return true when selected date/time is before oldest backup', () => { const selectedDate = DateTime.fromISO('2024-10-02'); - const oldestBackup = DateTime.fromISO('2024-10-02T09:00:00Z'); - const result = isTimeOutsideBackup(8, selectedDate, oldestBackup); + const oldestBackup = DateTime.fromISO('2024-10-02T09:52:05', { + zone: 'utc', + }); + const selectedTime = oldestBackup.minus({ second: 1 }); + const result = isTimeOutsideBackup( + selectedTime, + selectedDate, + oldestBackup + ); expect(result).toEqual(true); }); - it('should return false when hour + selected date is equal to the oldest backup', () => { + it('should return false when selected date/time is equal to the oldest backup', () => { const selectedDate = DateTime.fromISO('2024-10-02'); - const oldestBackup = DateTime.fromISO('2024-10-02T09:00:00Z'); - const result = isTimeOutsideBackup(9, selectedDate, oldestBackup); + const oldestBackup = DateTime.fromISO('2024-10-02T09:12:11', { + zone: 'utc', + }); + const selectedTime = DateTime.fromObject({ + hour: 9, + minute: 12, + second: 11, + }); + const result = isTimeOutsideBackup( + selectedTime, + selectedDate, + oldestBackup + ); expect(result).toEqual(false); }); it('should return false when hour + selected date is after the oldest backup', () => { const selectedDate = DateTime.fromISO('2024-10-03'); - const oldestBackup = DateTime.fromISO('2024-10-02T09:00:00Z'); - const result = isTimeOutsideBackup(1, selectedDate, oldestBackup); + const oldestBackup = DateTime.fromISO('2024-10-03T09:03:05', { + zone: 'utc', + }); + + const selectedTime = DateTime.fromObject({ hour: 9, minute: 3, second: 6 }); + const result = isTimeOutsideBackup( + selectedTime, + selectedDate, + oldestBackup + ); expect(result).toEqual(false); }); }); -describe('toFormatedDate', () => { +describe('toFormattedDate', () => { it('should convert a date and time to the format YYYY-MM-DD HH:mm for the dialog', () => { const selectedDate = DateTime.fromObject({ day: 15, month: 1, year: 2025 }); - const selectedTime: TimeOption = { label: '14:00', value: 14 }; - const result = toFormatedDate(selectedDate, selectedTime.value); - expect(result).toContain('2025-01-15 14:00'); + const selectedTime = DateTime.fromObject({ + hour: 14, + minute: 12, + second: 32, + }); + const result = toFormattedDate(selectedDate, selectedTime); + expect(result).toContain('2025-01-15 14:12:32'); }); it('should handle newest full backup plus incremental option correctly in UTC', () => { const selectedDate = null; @@ -398,11 +427,12 @@ describe('toFormatedDate', () => { const mockTodayWithHours = DateTime.fromObject({ day: today.day, hour: today.hour, - minute: 0, + minute: today.minute, + second: today.second, month: today.month, year: today.year, - }).toFormat('yyyy-MM-dd HH:mm'); - const result = toFormatedDate(selectedDate, undefined); + }).toFormat('yyyy-MM-dd HH:mm:ss'); + const result = toFormattedDate(selectedDate, undefined); expect(result).toContain(mockTodayWithHours); }); }); @@ -410,9 +440,13 @@ describe('toFormatedDate', () => { describe('toISOString', () => { it('should convert a date and time to ISO string format', () => { const selectedDate = DateTime.fromObject({ day: 15, month: 5, year: 2023 }); - const selectedTime: TimeOption = { label: '14:00', value: 14 }; - const result = toISOString(selectedDate, selectedTime.value); - expect(result).toContain('2023-05-15T14:00'); + const selectedTime = DateTime.fromObject({ + hour: 14, + minute: 12, + second: 32, + }); + const result = toISOString(selectedDate, selectedTime); + expect(result).toContain('2023-05-15T14:12:32'); }); it('should handle midnight correctly', () => { @@ -421,16 +455,24 @@ describe('toISOString', () => { month: 12, year: 2023, }); - const selectedTime: TimeOption = { label: '12:00 AM', value: 0 }; - const result = toISOString(selectedDate, selectedTime.value); - expect(result).toContain('2023-12-31T00:00'); + const selectedTime = DateTime.fromObject({ + hour: 0, + minute: 0, + second: 0, + }); + const result = toISOString(selectedDate, selectedTime); + expect(result).toContain('2023-12-31T00:00:00'); }); it('should handle noon correctly', () => { const selectedDate = DateTime.fromObject({ day: 1, month: 1, year: 2024 }); - const selectedTime: TimeOption = { label: '12:00 PM', value: 12 }; - const result = toISOString(selectedDate, selectedTime.value); - expect(result).toContain('2024-01-01T12:00'); + const selectedTime = DateTime.fromObject({ + hour: 12, + minute: 0, + second: 0, + }); + const result = toISOString(selectedDate, selectedTime); + expect(result).toContain('2024-01-01T12:00:00'); }); }); diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 9a0946196b9..065d8f99856 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -118,8 +118,8 @@ export const useIsDatabasesEnabled = (): IsDatabasesEnabled => { * @param {DateTime} date - The date you want to check. * @param {DateTime | null} oldestBackup - The date of the oldest backup. If there are no backups (i.e., `null`), the function will return `true`. * @returns {boolean} - * - `true` if the date is before the oldest backup or after today. - * - `false` if the date is within the range between the oldest backup and today. + * - `true` if the date/time is before the oldest backup or after today. + * - `false` if the date/time is within the range between the oldest backup and today. */ export const isDateOutsideBackup = ( date: DateTime, @@ -135,20 +135,20 @@ export const isDateOutsideBackup = ( /** * Check if the time added to the selectedDate is outside the backup window. * - * @param hour + * @param selectedTime * @param selectedDate * @param oldestBackup - * @returns true when the selectedDate + hour is before the oldest backup DateTime or later than DateTime.now() + * @returns true when the selectedDate and selectedTime is before the oldest backup DateTime or later than the current date/time using DateTime.utc() */ export const isTimeOutsideBackup = ( - time: number | undefined, + selectedTime: DateTime | null, selectedDate: DateTime, oldestBackup: DateTime ) => { - if (time == null) { + if (selectedTime === null) { return false; } - const selectedDateTime = toSelectedDateTime(selectedDate, time); + const selectedDateTime = toSelectedDateTime(selectedDate, selectedTime); return isDateOutsideBackup(selectedDateTime, oldestBackup); }; @@ -159,45 +159,48 @@ export const isTimeOutsideBackup = ( * @param selectedTime * @returns date as a string value in ISO format. */ -export const toISOString = (selectedDate: DateTime, selectedTime: number) => { +export const toISOString = (selectedDate: DateTime, selectedTime: DateTime) => { const selectedDateTime = toSelectedDateTime(selectedDate, selectedTime); return selectedDateTime.toISO({ includeOffset: false }); }; export const toSelectedDateTime = ( selectedDate: DateTime, - time: number = 0 + selectedTime: DateTime | null ) => { const isoDate = selectedDate?.toISODate(); const isoTime = DateTime.utc() - .set({ hour: time, minute: 0 }) + .set({ + hour: selectedTime?.hour, + minute: selectedTime?.minute, + second: selectedTime?.second, + }) ?.toISOTime({ includeOffset: false }); return DateTime.fromISO(`${isoDate}T${isoTime}`, { zone: 'UTC' }); }; /** * Format the selectedDate and time as a modified (i.e. shortened) ISO format - * If no date/time is provided it will default to DateTime.now() + * If no date/time is provided it will default to the current date/time using DateTime.utc() * * @param selectedDate * @param selectedTime * @returns date string in the format 'YYYY-MM-ddThh:mm:ss' */ -export const toFormatedDate = ( +export const toFormattedDate = ( selectedDate?: DateTime | null, - selectedTime?: number + selectedTime?: DateTime | null ) => { const today = DateTime.utc(); const isoDate = selectedDate && selectedTime ? toISOString(selectedDate!, selectedTime) - : toISOString(today, today.hour); - - return `${isoDate?.split('T')[0]} ${isoDate?.split('T')[1].slice(0, 5)}`; + : toISOString(today, today); + return `${isoDate?.split('T')[0]} ${isoDate?.split('T')[1].slice(0, 8)}`; }; /** - * Convert the sourceId and optional selectedDate and selecteTime into the DatabaseFork payload. + * Convert the sourceId and optional selectedDate and selectedTime into the DatabaseFork payload. * * @param sourceId * @param selectedDate @@ -207,7 +210,7 @@ export const toFormatedDate = ( export const toDatabaseFork = ( sourceId: number, selectedDate?: DateTime | null, - selectedTime?: number + selectedTime?: DateTime | null ) => { const fork: DatabaseFork = { source: sourceId, diff --git a/packages/manager/src/features/DataStream/DataStreamLanding.tsx b/packages/manager/src/features/Delivery/DeliveryLanding.tsx similarity index 83% rename from packages/manager/src/features/DataStream/DataStreamLanding.tsx rename to packages/manager/src/features/Delivery/DeliveryLanding.tsx index 5480d990ee6..c3dfb88206c 100644 --- a/packages/manager/src/features/DataStream/DataStreamLanding.tsx +++ b/packages/manager/src/features/Delivery/DeliveryLanding.tsx @@ -25,30 +25,31 @@ const Streams = React.lazy(() => })) ); -export const DataStreamLanding = React.memo(() => { +export const DeliveryLanding = React.memo(() => { const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { - pathname: '/datastream', + pathname: '/logs/delivery', }, - entity: 'DataStream', - title: 'DataStream', + removeCrumbX: 1, + entity: 'Delivery', + title: 'Delivery', }; const { handleTabChange, tabIndex, tabs } = useTabs([ { - to: '/datastream/streams', + to: '/logs/delivery/streams', title: 'Streams', }, { - to: '/datastream/destinations', + to: '/logs/delivery/destinations', title: 'Destinations', }, ]); return ( <> - - + + diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx similarity index 82% rename from packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.test.tsx rename to packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx index 492fec86b80..c5d6537d8b3 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx @@ -2,8 +2,8 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as React from 'react'; -import { destinationFactory } from 'src/factories/datastream'; -import { DestinationActionMenu } from 'src/features/DataStream/Destinations/DestinationActionMenu'; +import { destinationFactory } from 'src/factories/delivery'; +import { DestinationActionMenu } from 'src/features/Delivery/Destinations/DestinationActionMenu'; import { renderWithTheme } from 'src/utilities/testHelpers'; const fakeHandler = vi.fn(); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx similarity index 100% rename from packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.tsx rename to packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx new file mode 100644 index 00000000000..38ee59db256 --- /dev/null +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -0,0 +1,164 @@ +import { destinationType } from '@linode/api-v4'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { DestinationCreate } from './DestinationCreate'; + +import type { CreateDestinationPayload } from '@linode/api-v4'; + +describe('DestinationCreate', () => { + const renderDestinationCreate = ( + defaultValues?: Partial + ) => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + type: destinationType.LinodeObjectStorage, + ...defaultValues, + }, + }, + }); + }; + + it('should render disabled Destination Type input with proper selection', async () => { + renderDestinationCreate(); + + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); + + expect(destinationTypeAutocomplete).toBeDisabled(); + expect(destinationTypeAutocomplete).toHaveValue('Linode Object Storage'); + }); + + it( + 'should render all inputs for Linode Object Storage type and allow to fill out them', + { timeout: 10000 }, + async () => { + renderDestinationCreate({ label: '' }); + + const destinationNameInput = screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test'); + const hostInput = screen.getByLabelText('Host'); + await userEvent.type(hostInput, 'Test'); + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'Test'); + const regionAutocomplete = screen.getByLabelText('Region'); + await userEvent.click(regionAutocomplete); + await userEvent.type(regionAutocomplete, 'US, Chi'); + const chicagoRegion = await screen.findByText('US, Chicago, IL (us-ord)'); + await userEvent.click(chicagoRegion); + const accessKeyIDInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIDInput, 'Test'); + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + await userEvent.type(logPathPrefixInput, 'Test'); + + expect(destinationNameInput).toHaveValue('Test'); + expect(hostInput).toHaveValue('Test'); + expect(bucketInput).toHaveValue('Test'); + expect(regionAutocomplete).toHaveValue('US, Chicago, IL (us-ord)'); + expect(accessKeyIDInput).toHaveValue('Test'); + expect(secretAccessKeyInput).toHaveValue('Test'); + expect(logPathPrefixInput).toHaveValue('Test'); + } + ); + + describe('given Test Connection and Create Destination buttons', () => { + const testConnectionButtonText = 'Test Connection'; + const createDestinationButtonText = 'Create Destination'; + + const fillOutForm = async () => { + const destinationNameInput = screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test'); + const hostInput = screen.getByLabelText('Host'); + await userEvent.type(hostInput, 'Test'); + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'Test'); + const regionAutocomplete = screen.getByLabelText('Region'); + await userEvent.click(regionAutocomplete); + await userEvent.type(regionAutocomplete, 'US, Chi'); + const chicagoRegion = await screen.findByText('US, Chicago, IL (us-ord)'); + await userEvent.click(chicagoRegion); + const accessKeyIDInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIDInput, 'Test'); + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + await userEvent.type(logPathPrefixInput, 'Test'); + }; + + describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Create Destination button and perform proper call when it's clicked", async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json({}); + }) + ); + + renderDestinationCreate(); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(createDestinationButton).toBeEnabled(); + }); + + await userEvent.click(createDestinationButton); + expect(createDestinationSpy).toHaveBeenCalled(); + }); + }); + + describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { + const verifyDestinationSpy = vi.fn(); + + it('should not enable Create Destination button', async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }) + ); + + renderDestinationCreate(); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + expect(createDestinationButton).toBeDisabled(); + }); + }); + }); +}); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx new file mode 100644 index 00000000000..15b997bb0c1 --- /dev/null +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx @@ -0,0 +1,89 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType } from '@linode/api-v4'; +import { useCreateDestinationMutation } from '@linode/queries'; +import { destinationSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { DestinationForm } from 'src/features/Delivery/Destinations/DestinationForm/DestinationForm'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; +import type { DestinationFormType } from 'src/features/Delivery/Shared/types'; + +export const DestinationCreate = () => { + const { mutateAsync: createDestination, isPending: isCreatingDestination } = + useCreateDestinationMutation(); + const navigate = useNavigate(); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/logs/delivery/destinations/create', + crumbOverrides: [ + { + label: 'Delivery', + linkTo: '/logs/delivery/destinations', + position: 1, + }, + ], + }, + removeCrumbX: [1, 2], + title: 'Create Destination', + }; + + const form = useForm({ + defaultValues: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + mode: 'onBlur', + resolver: yupResolver(destinationSchema), + }); + + const onSubmit = () => { + const destination = form.getValues(); + + createDestination(destination) + .then(() => { + navigate({ to: '/logs/delivery/destinations' }); + return enqueueSnackbar( + `Destination ${destination.label} created successfully`, + { + variant: 'success', + } + ); + }) + .catch((errors) => { + for (const error of errors) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + form.setError('root', { message: error.reason }); + } + } + + return enqueueSnackbar('There was an issue creating your destination', { + variant: 'error', + }); + }); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx new file mode 100644 index 00000000000..3ac16a14b39 --- /dev/null +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -0,0 +1,147 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { destinationFactory } from 'src/factories/delivery'; +import { DestinationEdit } from 'src/features/Delivery/Destinations/DestinationForm/DestinationEdit'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const destinationId = 123; +const mockDestination = destinationFactory.build({ + id: destinationId, + label: `Destination ${destinationId}`, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ destinationId: 123 }), + }; +}); + +describe('DestinationEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited destination when destination fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/destinations/${destinationId}`, () => { + return HttpResponse.json(mockDestination); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + + assertInputHasValue('Destination Type', 'Linode Object Storage'); + await waitFor(() => { + assertInputHasValue('Destination Name', 'Destination 123'); + }); + assertInputHasValue('Host', '3000'); + assertInputHasValue('Bucket', 'Bucket Name'); + await waitFor(() => { + assertInputHasValue('Region', 'US, Chicago, IL (us-ord)'); + }); + assertInputHasValue('Access Key ID', 'Access Id'); + assertInputHasValue('Secret Access Key', 'Access Secret'); + assertInputHasValue('Log Path Prefix', 'file'); + }); + + describe('given Test Connection and Edit Destination buttons', () => { + const testConnectionButtonText = 'Test Connection'; + const editDestinationButtonText = 'Edit Destination'; + const editDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + describe('when Test Connection button clicked and connection verified positively', () => { + it("should enable Edit Destination button and perform proper call when it's clicked", async () => { + server.use( + http.get(`*/monitor/streams/destinations/${destinationId}`, () => { + return HttpResponse.json(mockDestination); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.put(`*/monitor/streams/destinations/${destinationId}`, () => { + editDestinationSpy(); + return HttpResponse.json({}); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const editDestinationButton = screen.getByRole('button', { + name: editDestinationButtonText, + }); + + expect(editDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(editDestinationButton).toBeEnabled(); + }); + + await userEvent.click(editDestinationButton); + expect(editDestinationSpy).toHaveBeenCalled(); + }); + }); + + describe('when Test Connection button clicked and connection verified negatively', () => { + it('should not enable Edit Destination button', async () => { + server.use( + http.get(`*/monitor/streams/destinations/${destinationId}`, () => { + return HttpResponse.json(mockDestination); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const editDestinationButton = screen.getByRole('button', { + name: editDestinationButtonText, + }); + + expect(editDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(editDestinationButton).toBeDisabled(); + }); + }); + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx similarity index 77% rename from packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx rename to packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx index 21bc2480e39..17c777e4455 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx @@ -14,18 +14,19 @@ import { FormProvider, useForm } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { DestinationForm } from 'src/features/DataStream/Destinations/DestinationForm/DestinationForm'; +import { DestinationForm } from 'src/features/Delivery/Destinations/DestinationForm/DestinationForm'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; -import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; +import type { DestinationFormType } from 'src/features/Delivery/Shared/types'; export const DestinationEdit = () => { const navigate = useNavigate(); const { destinationId } = useParams({ - from: '/datastream/destinations/$destinationId/edit', + from: '/logs/delivery/destinations/$destinationId/edit', }); - const { mutateAsync: updateDestination } = useUpdateDestinationMutation(); + const { mutateAsync: updateDestination, isPending: isUpdatingDestination } = + useUpdateDestinationMutation(); const { data: destination, isLoading, @@ -34,17 +35,17 @@ export const DestinationEdit = () => { const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { - pathname: '/datastream/destinations/edit', + pathname: '/logs/delivery/destinations/edit', crumbOverrides: [ { - label: 'DataStream', - linkTo: '/datastream/destinations', + label: 'Delivery', + linkTo: '/logs/delivery/destinations', position: 1, }, ], }, - removeCrumbX: 2, - title: 'Edit Destination', + removeCrumbX: [1, 2], + title: `Edit Destination ${destinationId}`, }; const form = useForm({ @@ -67,16 +68,16 @@ export const DestinationEdit = () => { }, [destination, form]); const onSubmit = () => { - const payload = { + const destination = { id: destinationId, ...form.getValues(), }; - updateDestination(payload) + updateDestination(destination) .then(() => { - navigate({ to: '/datastream/destinations' }); + navigate({ to: '/logs/delivery/destinations' }); return enqueueSnackbar( - `Destination ${payload.label} edited successfully`, + `Destination ${destination.label} edited successfully`, { variant: 'success', } @@ -113,7 +114,7 @@ export const DestinationEdit = () => { {!isLoading && !error && ( diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx new file mode 100644 index 00000000000..eda147d4ae9 --- /dev/null +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -0,0 +1,107 @@ +import { destinationType } from '@linode/api-v4'; +import { Autocomplete, Paper, TextField } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import { useEffect } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import { Controller, useWatch } from 'react-hook-form'; + +import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm'; +import { FormSubmitBar } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar'; +import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; +import { useVerifyDestination } from 'src/features/Delivery/Shared/useVerifyDestination'; + +import type { + DestinationFormType, + FormMode, +} from 'src/features/Delivery/Shared/types'; + +interface DestinationFormProps { + isSubmitting: boolean; + mode: FormMode; + onSubmit: SubmitHandler; +} + +export const DestinationForm = (props: DestinationFormProps) => { + const { mode, isSubmitting, onSubmit } = props; + + const { + verifyDestination, + isPending: isVerifyingDestination, + destinationVerified, + setDestinationVerified, + } = useVerifyDestination(); + + const { control, handleSubmit } = useFormContext(); + const destination = useWatch({ + control, + }) as DestinationFormType; + + useEffect(() => { + setDestinationVerified(false); + }, [destination, setDestinationVerified]); + + return ( +
    + + + + ( + { + field.onChange(value); + }} + options={destinationTypeOptions} + value={getDestinationTypeOption(field.value)} + /> + )} + /> + ( + { + field.onChange(value); + }} + placeholder="Destination Name" + value={field.value} + /> + )} + /> + {destination.type === destinationType.LinodeObjectStorage && ( + + )} + + + + + verifyDestination(destination) + )} + /> + + +
    + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts b/packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationCreateLazyRoute.ts similarity index 52% rename from packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts rename to packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationCreateLazyRoute.ts index 0f044abab07..068fcbb694b 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationCreateLazyRoute.ts @@ -1,9 +1,9 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { DestinationCreate } from 'src/features/DataStream/Destinations/DestinationForm/DestinationCreate'; +import { DestinationCreate } from 'src/features/Delivery/Destinations/DestinationForm/DestinationCreate'; export const destinationCreateLazyRoute = createLazyRoute( - '/datastream/destinations/create' + '/logs/delivery/destinations/create' )({ component: DestinationCreate, }); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts b/packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationEditLazyRoute.ts similarity index 50% rename from packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts rename to packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationEditLazyRoute.ts index 6d1ec02cf30..093277b3020 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/destinationEditLazyRoute.ts @@ -1,9 +1,9 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { DestinationEdit } from 'src/features/DataStream/Destinations/DestinationForm/DestinationEdit'; +import { DestinationEdit } from 'src/features/Delivery/Destinations/DestinationForm/DestinationEdit'; export const destinationEditLazyRoute = createLazyRoute( - '/datastream/destinations/$destinationId/edit' + '/logs/delivery/destinations/$destinationId/edit' )({ component: DestinationEdit, }); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx similarity index 71% rename from packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx rename to packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx index 3f4c0f1ecfc..8f68e259054 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx @@ -2,10 +2,11 @@ import { Hidden } from '@linode/ui'; import * as React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { DestinationActionMenu } from 'src/features/DataStream/Destinations/DestinationActionMenu'; +import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { DestinationActionMenu } from 'src/features/Delivery/Destinations/DestinationActionMenu'; import type { DestinationHandlers } from './DestinationActionMenu'; import type { Destination } from '@linode/api-v4'; @@ -17,14 +18,19 @@ interface DestinationTableRowProps extends DestinationHandlers { export const DestinationTableRow = React.memo( (props: DestinationTableRowProps) => { const { destination, onDelete, onEdit } = props; + const { id } = destination; return ( - - {destination.label} + + + + {destination.label} + + {getDestinationTypeOption(destination.type)?.label} - {destination.id} + {id} diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx similarity index 92% rename from packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx rename to packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx index 1d16ac50963..cab58d9319e 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx @@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories/datastream'; -import { DestinationsLanding } from 'src/features/DataStream/Destinations/DestinationsLanding'; +import { destinationFactory } from 'src/factories/delivery'; +import { DestinationsLanding } from 'src/features/Delivery/Destinations/DestinationsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; @@ -40,13 +40,12 @@ const destinations = [destination, ...destinationFactory.buildList(30)]; describe('Destinations Landing Table', () => { const renderComponentAndWaitForLoadingComplete = async () => { renderWithTheme(, { - initialRoute: '/datastream/destinations', + initialRoute: '/logs/delivery/destinations', }); const loadingElement = screen.queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); }; beforeEach(() => { @@ -126,7 +125,7 @@ describe('Destinations Landing Table', () => { await clickOnActionMenuItem('Edit'); expect(mockNavigate).toHaveBeenCalledWith({ - to: '/datastream/destinations/1/edit', + to: '/logs/delivery/destinations/1/edit', }); }); }); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx similarity index 88% rename from packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx rename to packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx index 48ff5497690..fb42168268c 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx @@ -16,21 +16,21 @@ import { DESTINATIONS_TABLE_DEFAULT_ORDER, DESTINATIONS_TABLE_DEFAULT_ORDER_BY, DESTINATIONS_TABLE_PREFERENCE_KEY, -} from 'src/features/DataStream/Destinations/constants'; -import { DestinationsLandingEmptyState } from 'src/features/DataStream/Destinations/DestinationsLandingEmptyState'; -import { DestinationTableRow } from 'src/features/DataStream/Destinations/DestinationTableRow'; -import { DataStreamTabHeader } from 'src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader'; +} from 'src/features/Delivery/Destinations/constants'; +import { DestinationsLandingEmptyState } from 'src/features/Delivery/Destinations/DestinationsLandingEmptyState'; +import { DestinationTableRow } from 'src/features/Delivery/Destinations/DestinationTableRow'; +import { DeliveryTabHeader } from 'src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import type { Destination } from '@linode/api-v4'; -import type { DestinationHandlers } from 'src/features/DataStream/Destinations/DestinationActionMenu'; +import type { DestinationHandlers } from 'src/features/Delivery/Destinations/DestinationActionMenu'; export const DestinationsLanding = () => { const navigate = useNavigate(); const { mutateAsync: deleteDestination } = useDeleteDestinationMutation(); - const destinationsUrl = '/datastream/destinations'; + const destinationsUrl = '/logs/delivery/destinations'; const search = useSearch({ from: destinationsUrl, shouldThrow: false, @@ -84,7 +84,7 @@ export const DestinationsLanding = () => { }; const navigateToCreate = () => { - navigate({ to: '/datastream/destinations/create' }); + navigate({ to: '/logs/delivery/destinations/create' }); }; if (isLoading) { @@ -104,7 +104,7 @@ export const DestinationsLanding = () => { } const handleEdit = ({ id }: Destination) => { - navigate({ to: `/datastream/destinations/${id}/edit` }); + navigate({ to: `/logs/delivery/destinations/${id}/edit` }); }; const handleDelete = ({ id, label }: Destination) => { @@ -136,7 +136,7 @@ export const DestinationsLanding = () => { return ( <> - { direction={order} handleClick={handleOrderChange} label="label" + sx={{ width: '30%' }} > Name diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyState.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx similarity index 87% rename from packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyState.tsx rename to packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx index 1fe38d71f6e..23226180731 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyState.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; -import ComputeIcon from 'src/assets/icons/entityIcons/compute.svg'; +import MonitorIcon from 'src/assets/icons/entityIcons/monitor.svg'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; import { gettingStartedGuides, headers, linkAnalyticsEvent, -} from 'src/features/DataStream/Destinations/DestinationsLandingEmptyStateData'; +} from 'src/features/Delivery/Destinations/DestinationsLandingEmptyStateData'; import { sendEvent } from 'src/utilities/analytics/utils'; interface DestinationsLandingEmptyStateProps { @@ -38,7 +38,7 @@ export const DestinationsLandingEmptyState = ( ]} gettingStartedGuidesData={gettingStartedGuides} headers={headers} - icon={ComputeIcon} + icon={MonitorIcon} linkAnalyticsEvent={linkAnalyticsEvent} /> diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyStateData.ts b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyStateData.ts similarity index 100% rename from packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyStateData.ts rename to packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyStateData.ts diff --git a/packages/manager/src/features/DataStream/Destinations/constants.ts b/packages/manager/src/features/Delivery/Destinations/constants.ts similarity index 100% rename from packages/manager/src/features/DataStream/Destinations/constants.ts rename to packages/manager/src/features/Delivery/Destinations/constants.ts diff --git a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.test.tsx similarity index 76% rename from packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx rename to packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.test.tsx index d5d81c33419..ef36ff3d93c 100644 --- a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx +++ b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.test.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; -import { DataStreamTabHeader } from 'src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader'; -import { streamStatusOptions } from 'src/features/DataStream/Shared/types'; +import { DeliveryTabHeader } from 'src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader'; +import { streamStatusOptions } from 'src/features/Delivery/Shared/types'; import { renderWithTheme } from 'src/utilities/testHelpers'; -describe('DataStreamTabHeader', () => { +describe('DeliveryTabHeader', () => { it('should render a create button', () => { const { getByText } = renderWithTheme( - null} /> + null} /> ); getByText('Create Stream'); @@ -15,7 +15,7 @@ describe('DataStreamTabHeader', () => { it('should render a disabled create button', () => { const { getByText } = renderWithTheme( - null} @@ -30,7 +30,7 @@ describe('DataStreamTabHeader', () => { it('should render a search input', () => { const { getByPlaceholderText } = renderWithTheme( - null} @@ -45,7 +45,7 @@ describe('DataStreamTabHeader', () => { it('should render a select input', () => { const selectValue = streamStatusOptions[0].value; const { getByPlaceholderText, getByLabelText } = renderWithTheme( - null} selectList={streamStatusOptions} diff --git a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx similarity index 95% rename from packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx rename to packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx index c44571be93d..6f0182e984e 100644 --- a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx +++ b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx @@ -7,9 +7,9 @@ import * as React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import type { Theme } from '@mui/material/styles'; -import type { LabelValueOption } from 'src/features/DataStream/Shared/types'; +import type { LabelValueOption } from 'src/features/Delivery/Shared/types'; -export interface DataStreamTabHeaderProps { +export interface DeliveryTabHeaderProps { buttonDataAttrs?: { [key: string]: boolean | string }; createButtonText?: string; disabledCreateButton?: boolean; @@ -25,7 +25,7 @@ export interface DataStreamTabHeaderProps { spacingBottom?: 0 | 4 | 16 | 24; } -export const DataStreamTabHeader = ({ +export const DeliveryTabHeader = ({ buttonDataAttrs, createButtonText, disabledCreateButton, @@ -39,7 +39,7 @@ export const DataStreamTabHeader = ({ selectValue, searchValue, onSearch, -}: DataStreamTabHeaderProps) => { +}: DeliveryTabHeaderProps) => { const theme = useTheme(); const xsDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); diff --git a/packages/manager/src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx similarity index 90% rename from packages/manager/src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm.tsx rename to packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx index 55f84d5fcac..cf599ef9374 100644 --- a/packages/manager/src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx @@ -6,7 +6,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import { HideShowText } from 'src/components/PasswordInput/HideShowText'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { PathSample } from 'src/features/DataStream/Shared/PathSample'; +import { PathSample } from 'src/features/Delivery/Shared/PathSample'; import { useFlags } from 'src/hooks/useFlags'; interface DestinationLinodeObjectStorageDetailsFormProps { @@ -51,11 +51,10 @@ export const DestinationLinodeObjectStorageDetailsForm = ({ onChange={(value) => { field.onChange(value); }} - placeholder="Host..." + placeholder="Host" value={field.value} /> )} - rules={{ required: true }} /> { field.onChange(value); }} - placeholder="Bucket..." + placeholder="Bucket" value={field.value} /> )} - rules={{ required: true }} /> field.onChange(region.id)} + onChange={(_, region) => { + field.onChange(region.id); + field.onBlur(); + }} regionFilter="core" regions={regions ?? []} value={field.value} @@ -103,7 +104,7 @@ export const DestinationLinodeObjectStorageDetailsForm = ({ label="Access Key ID" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} - placeholder="Access Key ID..." + placeholder="Access Key ID" value={field.value} /> )} @@ -118,7 +119,7 @@ export const DestinationLinodeObjectStorageDetailsForm = ({ label="Secret Access Key" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} - placeholder="Secret Access Key..." + placeholder="Secret Access Key" value={field.value} /> )} @@ -136,14 +137,14 @@ export const DestinationLinodeObjectStorageDetailsForm = ({ label="Log Path Prefix" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} - placeholder="Log Path Prefix..." + placeholder="Log Path Prefix" sx={{ width: 416 }} value={field.value} /> )} /> diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts b/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.styles.tsx similarity index 100% rename from packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts rename to packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.styles.tsx diff --git a/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.test.tsx b/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.test.tsx new file mode 100644 index 00000000000..d246a98ab7f --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.test.tsx @@ -0,0 +1,120 @@ +import { destinationType, streamType } from '@linode/api-v4'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { FormSubmitBar } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import type { FormMode, FormType } from 'src/features/Delivery/Shared/types'; + +describe('StreamFormSubmitBar', () => { + const mockFn = () => {}; + + const renderComponent = ( + formType: FormType, + mode: FormMode, + blockSubmit = false, + connectionTested = false + ) => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + }, + }); + }; + + describe('when in stream form', () => { + describe('and in create mode', () => { + const createStreamButtonText = 'Create Stream'; + describe('and blockSubmit is true and connection is not tested', () => { + it('should disabled Create Stream button', async () => { + renderComponent('stream', 'create', true); + const submitButton = screen.getByText(createStreamButtonText); + + expect(submitButton).toBeDisabled(); + }); + }); + + describe('and blockSubmit is true and connection is tested', () => { + it('should enabled Create Stream button', async () => { + renderComponent('stream', 'create', true, true); + const submitButton = screen.getByText(createStreamButtonText); + + expect(submitButton).toBeEnabled(); + }); + }); + + describe('and blockSubmit is false', () => { + it('should render enabled Create Stream button', async () => { + renderComponent('stream', 'create'); + const submitButton = screen.getByText(createStreamButtonText); + + expect(submitButton).toBeEnabled(); + }); + }); + }); + + describe('and in edit mode', () => { + it('should render enabled Edit Stream button', async () => { + renderComponent('stream', 'edit'); + const submitButton = screen.getByText('Edit Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + it('should render Delivery summary with destination type', () => { + renderComponent('stream', 'create'); + const deliveryTitle = screen.getByText('Delivery'); + const deliveryType = screen.getByText('Linode Object Storage'); + + expect(deliveryTitle).toBeInTheDocument(); + expect(deliveryType).toBeInTheDocument(); + }); + }); + + describe('when in destination form', () => { + describe('and in create mode', () => { + it('should render enabled Create Destination button', async () => { + renderComponent('destination', 'create'); + const submitButton = screen.getByText('Create Destination'); + + expect(submitButton).toBeEnabled(); + }); + }); + + describe('and in edit mode', () => { + it('should render enabled Edit Destination button', async () => { + renderComponent('destination', 'edit'); + const submitButton = screen.getByText('Edit Destination'); + + expect(submitButton).toBeEnabled(); + }); + }); + }); +}); diff --git a/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.tsx b/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.tsx new file mode 100644 index 00000000000..ff123f6cd72 --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.tsx @@ -0,0 +1,84 @@ +import { Box, Button, Divider, Paper, Stack, Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import * as React from 'react'; + +import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { StyledHeader } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.styles'; + +import type { DestinationType } from '@linode/api-v4'; +import type { FormMode, FormType } from 'src/features/Delivery/Shared/types'; + +interface StreamFormSubmitBarProps { + blockSubmit?: boolean; + connectionTested: boolean; + destinationType?: DestinationType; + formType: FormType; + isSubmitting: boolean; + isTesting: boolean; + mode: FormMode; + onSubmit: () => void; + onTestConnection: () => void; +} + +export const FormSubmitBar = (props: StreamFormSubmitBarProps) => { + const { + blockSubmit, + connectionTested, + destinationType, + formType, + mode, + onSubmit, + onTestConnection, + isSubmitting, + isTesting, + } = props; + + const capitalizedFormType = capitalize(formType); + const enableSubmit = !blockSubmit || connectionTested; + + return ( + + + {capitalizedFormType} Summary + {formType === 'stream' && destinationType && ( + <> + + + Delivery + + {getDestinationTypeOption(destinationType)?.label ?? ''} + + + + )} + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Shared/LabelValue.tsx b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx similarity index 97% rename from packages/manager/src/features/DataStream/Shared/LabelValue.tsx rename to packages/manager/src/features/Delivery/Shared/LabelValue.tsx index ddb57224006..0104400dcf0 100644 --- a/packages/manager/src/features/DataStream/Shared/LabelValue.tsx +++ b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx @@ -2,12 +2,12 @@ import { Box, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -type LabelValueProps = { +interface LabelValueProps { compact?: boolean; 'data-testid'?: string; label: string; value: string; -}; +} export const LabelValue = (props: LabelValueProps) => { const { compact = false, label, value, 'data-testid': dataTestId } = props; diff --git a/packages/manager/src/features/DataStream/Shared/PathSample.tsx b/packages/manager/src/features/Delivery/Shared/PathSample.tsx similarity index 94% rename from packages/manager/src/features/DataStream/Shared/PathSample.tsx rename to packages/manager/src/features/Delivery/Shared/PathSample.tsx index a72d8979a0b..15920c4a658 100644 --- a/packages/manager/src/features/DataStream/Shared/PathSample.tsx +++ b/packages/manager/src/features/Delivery/Shared/PathSample.tsx @@ -2,9 +2,9 @@ import { Box, InputLabel } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -export type PathSampleProps = { +interface PathSampleProps { value: string; -}; +} export const PathSample = (props: PathSampleProps) => { const { value } = props; diff --git a/packages/manager/src/features/DataStream/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts similarity index 95% rename from packages/manager/src/features/DataStream/Shared/types.ts rename to packages/manager/src/features/Delivery/Shared/types.ts index cd5392286bd..0730b676705 100644 --- a/packages/manager/src/features/DataStream/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -6,6 +6,7 @@ import type { } from '@linode/api-v4'; export type FormMode = 'create' | 'edit'; +export type FormType = 'destination' | 'stream'; export interface LabelValueOption { label: string; diff --git a/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts b/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts new file mode 100644 index 00000000000..913a8bd4b79 --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts @@ -0,0 +1,44 @@ +import { useVerifyDestinationQuery } from '@linode/queries'; +import { enqueueSnackbar } from 'notistack'; +import { useState } from 'react'; + +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { CreateDestinationPayload } from '@linode/api-v4'; + +export const useVerifyDestination = () => { + const [isPending, setIsPending] = useState(false); + const [destinationVerified, setDestinationVerified] = useState(false); + const { mutateAsync: callVerifyDestination } = useVerifyDestinationQuery(); + + const verifyDestination = async (destination: CreateDestinationPayload) => { + setIsPending(true); + try { + await callVerifyDestination(destination); + + setDestinationVerified(true); + enqueueSnackbar( + 'Delivery connection test completed successfully. Data can now be sent using this configuration.', + { variant: 'success' } + ); + } catch (error) { + setDestinationVerified(false); + enqueueSnackbar( + getAPIErrorOrDefault( + error, + 'Delivery connection test failed. Verify your delivery settings and try again.' + )[0].reason, + { variant: 'error' } + ); + } finally { + setIsPending(false); + } + }; + + return { + destinationVerified, + isPending, + setDestinationVerified, + verifyDestination, + }; +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.test.tsx similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx rename to packages/manager/src/features/Delivery/Streams/StreamActionMenu.test.tsx index 806aa9549e0..a7b6d8e9dd1 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.test.tsx @@ -2,8 +2,8 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as React from 'react'; -import { streamFactory } from 'src/factories/datastream'; -import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import { streamFactory } from 'src/factories/delivery'; +import { StreamActionMenu } from 'src/features/Delivery/Streams/StreamActionMenu'; import { renderWithTheme } from 'src/utilities/testHelpers'; import type { StreamStatus } from '@linode/api-v4'; diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx similarity index 100% rename from packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx rename to packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx similarity index 89% rename from packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx index 7eefd4a16b1..c306c0b6fe2 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx @@ -5,14 +5,15 @@ import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { describe, expect } from 'vitest'; -import { StreamFormCheckoutBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar'; -import { StreamFormGeneralInfo } from 'src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo'; +import { StreamFormCheckoutBar } from 'src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar'; +import { StreamFormGeneralInfo } from 'src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo'; import { renderWithTheme, renderWithThemeAndHookFormContext, } from 'src/utilities/testHelpers'; -describe('StreamFormCheckoutBar', () => { +// Skipped until StreamFormCheckoutBar will be used again +describe.skip('StreamFormCheckoutBar', () => { const getDeliveryPriceContext = () => screen.getByText(/\/unit/i).textContent; const createStream = () => {}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx similarity index 88% rename from packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx index cc4dab35826..2ed00c344f8 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx @@ -4,10 +4,10 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; import { displayPrice } from 'src/components/DisplayPrice'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; +import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { StyledHeader } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.styles'; -import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; +import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; export interface Props { createStream: () => void; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx similarity index 68% rename from packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx index 6ed13dd08f6..ddfd07f98e5 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx @@ -1,14 +1,63 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { + screen, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it } from 'vitest'; +import { kubernetesClusterFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { StreamFormClusters } from './StreamFormClusters'; -const renderComponentWithoutSelectedClusters = () => { - renderWithThemeAndHookFormContext({ +const queryMocks = vi.hoisted(() => ({ + useOrderV2: vi.fn().mockReturnValue({}), +})); + +const loadingTestId = 'circle-progress'; +const testClustersDetails = [ + { + label: 'gke-prod-europe-west1', + id: 1, + region: 'US, Atalanta, GA', + control_plane: { + audit_logs_enabled: false, + }, + }, + { + label: 'metrics-stream-cluster', + id: 2, + region: 'US, Chicago, IL', + control_plane: { + audit_logs_enabled: true, + }, + }, + { + label: 'prod-cluster-eu', + id: 3, + region: 'NL, Amsterdam', + control_plane: { + audit_logs_enabled: true, + }, + }, +]; +const clusters = kubernetesClusterFactory.buildList(3).map((cluster, idx) => ({ + ...cluster, + ...testClustersDetails[idx], +})); + +const renderComponentWithoutSelectedClusters = async () => { + server.use( + http.get('*/lke/clusters', () => { + return HttpResponse.json(makeResourcePage(clusters)); + }) + ); + + const utils = renderWithThemeAndHookFormContext({ component: , useFormOptions: { defaultValues: { @@ -21,6 +70,12 @@ const renderComponentWithoutSelectedClusters = () => { }, }, }); + + const loadingElement = utils.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + + return utils; }; const getColumnsValuesFromTable = (column = 1) => { @@ -54,7 +109,7 @@ const expectCheckboxStateToBe = ( describe('StreamFormClusters', () => { it('should render all clusters in table', async () => { - renderComponentWithoutSelectedClusters(); + await renderComponentWithoutSelectedClusters(); expect(getColumnsValuesFromTable()).toEqual([ 'gke-prod-europe-west1', @@ -63,50 +118,8 @@ describe('StreamFormClusters', () => { ]); }); - it('should filter clusters by name', async () => { - renderComponentWithoutSelectedClusters(); - const input = screen.getByPlaceholderText('Search'); - - // Type test value inside the search - await userEvent.click(input); - await userEvent.type(input, 'metrics'); - - await waitFor(() => - expect(getColumnsValuesFromTable()).toEqual(['metrics-stream-cluster']) - ); - }); - - it('should filter clusters by region', async () => { - renderComponentWithoutSelectedClusters(); - const input = screen.getByPlaceholderText('Search'); - - // Type test value inside the search - await userEvent.click(input); - await userEvent.type(input, 'US,'); - - await waitFor(() => - expect(getColumnsValuesFromTable(2)).toEqual([ - 'US, Atalanta, GA', - 'US, Chicago, IL', - ]) - ); - }); - - it('should filter clusters by log generation status', async () => { - renderComponentWithoutSelectedClusters(); - const input = screen.getByPlaceholderText('Search'); - - // Type test value inside the search - await userEvent.click(input); - await userEvent.type(input, 'enabled'); - - await waitFor(() => - expect(getColumnsValuesFromTable(3)).toEqual(['Enabled', 'Enabled']) - ); - }); - it('should toggle clusters checkboxes and header checkbox', async () => { - renderComponentWithoutSelectedClusters(); + await renderComponentWithoutSelectedClusters(); const table = screen.getByRole('table'); const headerCheckbox = within(table).getAllByRole('checkbox')[0]; const gkeProdCheckbox = getCheckboxByClusterName('gke-prod-europe-west1'); @@ -133,7 +146,7 @@ describe('StreamFormClusters', () => { }); it('should select and deselect all clusters with header checkbox', async () => { - renderComponentWithoutSelectedClusters(); + await renderComponentWithoutSelectedClusters(); const table = screen.getByRole('table'); const headerCheckbox = within(table).getAllByRole('checkbox')[0]; const gkeProdCheckbox = getCheckboxByClusterName('gke-prod-europe-west1'); @@ -161,19 +174,30 @@ describe('StreamFormClusters', () => { describe('when form has already selected clusters', () => { it('should render table with properly selected clusters', async () => { + server.use( + http.get('*/lke/clusters', () => { + return HttpResponse.json(makeResourcePage(clusters)); + }) + ); + renderWithThemeAndHookFormContext({ component: , useFormOptions: { defaultValues: { stream: { details: { - cluster_ids: [3], + cluster_ids: [2], is_auto_add_all_clusters_enabled: false, }, }, }, }, }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + const table = screen.getByRole('table'); const headerCheckbox = within(table).getAllByRole('checkbox')[0]; const gkeProdCheckbox = getCheckboxByClusterName('gke-prod-europe-west1'); @@ -189,8 +213,8 @@ describe('StreamFormClusters', () => { }); }); - it('should disable all table checkboxes if "Automatically include all..." checkbox is selected', async () => { - renderComponentWithoutSelectedClusters(); + it('should disable all table checkboxes if "Automatically include all" checkbox is selected', async () => { + await renderComponentWithoutSelectedClusters(); const table = screen.getByRole('table'); const autoIncludeAllCheckbox = screen.getByText( 'Automatically include all existing and recently configured clusters.' @@ -214,8 +238,8 @@ describe('StreamFormClusters', () => { expect(prodClusterCheckbox).toBeDisabled(); }); - it('should select and deselect all clusters with "Automatically include all..." checkbox', async () => { - renderComponentWithoutSelectedClusters(); + it('should select and deselect all clusters with "Automatically include all" checkbox', async () => { + await renderComponentWithoutSelectedClusters(); const checkboxes = screen.getAllByRole('checkbox'); const [autoIncludeAllCheckbox, headerTableCheckbox] = checkboxes; const gkeProdCheckbox = getCheckboxByClusterName('gke-prod-europe-west1'); @@ -226,7 +250,7 @@ describe('StreamFormClusters', () => { expect(autoIncludeAllCheckbox).not.toBeChecked(); - // Select "Automatically include all..." checkbox + // Select "Automatically include all" checkbox await userEvent.click(autoIncludeAllCheckbox); expect(autoIncludeAllCheckbox).toBeChecked(); expect(headerTableCheckbox).toBeChecked(); @@ -234,7 +258,7 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).toBeChecked(); expect(prodClusterCheckbox).toBeChecked(); - // Unselect "Automatically include all..." checkbox + // Unselect "Automatically include all" checkbox await userEvent.click(autoIncludeAllCheckbox); expect(autoIncludeAllCheckbox).not.toBeChecked(); expect(headerTableCheckbox).not.toBeChecked(); @@ -243,75 +267,8 @@ describe('StreamFormClusters', () => { expect(prodClusterCheckbox).not.toBeChecked(); }); - it('should sort clusters by Cluster Name if clicked', async () => { - renderComponentWithoutSelectedClusters(); - const sortHeader = screen.getByRole('columnheader', { - name: 'Cluster Name', - }); - - expect(getColumnsValuesFromTable()).toEqual([ - 'gke-prod-europe-west1', - 'metrics-stream-cluster', - 'prod-cluster-eu', - ]); - - // Sort by Cluster Name descending - await userEvent.click(sortHeader); - expect(getColumnsValuesFromTable()).toEqual([ - 'prod-cluster-eu', - 'metrics-stream-cluster', - 'gke-prod-europe-west1', - ]); - }); - - it('should sort clusters by Region if clicked', async () => { - renderComponentWithoutSelectedClusters(); - const sortHeader = screen.getByRole('columnheader', { - name: 'Region', - }); - - // Sort by Region ascending - await userEvent.click(sortHeader); - expect(getColumnsValuesFromTable(2)).toEqual([ - 'NL, Amsterdam', - 'US, Atalanta, GA', - 'US, Chicago, IL', - ]); - - // Sort by Region descending - await userEvent.click(sortHeader); - expect(getColumnsValuesFromTable(2)).toEqual([ - 'US, Chicago, IL', - 'US, Atalanta, GA', - 'NL, Amsterdam', - ]); - }); - - it('should sort clusters by Log Generation if clicked', async () => { - renderComponentWithoutSelectedClusters(); - const sortHeader = screen.getByRole('columnheader', { - name: 'Log Generation', - }); - - // Sort by Log Generation ascending - await userEvent.click(sortHeader); - expect(getColumnsValuesFromTable(3)).toEqual([ - 'Enabled', - 'Enabled', - 'Disabled', - ]); - - // Sort by Log Generation descending - await userEvent.click(sortHeader); - expect(getColumnsValuesFromTable(3)).toEqual([ - 'Disabled', - 'Enabled', - 'Enabled', - ]); - }); - it('should keep checkboxes selection after sorting', async () => { - renderComponentWithoutSelectedClusters(); + await renderComponentWithoutSelectedClusters(); const gkeProdCheckbox = getCheckboxByClusterName('gke-prod-europe-west1'); const metricsStreamCheckbox = getCheckboxByClusterName( 'metrics-stream-cluster' @@ -319,7 +276,7 @@ describe('StreamFormClusters', () => { const prodClusterCheckbox = getCheckboxByClusterName('prod-cluster-eu'); const sortHeader = screen.getByRole('columnheader', { - name: 'Log Generation', + name: 'Cluster Name', }); // Select "prod-cluster-eu" cluster @@ -328,7 +285,13 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).not.toBeChecked(); expect(prodClusterCheckbox).toBeChecked(); - // Sort by Log Generation ascending + // Sort by Cluster Name descending + queryMocks.useOrderV2.mockReturnValue({ + order: 'desc', + orderBy: 'label', + sortedData: clusters.reverse(), + }); + await userEvent.click(sortHeader); expect(gkeProdCheckbox).not.toBeChecked(); expect(metricsStreamCheckbox).not.toBeChecked(); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx new file mode 100644 index 00000000000..745346f8a88 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -0,0 +1,180 @@ +import { getAPIFilterFromQuery } from '@linode/search'; +import { + Box, + Checkbox, + CircleProgress, + ErrorState, + Notice, + Paper, + Typography, +} from '@linode/ui'; +import React, { useEffect, useState } from 'react'; +import { useWatch } from 'react-hook-form'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { Table } from 'src/components/Table'; +import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; +import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; + +import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; +import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; + +const controlPaths = { + isAutoAddAllClustersEnabled: + 'stream.details.is_auto_add_all_clusters_enabled', + clusterIds: 'stream.details.cluster_ids', +} as const; + +export const StreamFormClusters = () => { + const { control, setValue, formState } = + useFormContext(); + + const [order, setOrder] = useState<'asc' | 'desc'>('asc'); + const [orderBy, setOrderBy] = useState('label'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(MIN_PAGE_SIZE); + const [searchText, setSearchText] = useState(''); + + const { error: searchParseError, filter: searchFilter } = + getAPIFilterFromQuery(searchText, { + searchableFieldsWithoutOperator: ['label', 'region'], + }); + + const filter = { + ['+order']: order, + ['+order_by']: orderBy, + ...searchFilter, + }; + + const { + data: clusters, + isLoading, + error, + } = useKubernetesClustersQuery({ + filter, + isUsingBetaEndpoint: true, + params: { + page, + page_size: pageSize, + }, + }); + + const idsWithLogsEnabled = clusters?.data + .filter((cluster) => cluster.control_plane.audit_logs_enabled) + .map(({ id }) => id); + + const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ + control, + name: [controlPaths.isAutoAddAllClustersEnabled, controlPaths.clusterIds], + }); + + useEffect(() => { + setValue( + controlPaths.clusterIds, + isAutoAddAllClustersEnabled ? idsWithLogsEnabled : clusterIds || [] + ); + }, [isLoading]); + + const handleOrderChange = (newOrderBy: OrderByKeys) => { + if (orderBy === newOrderBy) { + setOrder(order === 'asc' ? 'desc' : 'asc'); + } else { + setOrderBy(newOrderBy); + setOrder('asc'); + } + }; + + return ( + + Clusters + {isLoading ? ( + + ) : error ? ( + + ) : ( + <> + + Disabling this option allows you to manually define which clusters + will be included in the stream. Stream will not be updated + automatically with newly configured clusters. + + ( + { + field.onChange(checked); + if (checked) { + setValue(controlPaths.clusterIds, idsWithLogsEnabled); + } else { + setValue(controlPaths.clusterIds, []); + } + }} + sxFormLabel={{ ml: -1 }} + text="Automatically include all existing and recently configured clusters." + /> + )} + /> + setSearchText(value)} + placeholder="Search" + value={searchText} + /> + + {!isAutoAddAllClustersEnabled && + formState.errors.stream?.details?.cluster_ids?.message && ( + + )} + + ( + + )} + /> +
    + +
    + + )} +
    + ); +}; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx new file mode 100644 index 00000000000..4c4b5495448 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx @@ -0,0 +1,138 @@ +import { Box, Checkbox } from '@linode/ui'; +import React from 'react'; +import type { ControllerRenderProps } from 'react-hook-form'; + +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableSortCell } from 'src/components/TableSortCell'; + +import type { KubernetesCluster, ResourcePage } from '@linode/api-v4'; +import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; + +export type OrderByKeys = 'label' | 'logGeneration' | 'region'; + +interface StreamFormClusterTableContentProps { + clusters: ResourcePage | undefined; + field: ControllerRenderProps< + StreamAndDestinationFormType, + 'stream.details.cluster_ids' + >; + idsWithLogsEnabled?: number[]; + isAutoAddAllClustersEnabled: boolean | undefined; + onOrderChange: (key: OrderByKeys) => void; + order: 'asc' | 'desc'; + orderBy: OrderByKeys; +} + +export const StreamFormClusterTableContent = ({ + field, + clusters, + order, + orderBy, + onOrderChange, + idsWithLogsEnabled, + isAutoAddAllClustersEnabled, +}: StreamFormClusterTableContentProps) => { + const selectedIds = field.value || []; + + const isAllSelected = + selectedIds.length === (idsWithLogsEnabled?.length ?? 0); + const isIndeterminate = selectedIds.length > 0 && !isAllSelected; + + const toggleAllClusters = () => + field.onChange(isAllSelected ? [] : idsWithLogsEnabled); + + const toggleCluster = (toggledId: number) => { + const updatedClusterIds = selectedIds.includes(toggledId) + ? selectedIds.filter((selectedId) => selectedId !== toggledId) + : [...selectedIds, toggledId]; + + field.onChange(updatedClusterIds); + }; + + return ( + <> + + + + {!!clusters?.results && ( + + )} + + onOrderChange('label')} + label="label" + sx={{ width: '35%' }} + > + Cluster Name + + onOrderChange('region')} + label="region" + sx={{ width: '35%' }} + > + Region + + onOrderChange('logGeneration')} + label="logGeneration" + sx={{ width: '25%' }} + > + Log Generation + + + + + {clusters?.results ? ( + clusters.data.map( + ({ + label, + region, + id, + control_plane: { audit_logs_enabled: logsEnabled }, + }) => ( + + + toggleCluster(id)} + /> + + {label} + {region} + + + + {logsEnabled ? 'Enabled' : 'Disabled'} + + + + ) + ) + ) : ( + + )} + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx similarity index 100% rename from packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx similarity index 92% rename from packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx index f031a4997ef..fa578cc4da3 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx @@ -1,7 +1,7 @@ import { useRegionsQuery } from '@linode/queries'; import React from 'react'; -import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; +import { LabelValue } from 'src/features/Delivery/Shared/LabelValue'; import type { LinodeObjectStorageDetails } from '@linode/api-v4'; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx similarity index 93% rename from packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index 71e0ed84dca..45b5891553f 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -2,9 +2,9 @@ import { destinationType } from '@linode/api-v4'; import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { beforeEach, describe } from 'vitest'; +import { beforeEach, describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories/datastream'; +import { destinationFactory } from 'src/factories/delivery'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; @@ -37,9 +37,8 @@ describe('StreamFormDelivery', () => { }); const loadingElement = screen.queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); const destinationTypeAutocomplete = screen.getByLabelText('Destination Type'); @@ -62,9 +61,8 @@ describe('StreamFormDelivery', () => { }); const loadingElement = screen.queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); const destinationNameAutocomplete = screen.getByLabelText('Destination Name'); @@ -93,9 +91,8 @@ describe('StreamFormDelivery', () => { }); const loadingElement = screen.queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); const destinationNameAutocomplete = screen.getByLabelText('Destination Name'); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx similarity index 75% rename from packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index d4d7cbf4f24..15518bd479a 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -10,26 +10,26 @@ import { } from '@linode/ui'; import { createFilterOptions } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import React from 'react'; +import React, { useState } from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; -import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; -import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary'; +import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm'; +import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; +import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary'; import type { DestinationType, LinodeObjectStorageDetails, } from '@linode/api-v4'; -import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; +import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; -type DestinationName = { +interface DestinationName { create?: boolean; id?: number; label: string; type?: DestinationType; -}; +} const controlPaths = { accessKeyId: 'destination.details.access_key_id', @@ -38,23 +38,24 @@ const controlPaths = { host: 'destination.details.host', path: 'destination.details.path', region: 'destination.details.region', -}; +} as const; export const StreamFormDelivery = () => { const theme = useTheme(); - const { control, setValue } = useFormContext(); + const { control, setValue, clearErrors } = + useFormContext(); + const { data: destinations, isLoading, error } = useAllDestinationsQuery(); - const [showDestinationForm, setShowDestinationForm] = - React.useState(false); + const [creatingNewDestination, setCreatingNewDestination] = + useState(false); - const { data: destinations, isLoading, error } = useAllDestinationsQuery(); - const destinationNameOptions: DestinationName[] = (destinations || []).map( - ({ id, label, type }) => ({ - id, - label, - type, - }) - ); + const destinationNameOptions: DestinationName[] = ( + destinations?.data || [] + ).map(({ id, label, type }) => ({ + id, + label, + type, + })); const selectedDestinationType = useWatch({ control, @@ -68,6 +69,9 @@ export const StreamFormDelivery = () => { const destinationNameFilterOptions = createFilterOptions(); + const findDestination = (id: number) => + destinations?.data?.find((destination) => destination.id === id); + const getDestinationForm = () => ( <> { label="Destination Name" onBlur={field.onBlur} onChange={(_, newValue) => { - setValue( - 'stream.destinations', - newValue?.id ? [newValue?.id] : [] - ); + const id = newValue?.id; + + setValue('stream.destinations', id ? [id] : []); + const selectedDestination = id ? findDestination(id) : undefined; + if (selectedDestination) { + setValue('destination.details', selectedDestination.details); + } else { + clearErrors('destination.details'); + } + field.onChange(newValue?.label || newValue); - setShowDestinationForm(!!newValue?.create); + setCreatingNewDestination(!!newValue?.create); }} options={destinationNameOptions.filter( ({ type }) => type === selectedDestinationType @@ -146,16 +156,15 @@ export const StreamFormDelivery = () => { /> {selectedDestinationType === destinationType.LinodeObjectStorage && ( <> - {showDestinationForm && ( + {creatingNewDestination && !selectedDestinations?.length && ( )} - {!!selectedDestinations?.length && ( + {selectedDestinations?.[0] && ( id === selectedDestinations[0] - )?.details as LinodeObjectStorageDetails)} + {...(findDestination(selectedDestinations[0]) + ?.details as LinodeObjectStorageDetails)} /> )} diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx new file mode 100644 index 00000000000..5f9739fdb38 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx @@ -0,0 +1,231 @@ +import { destinationType, streamType } from '@linode/api-v4'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { destinationFactory } from 'src/factories/delivery'; +import { StreamCreate } from 'src/features/Delivery/Streams/StreamForm/StreamCreate'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const mockDestinations = [ + destinationFactory.build({ id: 1, label: 'Destination 1' }), +]; + +describe('StreamCreate', () => { + const renderStreamCreate = () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + }, + }); + }; + + describe( + 'given Test Connection and Create Stream buttons', + { timeout: 10000 }, + () => { + const testConnectionButtonText = 'Test Connection'; + const createStreamButtonText = 'Create Stream'; + + const fillOutFormWithNewDestination = async () => { + const streamNameInput = screen.getByLabelText('Name'); + await userEvent.type(streamNameInput, 'Test'); + const destinationNameInput = screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test destination name'); + const createNewTestDestination = await screen.findByText( + 'Test destination name', + { exact: false } + ); + await userEvent.click(createNewTestDestination); + const hostInput = screen.getByLabelText('Host'); + await waitFor(() => { + expect(hostInput).toBeDefined(); + }); + await userEvent.type(hostInput, 'Test'); + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'Test'); + const regionAutocomplete = screen.getByLabelText('Region'); + await userEvent.click(regionAutocomplete); + await userEvent.type(regionAutocomplete, 'US, Chi'); + const chicagoRegion = await screen.findByText( + 'US, Chicago, IL (us-ord)' + ); + await userEvent.click(chicagoRegion); + const accessKeyIDInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIDInput, 'Test'); + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + await userEvent.type(logPathPrefixInput, 'Test'); + }; + + describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { + describe('and creating new destination', () => { + const createStreamSpy = vi.fn(); + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Create Stream button and perform proper calls when it's clicked", async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json(mockDestinations[0]); + }), + http.post('*/monitor/streams', () => { + createStreamSpy(); + return HttpResponse.json({}); + }) + ); + + renderStreamCreate(); + await fillOutFormWithNewDestination(); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createStreamButton = screen.getByRole('button', { + name: createStreamButtonText, + }); + expect(createStreamButton).toBeDisabled(); + + // Test connection + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(createStreamButton).toBeEnabled(); + }); + + // Create stream + await userEvent.click(createStreamButton); + + expect(createDestinationSpy).toHaveBeenCalled(); + await waitFor(() => { + expect(createStreamSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('and selected existing destination', () => { + const createStreamSpy = vi.fn(); + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Create Stream button and perform proper calls when it's clicked", async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json(mockDestinations[0]); + }), + http.post('*/monitor/streams', () => { + createStreamSpy(); + return HttpResponse.json({}); + }) + ); + + renderStreamCreate(); + + // Fill out form and select existing destination + const streamNameInput = screen.getByLabelText('Name'); + await userEvent.type(streamNameInput, 'Test'); + const destinationNameInput = + screen.getByLabelText('Destination Name'); + await userEvent.click(destinationNameInput); + const existingDestination = screen.getByText('Destination 1'); + await userEvent.click(existingDestination); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createStreamButton = screen.getByRole('button', { + name: createStreamButtonText, + }); + + // Create stream button should not be disabled with existing destination selected + expect(createStreamButton).toBeEnabled(); + + // Test connection + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(createStreamButton).toBeEnabled(); + }); + + // Create stream + await userEvent.click(createStreamButton); + + // New destination should not be created with existing destination selected + expect(createDestinationSpy).not.toHaveBeenCalled(); + await waitFor(() => { + expect(createStreamSpy).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { + const verifyDestinationSpy = vi.fn(); + + it('should not enable Create Stream button', async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }) + ); + + renderStreamCreate(); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createStreamButton = screen.getByRole('button', { + name: createStreamButtonText, + }); + + await fillOutFormWithNewDestination(); + + expect(createStreamButton).toBeDisabled(); + + await userEvent.click(testConnectionButton); + + expect(verifyDestinationSpy).toHaveBeenCalled(); + expect(createStreamButton).toBeDisabled(); + }); + }); + } + ); +}); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx new file mode 100644 index 00000000000..f7728d87a35 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx @@ -0,0 +1,58 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { StreamForm } from 'src/features/Delivery/Streams/StreamForm/StreamForm'; + +import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; + +export const StreamCreate = () => { + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/logs/delivery/streams/create', + crumbOverrides: [ + { + label: 'Delivery', + linkTo: '/logs/delivery/streams', + position: 1, + }, + ], + }, + removeCrumbX: [1, 2], + title: 'Create Stream', + }; + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx new file mode 100644 index 00000000000..7520ac662b1 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -0,0 +1,273 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { destinationFactory, streamFactory } from 'src/factories/delivery'; +import { StreamEdit } from 'src/features/Delivery/Streams/StreamForm/StreamEdit'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const streamId = 123; +const mockDestinations = [destinationFactory.build({ id: 1 })]; +const mockStream = streamFactory.build({ + id: streamId, + label: `Stream ${streamId}`, + destinations: mockDestinations, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ streamId: 123 }), + }; +}); + +describe('StreamEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited stream when stream fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + + await waitFor(() => { + assertInputHasValue('Name', 'Stream 123'); + }); + assertInputHasValue('Stream Type', 'Audit Logs'); + await waitFor(() => { + assertInputHasValue('Destination Type', 'Linode Object Storage'); + }); + assertInputHasValue('Destination Name', 'Destination 1'); + + // Host: + expect(screen.getByText('3000')).toBeVisible(); + // Bucket: + expect(screen.getByText('Bucket Name')).toBeVisible(); + // Region: + await waitFor(() => { + expect(screen.getByText('US, Chicago, IL')).toBeVisible(); + }); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); + // Log Path: + expect(screen.getByText('file')).toBeVisible(); + }); + + describe( + 'given Test Connection and Edit Stream buttons', + { timeout: 10000 }, + () => { + const testConnectionButtonText = 'Test Connection'; + const editStreamButtonText = 'Edit Stream'; + + const fillOutNewDestinationForm = async () => { + const destinationNameInput = screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test destination name'); + const createNewTestDestination = await screen.findByText( + 'Test destination name', + { exact: false } + ); + await userEvent.click(createNewTestDestination); + }; + + describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { + describe('and creating new destination', () => { + const editStreamSpy = vi.fn(); + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Edit Stream button and perform proper calls when it's clicked", async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json(mockDestinations[0]); + }), + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.put(`*/monitor/streams/${streamId}`, () => { + editStreamSpy(); + return HttpResponse.json({}); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + await fillOutNewDestinationForm(); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const editStreamButton = screen.getByRole('button', { + name: editStreamButtonText, + }); + expect(editStreamButton).toBeDisabled(); + + // Test connection + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(editStreamButton).toBeEnabled(); + }); + + // Edit stream + await userEvent.click(editStreamButton); + + expect(createDestinationSpy).toHaveBeenCalled(); + await waitFor(() => { + expect(editStreamSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('and selected existing destination', () => { + const editStreamSpy = vi.fn(); + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Edit Stream button and perform proper calls when it's clicked", async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json(mockDestinations[0]); + }), + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.put(`*/monitor/streams/${streamId}`, () => { + editStreamSpy(); + return HttpResponse.json({}); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + // Change name and leave existing destination + const streamNameInput = screen.getByLabelText('Name'); + await userEvent.type(streamNameInput, 'Test'); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const editStreamButton = screen.getByRole('button', { + name: editStreamButtonText, + }); + + // Edit stream button should not be disabled with existing destination selected + expect(editStreamButton).toBeEnabled(); + + // Test connection + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(editStreamButton).toBeEnabled(); + }); + + // Edit stream + await userEvent.click(editStreamButton); + + // New destination should not be created with existing destination selected + expect(createDestinationSpy).not.toHaveBeenCalled(); + await waitFor(() => { + expect(editStreamSpy).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { + const verifyDestinationSpy = vi.fn(); + + it('should not enable Edit Stream button', async () => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }), + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }), + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const editStreamButton = screen.getByRole('button', { + name: editStreamButtonText, + }); + + await fillOutNewDestinationForm(); + + expect(editStreamButton).toBeDisabled(); + + await userEvent.click(testConnectionButton); + + expect(verifyDestinationSpy).toHaveBeenCalled(); + expect(editStreamButton).toBeDisabled(); + }); + }); + } + ); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx similarity index 53% rename from packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx index 9a3f6bf846e..6ede8ecf012 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx @@ -1,9 +1,9 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { destinationType, streamType } from '@linode/api-v4'; -import { useStreamQuery, useUpdateStreamMutation } from '@linode/queries'; +import { useAllDestinationsQuery, useStreamQuery } from '@linode/queries'; import { Box, CircleProgress, ErrorState } from '@linode/ui'; import { streamAndDestinationFormSchema } from '@linode/validation'; -import { useNavigate, useParams } from '@tanstack/react-router'; +import { useParams } from '@tanstack/react-router'; import * as React from 'react'; import { useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; @@ -13,19 +13,39 @@ import { LandingHeader, type LandingHeaderProps, } from 'src/components/LandingHeader'; -import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; -import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; +import { StreamForm } from 'src/features/Delivery/Streams/StreamForm/StreamForm'; -import type { UpdateStreamPayloadWithId } from '@linode/api-v4'; -import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; +import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; export const StreamEdit = () => { - const navigate = useNavigate(); const { streamId } = useParams({ - from: '/datastream/streams/$streamId/edit', + from: '/logs/delivery/streams/$streamId/edit', }); - const { mutateAsync: updateStream } = useUpdateStreamMutation(); - const { data: stream, isLoading, error } = useStreamQuery(Number(streamId)); + const { + data: destinations, + isLoading: isLoadingDestinations, + error: errorDestinations, + } = useAllDestinationsQuery(); + const { + data: stream, + isLoading: isLoadingStream, + error: errorStream, + } = useStreamQuery(Number(streamId)); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/logs/delivery/streams/edit', + crumbOverrides: [ + { + label: 'Delivery', + linkTo: '/logs/delivery/streams', + position: 1, + }, + ], + }, + removeCrumbX: [1, 2], + title: `Edit Stream ${streamId}`, + }; const form = useForm({ defaultValues: { @@ -55,77 +75,49 @@ export const StreamEdit = () => { } : {}; + const streamsDestinationIds = stream.destinations.map(({ id }) => id); form.reset({ stream: { ...stream, details, - destinations: stream.destinations.map(({ id }) => id), + destinations: streamsDestinationIds, }, - destination: stream.destinations?.[0], + destination: destinations?.data?.find( + ({ id }) => id === streamsDestinationIds[0] + ), }); } - }, [stream, form]); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/datastream/streams/edit', - crumbOverrides: [ - { - label: 'DataStream', - linkTo: '/datastream/streams', - position: 1, - }, - ], - }, - removeCrumbX: 2, - title: 'Edit Stream', - }; - - const onSubmit = () => { - const { - stream: { label, type, destinations, details }, - } = form.getValues(); - - // TODO: DPS-33120 create destination call if new destination created - - const payload: UpdateStreamPayloadWithId = { - id: stream!.id, - label, - type: stream!.type, - status: stream!.status, - destinations: destinations as number[], // TODO: remove type assertion after DPS-33120 - details: getStreamPayloadDetails(type, details), - }; - - updateStream(payload).then(() => { - navigate({ to: '/datastream/streams' }); - }); - }; + }, [stream, destinations, form]); return ( <> - {isLoading && ( + {(isLoadingStream || isLoadingDestinations) && ( )} - {error && ( + {errorStream && ( )} - {!isLoading && !error && ( - - - + {errorDestinations && ( + )} + {!isLoadingStream && + !isLoadingDestinations && + !errorStream && + !errorDestinations && ( + + + + )} ); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx new file mode 100644 index 00000000000..00ae4fbe5ab --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -0,0 +1,187 @@ +import { type StreamStatus, streamType } from '@linode/api-v4'; +import { + useCreateDestinationMutation, + useCreateStreamMutation, + useUpdateStreamMutation, +} from '@linode/queries'; +import { Stack } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import { useNavigate } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { type SubmitHandler, useFormContext, useWatch } from 'react-hook-form'; + +import { getStreamPayloadDetails } from 'src/features/Delivery/deliveryUtils'; +import { FormSubmitBar } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar'; +import { useVerifyDestination } from 'src/features/Delivery/Shared/useVerifyDestination'; +import { StreamFormDelivery } from 'src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery'; + +import { StreamFormClusters } from './Clusters/StreamFormClusters'; +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +import type { FormMode } from 'src/features/Delivery/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; + +interface StreamFormProps { + mode: FormMode; + streamId?: number; +} + +export const StreamForm = (props: StreamFormProps) => { + const { mode, streamId } = props; + + const navigate = useNavigate(); + const { mutateAsync: createDestination, isPending: isCreatingDestination } = + useCreateDestinationMutation(); + const { mutateAsync: createStream, isPending: isCreatingStream } = + useCreateStreamMutation(); + const { mutateAsync: updateStream, isPending: isUpdatingStream } = + useUpdateStreamMutation(); + const { + verifyDestination, + isPending: isVerifyingDestination, + destinationVerified, + setDestinationVerified, + } = useVerifyDestination(); + + const form = useFormContext(); + const { control, handleSubmit, trigger } = form; + + const selectedStreamType = useWatch({ + control, + name: 'stream.type', + }); + + const selectedDestinations = useWatch({ + control, + name: 'stream.destinations', + }); + + const destination = useWatch({ + control, + name: 'destination', + }); + + useEffect(() => { + setDestinationVerified(false); + }, [destination, setDestinationVerified]); + + const isSubmitting = + isCreatingDestination || isCreatingStream || isUpdatingStream; + + const onSubmit: SubmitHandler = async () => { + const { + stream: { label, type, details, status, destinations }, + destination, + } = form.getValues(); + + let destinationId = destinations?.[0]; + if (!destinationId) { + try { + const { id } = await createDestination(destination); + destinationId = id; + enqueueSnackbar( + `Destination ${destination.label} created successfully`, + { variant: 'success' } + ); + form.setValue('stream.destinations', [id]); + } catch (errors) { + for (const error of errors) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + form.setError('root', { message: error.reason }); + } + } + + enqueueSnackbar('There was an issue creating your destination', { + variant: 'error', + }); + return; + } + } + + const payloadDetails = getStreamPayloadDetails(type, details); + + try { + if (mode === 'create') { + await createStream({ + label, + type, + destinations: [destinationId], + details: payloadDetails, + }); + enqueueSnackbar(`Stream ${label} created successfully`, { + variant: 'success', + }); + } else if (mode === 'edit' && streamId) { + await updateStream({ + id: streamId, + label, + type, + status: status as StreamStatus, + destinations: [destinationId], + details: payloadDetails, + }); + enqueueSnackbar(`Stream ${label} edited successfully`, { + variant: 'success', + }); + } + + navigate({ to: '/logs/delivery/streams' }); + } catch (errors) { + for (const error of errors) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + form.setError('root', { message: error.reason }); + } + } + + enqueueSnackbar( + `There was an issue ${mode === 'create' ? 'creating' : 'editing'} your stream`, + { + variant: 'error', + } + ); + } + }; + + const handleTestConnection = async () => { + const isValid = await trigger(['destination']); + + if (isValid) { + await verifyDestination(destination); + } + }; + + return ( +
    + + + + + {selectedStreamType === streamType.LKEAuditLogs && ( + + )} + + + + + + + +
    + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx similarity index 86% rename from packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx index aeea11d63e9..c385377d3e4 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx @@ -16,7 +16,7 @@ describe('StreamFormGeneralInfo', () => { }); // Type test value inside the input - const nameInput = screen.getByPlaceholderText('Stream name...'); + const nameInput = screen.getByPlaceholderText('Stream name'); await userEvent.type(nameInput, 'Test'); await waitFor(() => { @@ -56,23 +56,13 @@ describe('StreamFormGeneralInfo', () => { }); describe('when in edit mode and with streamId prop', () => { - const streamId = '123'; - it('should render ID', () => { - renderWithThemeAndHookFormContext({ - component: , - }); - - // ID: - expect(screen.getByText(streamId)).toBeVisible(); - }); - it('should render Name input and allow to type text', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , }); // Type test value inside the input - const nameInput = screen.getByPlaceholderText('Stream name...'); + const nameInput = screen.getByPlaceholderText('Stream name'); await userEvent.type(nameInput, 'Test'); await waitFor(() => { @@ -82,7 +72,7 @@ describe('StreamFormGeneralInfo', () => { it('should render disabled Stream type input', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx similarity index 78% rename from packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx index 8ade80467c4..21f85022a3c 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -6,20 +6,19 @@ import { Controller, useFormContext } from 'react-hook-form'; import { getStreamTypeOption, isFormInEditMode, -} from 'src/features/DataStream/dataStreamUtils'; -import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; -import { streamTypeOptions } from 'src/features/DataStream/Shared/types'; +} from 'src/features/Delivery/deliveryUtils'; +import { streamTypeOptions } from 'src/features/Delivery/Shared/types'; import type { StreamAndDestinationFormType } from './types'; -import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { FormMode } from 'src/features/Delivery/Shared/types'; -type StreamFormGeneralInfoProps = { +interface StreamFormGeneralInfoProps { mode: FormMode; streamId?: string; -}; +} export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { - const { mode, streamId } = props; + const { mode } = props; const { control, setValue } = useFormContext(); @@ -34,7 +33,6 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { return ( General Information - {streamId && } { onChange={(value) => { field.onChange(value); }} - placeholder="Stream name..." + placeholder="Stream name" value={field.value} /> )} - rules={{ required: true }} /> { value={getStreamTypeOption(field.value)} /> )} - rules={{ required: true }} /> ); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts b/packages/manager/src/features/Delivery/Streams/StreamForm/streamCreateLazyRoute.ts similarity index 55% rename from packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts rename to packages/manager/src/features/Delivery/Streams/StreamForm/streamCreateLazyRoute.ts index eda795aea08..e4f52d7d3bf 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/streamCreateLazyRoute.ts @@ -1,9 +1,9 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { StreamCreate } from 'src/features/DataStream/Streams/StreamForm/StreamCreate'; +import { StreamCreate } from 'src/features/Delivery/Streams/StreamForm/StreamCreate'; export const streamCreateLazyRoute = createLazyRoute( - '/datastream/streams/create' + '/logs/delivery/streams/create' )({ component: StreamCreate, }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts b/packages/manager/src/features/Delivery/Streams/StreamForm/streamEditLazyRoute.ts similarity index 53% rename from packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts rename to packages/manager/src/features/Delivery/Streams/StreamForm/streamEditLazyRoute.ts index a9ad91972cb..818107451b4 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/streamEditLazyRoute.ts @@ -1,9 +1,9 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; +import { StreamEdit } from 'src/features/Delivery/Streams/StreamForm/StreamEdit'; export const streamEditLazyRoute = createLazyRoute( - '/datastream/streams/$streamId/edit' + '/logs/delivery/streams/$streamId/edit' )({ component: StreamEdit, }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts b/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts new file mode 100644 index 00000000000..e205160f931 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts @@ -0,0 +1,7 @@ +import type { CreateStreamPayload } from '@linode/api-v4'; +import type { DestinationFormType } from 'src/features/Delivery/Shared/types'; + +export interface StreamAndDestinationFormType { + destination: DestinationFormType; + stream: CreateStreamPayload; +} diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.test.tsx similarity index 81% rename from packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx rename to packages/manager/src/features/Delivery/Streams/StreamTableRow.test.tsx index 025fa1636d6..2c1c8de5e28 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.test.tsx @@ -1,10 +1,10 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { describe, expect } from 'vitest'; +import { beforeEach, describe, expect } from 'vitest'; -import { streamFactory } from 'src/factories/datastream'; -import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; +import { streamFactory } from 'src/factories/delivery'; +import { StreamTableRow } from 'src/features/Delivery/Streams/StreamTableRow'; import { mockMatchMedia, renderWithTheme, @@ -16,6 +16,10 @@ const fakeHandler = vi.fn(); describe('StreamTableRow', () => { const stream = { ...streamFactory.build(), id: 1 }; + beforeEach(() => { + vi.stubEnv('TZ', 'UTC'); + }); + it('should render a stream row', async () => { mockMatchMedia(); renderWithTheme( @@ -30,7 +34,7 @@ describe('StreamTableRow', () => { ); // Name: - screen.getByText('Data Stream 1'); + screen.getByText('Stream 1'); // Stream Type: screen.getByText('Audit Logs'); // Status: diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx similarity index 77% rename from packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx rename to packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx index 4a5c0f0cacb..13cf2ab559f 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx @@ -2,14 +2,15 @@ import { Hidden } from '@linode/ui'; import * as React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { getDestinationTypeOption, getStreamTypeOption, -} from 'src/features/DataStream/dataStreamUtils'; -import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +} from 'src/features/Delivery/deliveryUtils'; +import { StreamActionMenu } from 'src/features/Delivery/Streams/StreamActionMenu'; import type { Handlers as StreamHandlers } from './StreamActionMenu'; import type { Stream, StreamStatus } from '@linode/api-v4'; @@ -20,16 +21,19 @@ interface StreamTableRowProps extends StreamHandlers { export const StreamTableRow = React.memo((props: StreamTableRowProps) => { const { stream, onDelete, onDisableOrEnable, onEdit } = props; + const { id, status } = stream; return ( - - {stream.label} + + + {stream.label} + {getStreamTypeOption(stream.type)?.label} - - {humanizeStreamStatus(stream.status)} + + {humanizeStreamStatus(status)} - {stream.id} + {id} {getDestinationTypeOption(stream.destinations[0]?.type)?.label} diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx similarity index 92% rename from packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx rename to packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx index e14ef8f8872..b21e33cf5dc 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx @@ -7,8 +7,8 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { streamFactory } from 'src/factories/datastream'; -import { StreamsLanding } from 'src/features/DataStream/Streams/StreamsLanding'; +import { streamFactory } from 'src/factories/delivery'; +import { StreamsLanding } from 'src/features/Delivery/Streams/StreamsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; @@ -48,13 +48,12 @@ const streams = [stream, ...streamFactory.buildList(30)]; describe('Streams Landing Table', () => { const renderComponentAndWaitForLoadingComplete = async () => { renderWithTheme(, { - initialRoute: '/datastream/streams', + initialRoute: '/logs/delivery/streams', }); const loadingElement = screen.queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); }; beforeEach(() => { @@ -104,7 +103,7 @@ describe('Streams Landing Table', () => { await renderComponentAndWaitForLoadingComplete(); screen.getByText((text) => - text.includes('Create a data stream and configure delivery of cloud logs') + text.includes('Create a stream and configure delivery of cloud logs') ); }); @@ -139,7 +138,7 @@ describe('Streams Landing Table', () => { await clickOnActionMenuItem('Edit'); expect(mockNavigate).toHaveBeenCalledWith({ - to: '/datastream/streams/1/edit', + to: '/logs/delivery/streams/1/edit', }); }); }); @@ -158,7 +157,7 @@ describe('Streams Landing Table', () => { expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ id: 1, status: 'inactive', - label: 'Data Stream 1', + label: 'Stream 1', destinations: [123], details: {}, type: 'audit_logs', @@ -181,7 +180,7 @@ describe('Streams Landing Table', () => { expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ id: 1, status: 'active', - label: 'Data Stream 1', + label: 'Stream 1', destinations: [123], details: {}, type: 'audit_logs', diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx similarity index 91% rename from packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx rename to packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx index 6d131412af3..de441286baf 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx @@ -13,15 +13,15 @@ import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { TableSortCell } from 'src/components/TableSortCell'; -import { DataStreamTabHeader } from 'src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader'; -import { streamStatusOptions } from 'src/features/DataStream/Shared/types'; +import { DeliveryTabHeader } from 'src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader'; +import { streamStatusOptions } from 'src/features/Delivery/Shared/types'; import { STREAMS_TABLE_DEFAULT_ORDER, STREAMS_TABLE_DEFAULT_ORDER_BY, STREAMS_TABLE_PREFERENCE_KEY, -} from 'src/features/DataStream/Streams/constants'; -import { StreamsLandingEmptyState } from 'src/features/DataStream/Streams/StreamsLandingEmptyState'; -import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; +} from 'src/features/Delivery/Streams/constants'; +import { StreamsLandingEmptyState } from 'src/features/Delivery/Streams/StreamsLandingEmptyState'; +import { StreamTableRow } from 'src/features/Delivery/Streams/StreamTableRow'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -32,9 +32,9 @@ import type { Stream } from '@linode/api-v4'; export const StreamsLanding = () => { const navigate = useNavigate(); - const streamsUrl = '/datastream/streams'; + const streamsUrl = '/logs/delivery/streams'; const search = useSearch({ - from: '/datastream/streams', + from: '/logs/delivery/streams', shouldThrow: false, }); const pagination = usePaginationV2({ @@ -103,7 +103,7 @@ export const StreamsLanding = () => { }; const navigateToCreate = () => { - navigate({ to: '/datastream/streams/create' }); + navigate({ to: '/logs/delivery/streams/create' }); }; if (isLoading) { @@ -121,7 +121,7 @@ export const StreamsLanding = () => { } const handleEdit = ({ id }: Stream) => { - navigate({ to: `/datastream/streams/${id}/edit` }); + navigate({ to: `/logs/delivery/streams/${id}/edit` }); }; const handleDelete = ({ id, label }: Stream) => { @@ -194,7 +194,7 @@ export const StreamsLanding = () => { return ( <> - diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts similarity index 87% rename from packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts rename to packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts index 420a3671c26..c62f70d5607 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts +++ b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts @@ -7,7 +7,7 @@ import type { export const headers: ResourcesHeaders = { title: 'Streams', subtitle: '', - description: 'Create a data stream and configure delivery of cloud logs', + description: 'Create a stream and configure delivery of cloud logs', }; export const linkAnalyticsEvent: ResourcesLinks['linkAnalyticsEvent'] = { diff --git a/packages/manager/src/features/DataStream/Streams/constants.ts b/packages/manager/src/features/Delivery/Streams/constants.ts similarity index 100% rename from packages/manager/src/features/DataStream/Streams/constants.ts rename to packages/manager/src/features/Delivery/Streams/constants.ts diff --git a/packages/manager/src/features/Delivery/deliveryLandingLazyRoute.ts b/packages/manager/src/features/Delivery/deliveryLandingLazyRoute.ts new file mode 100644 index 00000000000..23a70e903ec --- /dev/null +++ b/packages/manager/src/features/Delivery/deliveryLandingLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { DeliveryLanding } from 'src/features/Delivery/DeliveryLanding'; + +export const deliveryLandingLazyRoute = createLazyRoute('/logs/delivery')({ + component: DeliveryLanding, +}); diff --git a/packages/manager/src/features/DataStream/dataStreamUtils.test.ts b/packages/manager/src/features/Delivery/deliveryUtils.test.ts similarity index 72% rename from packages/manager/src/features/DataStream/dataStreamUtils.test.ts rename to packages/manager/src/features/Delivery/deliveryUtils.test.ts index b65d3be7593..aa255f8ecc2 100644 --- a/packages/manager/src/features/DataStream/dataStreamUtils.test.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.test.ts @@ -1,10 +1,10 @@ import { destinationType } from '@linode/api-v4'; import { expect } from 'vitest'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; +import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; -describe('dataStream utils functions', () => { +describe('delivery utils functions', () => { describe('getDestinationTypeOption ', () => { it('should return option object matching provided value', () => { const result = getDestinationTypeOption( diff --git a/packages/manager/src/features/DataStream/dataStreamUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts similarity index 71% rename from packages/manager/src/features/DataStream/dataStreamUtils.ts rename to packages/manager/src/features/Delivery/deliveryUtils.ts index 50111ba9186..918cb0a2c96 100644 --- a/packages/manager/src/features/DataStream/dataStreamUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -1,16 +1,21 @@ -import { isEmpty, streamType } from '@linode/api-v4'; +import { + type Destination, + isEmpty, + type Stream, + streamType, +} from '@linode/api-v4'; import { omitProps } from '@linode/ui'; import { destinationTypeOptions, streamTypeOptions, -} from 'src/features/DataStream/Shared/types'; +} from 'src/features/Delivery/Shared/types'; import type { StreamDetails, StreamType } from '@linode/api-v4'; import type { FormMode, LabelValueOption, -} from 'src/features/DataStream/Shared/types'; +} from 'src/features/Delivery/Shared/types'; export const getDestinationTypeOption = ( destinationTypeValue: string @@ -40,3 +45,11 @@ export const getStreamPayloadDetails = ( return payloadDetails; }; + +export const getStreamDescription = (stream: Stream) => { + return `${getStreamTypeOption(stream.type)?.label}`; +}; + +export const getDestinationDescription = (destination: Destination) => { + return `${getDestinationTypeOption(destination.type)?.label}`; +}; diff --git a/packages/manager/src/features/Domains/DomainTableRow.tsx b/packages/manager/src/features/Domains/DomainTableRow.tsx index 2d44ce9827a..b2d7a4acd20 100644 --- a/packages/manager/src/features/Domains/DomainTableRow.tsx +++ b/packages/manager/src/features/Domains/DomainTableRow.tsx @@ -1,4 +1,4 @@ -import { StyledLinkButton } from '@linode/ui'; +import { LinkButton } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -29,9 +29,9 @@ export const DomainTableRow = React.memo((props: DomainTableRowProps) => { {domain.type !== 'slave' ? ( {domain.domain} ) : ( - props.onEdit(domain)}> + props.onEdit(domain)}> {domain.domain} - + )} diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.test.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.test.tsx index 3a82d17b8e2..a84c2584297 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.test.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.test.tsx @@ -49,7 +49,7 @@ describe('TransferControls', () => { expect(makeServiceTransferBtn).not.toHaveAttribute('aria-disabled', 'true'); }); - it('should disable "Review Details" button if the user does not have accept_service_transfer permission', async () => { + it('should disable "Review Details" button and transfer textfield if the user does not have accept_service_transfer permission', async () => { const { getByRole } = renderWithTheme( ); @@ -58,6 +58,15 @@ describe('TransferControls', () => { name: 'Review Details', }); expect(reviewBtn).toHaveAttribute('aria-disabled', 'true'); + expect(reviewBtn).toHaveAttribute( + 'data-qa-tooltip', + 'You do not have permission to receive service transfers.' + ); + + const tokenTextfield = getByRole('textbox', { + name: 'Receive a Service Transfer', + }); + expect(tokenTextfield).toBeDisabled(); }); it('should enable "Review Details" button if the user has accept_service_transfer permission', async () => { @@ -72,6 +81,13 @@ describe('TransferControls', () => { ); + const reviewBtnBeforeInput = getByRole('button', { + name: 'Review Details', + }); + expect(reviewBtnBeforeInput).toHaveAttribute( + 'data-qa-tooltip', + 'Enter a service transfer token to review the details and accept the transfer.' + ); const input = getByPlaceholderText('Enter a token'); await userEvent.type(input, 'test-token'); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx index 44606440ee8..4171b98e4e3 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx @@ -66,6 +66,7 @@ export const TransferControls = React.memo((props: Props) => { direction="row" > { buttonType="primary" disabled={!permissions.accept_service_transfer || token === ''} onClick={() => setConfirmDialogOpen(true)} - tooltipText="Enter a service transfer token to review the details and accept the transfer." + tooltipText={ + !permissions.accept_service_transfer + ? 'You do not have permission to receive service transfers.' + : 'Enter a service transfer token to review the details and accept the transfer.' + } > Review Details diff --git a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx index 124b873abc9..c3e757331e0 100644 --- a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx +++ b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx @@ -1,4 +1,4 @@ -import { StyledLinkButton } from '@linode/ui'; +import { LinkButton } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize, pluralize } from '@linode/utilities'; import * as React from 'react'; @@ -63,9 +63,9 @@ export const RenderTransferRow = React.memo((props: Props) => { - handleTokenClick(token, entities)}> + handleTokenClick(token, entities)}> {token} - +
    diff --git a/packages/manager/src/features/Events/factories/datastream.tsx b/packages/manager/src/features/Events/factories/delivery.tsx similarity index 100% rename from packages/manager/src/features/Events/factories/datastream.tsx rename to packages/manager/src/features/Events/factories/delivery.tsx diff --git a/packages/manager/src/features/Events/factories/index.ts b/packages/manager/src/features/Events/factories/index.ts index 92329fa769b..c7b1b0eee84 100644 --- a/packages/manager/src/features/Events/factories/index.ts +++ b/packages/manager/src/features/Events/factories/index.ts @@ -3,7 +3,7 @@ export * from './backup'; export * from './community'; export * from './credit'; export * from './database'; -export * from './datastream'; +export * from './delivery'; export * from './disk'; export * from './dns'; export * from './domain'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx index 6e7039fcb4d..fa0044ee478 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx @@ -25,7 +25,7 @@ describe('FirewallDeviceRow', () => { }); server.use( - http.get('*/v4/account', () => { + http.get('*/v4*/account', () => { return HttpResponse.json(account); }) ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.styles.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.styles.ts index 6802911f61e..ec4b058c5cd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.styles.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.styles.ts @@ -1,4 +1,4 @@ -import { Box, Button, omittedProps, StyledLinkButton } from '@linode/ui'; +import { Box, Button, omittedProps } from '@linode/ui'; import { styled } from '@mui/material/styles'; import DragIndicator from 'src/assets/icons/drag-indicator.svg'; @@ -58,12 +58,6 @@ export const StyledFirewallTableButton = styled(Button, { margin: `${theme.spacing(1)} 0px`, })); -export const MoreStyledLinkButton = styled(StyledLinkButton, { - label: 'MoreStyledLinkButton', -})(({ ...props }) => ({ - color: props.disabled ? 'inherit' : '', -})); - export const StyledButtonDiv = styled('div', { label: 'StyledButtonDiv' })({ alignContent: 'center', display: 'flex', diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 3969eefa1e5..661625a14d8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -14,7 +14,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Box, Typography } from '@linode/ui'; +import { Box, LinkButton, Typography } from '@linode/ui'; import { Autocomplete } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize } from '@linode/utilities'; @@ -40,7 +40,6 @@ import { CustomKeyboardSensor } from 'src/utilities/CustomKeyboardSensor'; import { FirewallRuleActionMenu } from './FirewallRuleActionMenu'; import { - MoreStyledLinkButton, StyledButtonDiv, StyledDragIndicator, StyledErrorDiv, @@ -361,12 +360,12 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { {label || ( - handleOpenRuleDrawerForEditing(index)} > Add a label - + )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index 7b3259387a1..856359c8194 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -8,6 +8,7 @@ import { Chip, CircleProgress, ErrorState, + LinkButton, Paper, Typography, } from '@linode/ui'; @@ -19,7 +20,6 @@ import { AkamaiBanner } from 'src/components/AkamaiBanner/AkamaiBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/GenerateFirewallDialog'; import { LandingHeader } from 'src/components/LandingHeader'; -import { LinkButton } from 'src/components/LinkButton'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx index e137950eab6..b05c0bfbf0b 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx @@ -84,7 +84,7 @@ describe('Create Firewall Drawer', () => { capabilities: ['Linode Interfaces'], }); - server.use(http.get('*/v4/account', () => HttpResponse.json(account))); + server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); const { getByLabelText, findByTestId } = renderWithTheme( , diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx index 438c099345c..0b17aaf6de8 100644 --- a/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx +++ b/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx @@ -47,7 +47,7 @@ interface Props export const FirewallSelect = ( props: Props ) => { - const { errorText, hideDefaultChips, loading, value, ...rest } = props; + const { errorText, hideDefaultChips, label, loading, value, ...rest } = props; const { data: firewalls, error, isLoading } = useAllFirewallsQuery(); @@ -61,8 +61,9 @@ export const FirewallSelect = ( return ( + aria-label={label === '' ? 'Firewall' : undefined} errorText={errorText ?? error?.[0].reason} - label="Firewall" + label={label ?? 'Firewall'} loading={isLoading || loading} noMarginTop options={firewalls ?? []} diff --git a/packages/manager/src/features/Footer.tsx b/packages/manager/src/features/Footer.tsx index c439b665d64..a501ca9a677 100644 --- a/packages/manager/src/features/Footer.tsx +++ b/packages/manager/src/features/Footer.tsx @@ -7,6 +7,8 @@ import { DEVELOPERS_LINK, FEEDBACK_LINK } from 'src/constants'; import packageJson from '../../package.json'; +export const FOOTER_HEIGHT = 45; + export const Footer = React.memo(() => { return (