diff --git a/docs/tooling/analytics.md b/docs/tooling/analytics.md index 9c8c994c08a..e9d91cb86e2 100644 --- a/docs/tooling/analytics.md +++ b/docs/tooling/analytics.md @@ -12,11 +12,11 @@ Pendo is configured in [`usePendo.js`](https://github.com/linode/manager/blob/de Important notes: -- Pendo is only loaded if a valid `PENDO_API_KEY` is configured as an environment variable. In our development, staging, and production environments, `PENDO_API_KEY` is available at build time. See **Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo** for set up with local environments. +- Pendo is only loaded if the user has enabled Performance Cookies via OneTrust *and* if a valid `PENDO_API_KEY` is configured as an environment variable. In our development, staging, and production environments, `PENDO_API_KEY` is available at build time. See **Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo** for set up with local environments. - We load the Pendo agent from the CDN, rather than [self-hosting](https://support.pendo.io/hc/en-us/articles/360038969692-Self-hosting-the-Pendo-agent), and we have configured a [CNAME](https://support.pendo.io/hc/en-us/articles/360043539891-CNAME-for-Pendo). - We are hashing account and visitor IDs in a way that is consistent with Akamai's standards. - At initialization, we do string transformation on select URL patterns to **remove sensitive data**. When new URL patterns are added to Cloud Manager, verify that existing transforms remove sensitive data; if not, update the transforms. -- Pendo is currently not using any client-side (cookies or local) storage. +- Pendo will respect OneTrust cookie preferences in development, staging, and production environments and does not check cookie preferences in the local environment. - Pendo makes use of the existing `data-testid` properties, used in our automated testing, for tagging elements. They are more persistent and reliable than CSS properties, which are liable to change. ### Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo diff --git a/package.json b/package.json index a23e6466eff..fe481632491 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "devDependencies": { "husky": "^9.1.6", "typescript": "^5.7.3", - "vitest": "^2.1.1" + "vitest": "^3.0.5" }, "scripts": { "lint": "yarn run eslint . --quiet --ext .js,.ts,.tsx", diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index c3f91bdc99e..8a8711604c7 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,32 @@ +## [2025-02-11] - v0.134.0 + + +### Added: + +- Labels and Taints types and params ([#11528](https://github.com/linode/manager/pull/11528)) +- API endpoints for NotificationChannels ([#11547](https://github.com/linode/manager/pull/11547)) +- `service-transfer` related endpoints ([#11562](https://github.com/linode/manager/pull/11562)) +- `billing_agreement` to Agreements interface ([#11563](https://github.com/linode/manager/pull/11563)) +- `Enhanced Interfaces` to a Region's `Capabilities` ([#11584](https://github.com/linode/manager/pull/11584)) +- New database statuses for database_migration event ([#11590](https://github.com/linode/manager/pull/11590)) + +### Changed: + +- Quotas API spec to make region field optional ([#11551](https://github.com/linode/manager/pull/11551)) +- Update Taint value to allow undefined ([#11553](https://github.com/linode/manager/pull/11553)) +- Mark `entity-transfers` related endpoints as deprecated ([#11562](https://github.com/linode/manager/pull/11562)) + +### Upcoming Features: + +- Update `PermissionType` types for IAM ([#11423](https://github.com/linode/manager/pull/11423)) +- Add new API types and endpoints for Linode Interfaces project: `/v4/linodes/instances` ([#11527](https://github.com/linode/manager/pull/11527)) +- Update `AccountAccessType` and `RoleType` types for IAM ([#11533](https://github.com/linode/manager/pull/11533)) +- Add and update `/v4/networking` endpoints and types for Linode Interfaces ([#11559](https://github.com/linode/manager/pull/11559)) +- Update `/v4/account` and `/v4/vpcs` endpoints and types for upcoming Linode Interfaces project ([#11562](https://github.com/linode/manager/pull/11562)) +- Update existing `v4/linodes/instances` endpoints and types for Linode Interfaces project ([#11566](https://github.com/linode/manager/pull/11566)) +- Add new `editAlertDefinition` endpoint to edit the resources associated with CloudPulse alerts ([#11583](https://github.com/linode/manager/pull/11583)) +- Add support for quotas usage endpoint ([#11597](https://github.com/linode/manager/pull/11597)) + ## [2025-01-28] - v0.133.0 ### Changed: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 9be35fb4b2e..83f886c61cc 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.133.0", + "version": "0.134.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 08b575c7606..ab7a9f001cb 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -94,12 +94,22 @@ export interface AccountAvailability { unavailable: Capabilities[]; } +export const linodeInterfaceAccountSettings = [ + 'legacy_config_only', + 'legacy_config_default_but_linode_allowed', + 'linode_default_but_legacy_config_allowed', + 'linode_only', +]; + +export type LinodeInterfaceAccountSetting = typeof linodeInterfaceAccountSettings[number]; + export interface AccountSettings { managed: boolean; longview_subscription: string | null; network_helper: boolean; backups_enabled: boolean; object_storage: 'active' | 'disabled' | 'suspended'; + interfaces_for_new_linodes: LinodeInterfaceAccountSetting; } export interface ActivePromotion { @@ -254,6 +264,7 @@ export type AgreementType = 'eu_model' | 'privacy_policy'; export interface Agreements { eu_model: boolean; privacy_policy: boolean; + billing_agreement: boolean; } export type NotificationType = @@ -320,6 +331,7 @@ export const EventActionKeys = [ 'database_scale', 'database_update_failed', 'database_update', + 'database_migrate', 'database_upgrade', 'disk_create', 'disk_delete', @@ -358,6 +370,9 @@ export const EventActionKeys = [ 'image_delete', 'image_update', 'image_upload', + 'interface_create', + 'interface_delete', + 'interface_update', 'ipaddress_update', 'ipv6pool_add', 'ipv6pool_delete', diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 54ece904adc..d3efb663433 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -6,7 +6,13 @@ import Request, { setParams, setXFilter, } from '../request'; -import { Alert, AlertServiceType, CreateAlertDefinitionPayload } from './types'; +import { + Alert, + AlertServiceType, + CreateAlertDefinitionPayload, + EditAlertDefinitionPayload, + NotificationChannel, +} from './types'; import { BETA_API_ROOT as API_ROOT } from '../constants'; import { Params, Filter, ResourcePage } from '../types'; @@ -44,3 +50,25 @@ export const getAlertDefinitionByServiceTypeAndId = ( ), setMethod('GET') ); + +export const editAlertDefinition = ( + data: EditAlertDefinitionPayload, + serviceType: string, + alertId: number +) => + Request( + setURL( + `${API_ROOT}/monitor/services/${encodeURIComponent( + serviceType + )}/alert-definitions/${encodeURIComponent(alertId)}` + ), + setMethod('PUT'), + setData(data) + ); +export const getNotificationChannels = (params?: Params, filters?: Filter) => + Request>( + setURL(`${API_ROOT}/monitor/alert-channels`), + setMethod('GET'), + setParams(params), + setXFilter(filters) + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 1fa7b63d280..7a43d6c192d 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -294,3 +294,7 @@ export type NotificationChannel = | NotificationChannelSlack | NotificationChannelWebHook | NotificationChannelPagerDuty; + +export interface EditAlertDefinitionPayload { + entity_ids: string[]; +} diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 711dbb3aec3..72b0b03bc8d 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -33,6 +33,8 @@ export type DatabaseStatus = | 'active' | 'degraded' | 'failed' + | 'migrating' + | 'migrated' | 'provisioning' | 'resizing' | 'restoring' diff --git a/packages/api-v4/src/entity-transfers/transfers.ts b/packages/api-v4/src/entity-transfers/transfers.ts index 61a3c2c19c0..ec32e88040e 100644 --- a/packages/api-v4/src/entity-transfers/transfers.ts +++ b/packages/api-v4/src/entity-transfers/transfers.ts @@ -11,6 +11,7 @@ import { Filter, Params, ResourcePage as Page } from '../types'; import { CreateTransferPayload, EntityTransfer } from './types'; /** + * @deprecated * getEntityTransfers * * Returns a paginated list of all Entity Transfers which this customer has created or accepted. @@ -24,6 +25,7 @@ export const getEntityTransfers = (params?: Params, filter?: Filter) => ); /** + * @deprecated * getEntityTransfer * * Get a single Entity Transfer by its token (uuid). A Pending transfer @@ -39,6 +41,7 @@ export const getEntityTransfer = (token: string) => ); /** + * @deprecated * createEntityTransfer * * Creates a pending Entity Transfer for one or more entities on @@ -52,6 +55,7 @@ export const createEntityTransfer = (data: CreateTransferPayload) => ); /** + * @deprecated * acceptEntityTransfer * * Accepts a transfer that has been created by a user on a different account. @@ -67,6 +71,7 @@ export const acceptEntityTransfer = (token: string) => ); /** + * @deprecated * cancelTransfer * * Cancels a pending transfer. Only unrestricted users on the account diff --git a/packages/api-v4/src/firewalls/firewalls.ts b/packages/api-v4/src/firewalls/firewalls.ts index 6b247ce7eac..7b5671110ff 100644 --- a/packages/api-v4/src/firewalls/firewalls.ts +++ b/packages/api-v4/src/firewalls/firewalls.ts @@ -11,6 +11,7 @@ import { CreateFirewallSchema, FirewallDeviceSchema, UpdateFirewallSchema, + UpdateFirewallSettingsSchema, } from '@linode/validation/lib/firewalls.schema'; import { CreateFirewallPayload, @@ -18,8 +19,12 @@ import { FirewallDevice, FirewallDevicePayload, FirewallRules, + FirewallSettings, FirewallTemplate, + FirewallTemplateSlug, UpdateFirewallPayload, + UpdateFirewallRules, + UpdateFirewallSettings, } from './types'; /** @@ -150,7 +155,10 @@ export const getFirewallRules = ( * Updates the inbound and outbound Rules for a Firewall. Using this endpoint will * replace all of a Firewall's ruleset with the Rules specified in your request. */ -export const updateFirewallRules = (firewallID: number, data: FirewallRules) => +export const updateFirewallRules = ( + firewallID: number, + data: UpdateFirewallRules +) => Request( setMethod('PUT'), setData(data), // Validation is too complicated for these; leave it to the API. @@ -245,6 +253,29 @@ export const deleteFirewallDevice = (firewallID: number, deviceID: number) => ) ); +/** + * getFirewallSettings + * + * Returns current interface default firewall settings + */ +export const getFirewallSettings = () => + Request( + setMethod('GET'), + setURL(`${BETA_API_ROOT}/networking/firewalls/settings`) + ); + +/** + * updateFirewallSettings + * + * Update which firewalls should be the interface default firewalls + */ +export const updateFirewallSettings = (data: UpdateFirewallSettings) => + Request( + setMethod('PUT'), + setURL(`${BETA_API_ROOT}/networking/firewalls/settings`), + setData(data, UpdateFirewallSettingsSchema) + ); + // #region Templates /** @@ -262,9 +293,8 @@ export const getTemplates = () => * getTemplate * * Get a specific firewall template by its slug. - * */ -export const getTemplate = (templateSlug: string) => +export const getTemplate = (templateSlug: FirewallTemplateSlug) => Request( setMethod('GET'), setURL( diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 53bd059841b..859b3ad9402 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -2,7 +2,7 @@ export type FirewallStatus = 'enabled' | 'disabled' | 'deleted'; export type FirewallRuleProtocol = 'ALL' | 'TCP' | 'UDP' | 'ICMP' | 'IPENCAP'; -export type FirewallDeviceEntityType = 'linode' | 'nodebalancer'; +export type FirewallDeviceEntityType = 'linode' | 'nodebalancer' | 'interface'; export type FirewallPolicyType = 'ACCEPT' | 'DROP'; @@ -14,21 +14,25 @@ export interface Firewall { rules: FirewallRules; created: string; updated: string; - entities: { - id: number; - type: FirewallDeviceEntityType; - label: string; - url: string; - }[]; + entities: FirewallDeviceEntity[]; } export interface FirewallRules { + fingerprint: string; inbound?: FirewallRuleType[] | null; outbound?: FirewallRuleType[] | null; inbound_policy: FirewallPolicyType; outbound_policy: FirewallPolicyType; + version: number; } +export type UpdateFirewallRules = Omit< + FirewallRules, + 'fingerprint' | 'version' +>; + +export type FirewallTemplateRules = UpdateFirewallRules; + export interface FirewallRuleType { label?: string | null; description?: string | null; @@ -55,18 +59,21 @@ export interface FirewallDevice { entity: FirewallDeviceEntity; } +export type FirewallTemplateSlug = 'akamai-non-prod' | 'vpc' | 'public'; + export interface FirewallTemplate { - slug: string; - rules: FirewallRules; + slug: FirewallTemplateSlug; + rules: FirewallTemplateRules; } export interface CreateFirewallPayload { label?: string; tags?: string[]; - rules: FirewallRules; + rules: UpdateFirewallRules; devices?: { linodes?: number[]; nodebalancers?: number[]; + interfaces?: number[]; }; } @@ -80,3 +87,18 @@ export interface FirewallDevicePayload { id: number; type: FirewallDeviceEntityType; } + +export interface DefaultFirewallIDs { + public_interface: number; + vpc_interface: number; + linode: number; + nodebalancer: number; +} + +export interface FirewallSettings { + default_firewall_ids: DefaultFirewallIDs; +} + +export interface UpdateFirewallSettings { + default_firewall_ids: Partial; +} diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 3749b644e64..ea8fb3ec9d6 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -15,10 +15,12 @@ export type AccountAccessType = | 'account_linode_admin' | 'linode_creator' | 'linode_contributor' + | 'account_admin' | 'firewall_creator'; export type RoleType = | 'linode_contributor' + | 'linode_viewer' | 'firewall_admin' | 'linode_creator' | 'firewall_creator'; @@ -33,12 +35,162 @@ export interface ResourceAccess { roles: RoleType[]; } -type PermissionType = +export type PermissionType = + | 'acknowledge_account_agreement' + | 'add_nodebalancer_config_node' + | 'add_nodebalancer_config' + | 'allocate_ip' + | 'allocate_linode_ip_address' + | 'assign_ips' + | 'assign_ipv4' + | 'attach_volume' + | 'boot_linode' + | 'cancel_account' + | 'cancel_linode_backups' + | 'clone_linode_disk' + | 'clone_linode' + | 'clone_volume' + | 'create_firewall_device' + | 'create_firewall' + | 'create_image' + | 'create_ipv6_range' + | 'create_linode_backup_snapshot' + | 'create_linode_config_profile_interface' + | 'create_linode_config_profile' + | 'create_linode_disk' | 'create_linode' - | 'update_linode' - | 'update_firewall' + | 'create_nodebalancer' + | 'create_oauth_client' + | 'create_payment_method' + | 'create_promo_code' + | 'create_service_transfer' + | 'create_user' + | 'create_volume' + | 'create_vpc_subnet' + | 'create_vpc' + | 'delete_firewall_device' + | 'delete_firewall' + | 'delete_image' + | 'delete_linode_config_profile_interface' + | 'delete_linode_config_profile' + | 'delete_linode_disk' + | 'delete_linode_ip_address' | 'delete_linode' - | 'view_linode'; + | 'delete_nodebalancer_config_node' + | 'delete_nodebalancer_config' + | 'delete_nodebalancer' + | 'delete_payment_method' + | 'delete_user' + | 'delete_volume' + | 'delete_vpc_subnet' + | 'delete_vpc' + | 'detach_volume' + | 'enable_linode_backups' + | 'enable_managed' + | 'enroll_beta_program' + | 'list_account_agreements' + | 'list_account_logins' + | 'list_all_vpc_ipaddresses' + | 'list_available_services' + | 'list_child_accounts' + | 'list_enrolled_beta_programs' + | 'list_events' + | 'list_firewall_devices' + | 'list_firewalls' + | 'list_images' + | 'list_invoice_items' + | 'list_invoices' + | 'list_linode_backups' + | 'list_linode_config_profile_interfaces' + | 'list_linode_config_profiles' + | 'list_linode_disks' + | 'list_linode_firewalls' + | 'list_linode_kernels' + | 'list_linode_nodebalancers' + | 'list_linode_types' + | 'list_linode_volumes' + | 'list_linodes' + | 'list_maintenances' + | 'list_nodebalancer_config_nodes' + | 'list_nodebalancer_configs' + | 'list_nodebalancer_firewalls' + | 'list_nodebalancers' + | 'list_notifications' + | 'list_oauth_clients' + | 'list_payment_methods' + | 'list_payments' + | 'list_service_transfers' + | 'list_users' + | 'list_volumes' + | 'list_vpc_ip_addresses' + | 'list_vpc_subnets' + | 'list_vpcs' + | 'make_payment' + | 'migrate_linode' + | 'password_reset_linode' + | 'reboot_linode' + | 'rebuild_linode' + | 'rebuild_nodebalancer_config' + | 'reorder_linode_config_profile_interfaces' + | 'rescue_linode' + | 'reset_linode_disk_root_password' + | 'resize_linode_disk' + | 'resize_linode' + | 'resize_volume' + | 'restore_linode_backup' + | 'set_default_payment_method' + | 'share_ips' + | 'share_ipv4' + | 'shutdown_linode' + | 'update_account_settings' + | 'update_account' + | 'update_firewall_rules' + | 'update_firewall' + | 'update_image' + | 'update_linode_config_profile_interface' + | 'update_linode_config_profile' + | 'update_linode_disk' + | 'update_linode_ip_address' + | 'update_linode' + | 'update_nodebalancer_config_node' + | 'update_nodebalancer_config' + | 'update_nodebalancer' + | 'update_user' + | 'update_volume' + | 'update_vpc_subnet' + | 'update_vpc' + | 'upgrade_linode' + | 'upload_image' + | 'view_account_settings' + | 'view_account' + | 'view_firewall_device' + | 'view_firewall' + | 'view_image' + | 'view_invoice' + | 'view_linode_backup' + | 'view_linode_config_profile_interface' + | 'view_linode_config_profile' + | 'view_linode_disk' + | 'view_linode_ip_address' + | 'view_linode_kernel' + | 'view_linode_monthly_network_transfer_stats' + | 'view_linode_monthly_stats' + | 'view_linode_network_transfer' + | 'view_linode_networking_info' + | 'view_linode_stats' + | 'view_linode_type' + | 'view_linode' + | 'view_network_usage' + | 'view_nodebalancer_config_node' + | 'view_nodebalancer_config' + | 'view_nodebalancer_statistics' + | 'view_nodebalancer' + | 'view_payment_method' + | 'view_payment' + | 'view_user' + | 'view_volume' + | 'view_vpc_subnet' + | 'view_vpc'; export interface IamAccountPermissions { account_access: IamAccess[]; diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index fab28fabeb5..c8eea9ea812 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -36,6 +36,8 @@ export * from './quotas'; export * from './regions'; +export * from './service-transfers'; + export * from './stackscripts'; export * from './support'; diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index 6642fcec895..bb370db3688 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -2,6 +2,21 @@ import type { EncryptionStatus } from '../linodes'; export type KubernetesTier = 'standard' | 'enterprise'; +export type KubernetesTaintEffect = + | 'NoSchedule' + | 'PreferNoSchedule' + | 'NoExecute'; + +export type Label = { + [key: string]: string; +}; + +export interface Taint { + effect: KubernetesTaintEffect; + key: string; + value: string | undefined; +} + export interface KubernetesCluster { created: string; updated: string; @@ -22,8 +37,10 @@ export interface KubernetesCluster { export interface KubeNodePoolResponse { count: number; id: number; + labels: Label; nodes: PoolNodeResponse[]; tags: string[]; + taints: Taint[]; type: string; autoscaler: AutoscaleSettings; disk_encryption?: EncryptionStatus; // @TODO LDE: remove optionality once LDE is fully rolled out @@ -44,6 +61,8 @@ export interface UpdateNodePoolData { autoscaler: AutoscaleSettings; count: number; tags: string[]; + labels: Label; + taints: Taint[]; } export interface AutoscaleSettings { diff --git a/packages/api-v4/src/linodes/configs.ts b/packages/api-v4/src/linodes/configs.ts index 9e3525c2887..0c3927fa58a 100644 --- a/packages/api-v4/src/linodes/configs.ts +++ b/packages/api-v4/src/linodes/configs.ts @@ -3,7 +3,7 @@ import { UpdateConfigInterfaceOrderSchema, UpdateConfigInterfaceSchema, UpdateLinodeConfigSchema, - LinodeInterfaceSchema, + ConfigProfileInterfaceSchema, } from '@linode/validation/lib/linodes.schema'; import { API_ROOT } from '../constants'; import Request, { @@ -98,6 +98,7 @@ export const deleteLinodeConfig = (linodeId: number, configId: number) => * updateLinodeConfig * * Update a configuration profile. + * Interfaces field must be omitted or null if Linode is using new Linode Interfaces. * * @param linodeId { number } The id of a Linode the specified config is attached to. * @param configId { number } The id of the config to be updated. @@ -164,6 +165,7 @@ export const getConfigInterface = ( * appendConfigInterface * * Append a single new Linode config interface object to an existing config. + * Cannot be used for Linodes using the new Linode Interfaces. * * @param linodeId { number } The id of a Linode to receive the new config interface. * @param configId { number } The id of a config to receive the new interface. @@ -180,7 +182,7 @@ export const appendConfigInterface = ( )}/configs/${encodeURIComponent(configId)}/interfaces` ), setMethod('POST'), - setData(data, LinodeInterfaceSchema) + setData(data, ConfigProfileInterfaceSchema) ); /** diff --git a/packages/api-v4/src/linodes/linode-interfaces.ts b/packages/api-v4/src/linodes/linode-interfaces.ts new file mode 100644 index 00000000000..0eeb978983a --- /dev/null +++ b/packages/api-v4/src/linodes/linode-interfaces.ts @@ -0,0 +1,237 @@ +import { + CreateLinodeInterfaceSchema, + ModifyLinodeInterfaceSchema, + UpdateLinodeInterfaceSettingsSchema, + UpgradeToLinodeInterfaceSchema, +} from '@linode/validation'; +import type { Firewall } from 'src/firewalls/types'; +import { BETA_API_ROOT } from '../constants'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; +import { Filter, ResourcePage as Page, Params } from '../types'; +import type { + CreateLinodeInterfacePayload, + LinodeInterfaceHistory, + LinodeInterfaceSettings, + LinodeInterfaceSettingsPayload, + LinodeInterface, + LinodeInterfaces, + ModifyLinodeInterfacePayload, + UpgradeInterfaceData, + UpgradeInterfacePayload, +} from './types'; + +// These endpoints refer to the new Linode Interfaces endpoints. +// For old Configuration Profile interfaces, see config.ts + +/** + * createLinodeInterface + * + * Adds a new Linode Interface to a Linode. + * + * @param linodeId { number } The id of a Linode to receive the new interface. + */ +export const createLinodeInterface = ( + linodeId: number, + data: CreateLinodeInterfacePayload +) => + Request( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces` + ), + setMethod('POST'), + setData(data, CreateLinodeInterfaceSchema) + ); + +/** + * getLinodeInterfaces + * + * Gets LinodeInterfaces associated with the specified Linode. + * + * @param linodeId { number } The id of the Linode to get all Linode Interfaces for. + */ +export const getLinodeInterfaces = (linodeId: number) => + Request( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces` + ), + setMethod('GET') + ); + +/** + * getLinodeInterfacesHistory + * + * Returns paginated list of interface history for specified Linode. + * + * @param linodeId { number } The id of a Linode to get the interface history for. + */ +export const getLinodeInterfacesHistory = ( + linodeId: number, + params?: Params, + filters?: Filter +) => + Request>( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces/history` + ), + setMethod('GET'), + setParams(params), + setXFilter(filters) + ); + +/** + * getLinodeInterfacesSettings + * + * Returns the interface settings related to the specified Linode. + * + * @param linodeId { number } The id of a Linode to get the interface history for. + */ +export const getLinodeInterfacesSettings = (linodeId: number) => + Request( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces/settings` + ), + setMethod('GET') + ); + +/** + * updateLinodeInterfacesSettings + * + * Update the interface settings related to the specified Linode. + * + * @param linodeId { number } The id of a Linode to update the interface settings for. + * @param data { LinodeInterfaceSettingPayload } The payload to update the interface settings with. + */ +export const updateLinodeInterfacesSettings = ( + linodeId: number, + data: LinodeInterfaceSettingsPayload +) => + Request( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces/settings` + ), + setMethod('PUT'), + setData(data, UpdateLinodeInterfaceSettingsSchema) + ); + +/** + * getLinodeInterface + * + * Returns information about a single Linode interface. + * + * @param linodeId { number } The id of a Linode the specified Linode Interface is attached to. + * @param interfaceId { number } The id of the Linode Interface to be returned + */ +export const getLinodeInterface = (linodeId: number, interfaceId: number) => + Request( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces/${encodeURIComponent(interfaceId)}` + ), + setMethod('GET') + ); + +/** + * updateLinodeInterface + * + * Update specified interface for the specified Linode. + * + * @param linodeId { number } The id of a Linode to update the interface history for. + * @param interfaceId { number } The id of the Interface to update. + * @param data { ModifyLinodeInterfacePayload } The payload to update the interface with. + */ +export const updateLinodeInterface = ( + linodeId: number, + interfaceId: number, + data: ModifyLinodeInterfacePayload +) => + Request( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces/${encodeURIComponent(interfaceId)}` + ), + setMethod('PUT'), + setData(data, ModifyLinodeInterfaceSchema) + ); + +/** + * deleteLinodeInterface + * + * Delete a single specified Linode interface. + * + * @param linodeId { number } The id of a Linode to update the interface history for. + * @param interfaceId { number } The id of the Interface to update. + */ +export const deleteLinodeInterface = (linodeId: number, interfaceId: number) => + Request<{}>( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces/${encodeURIComponent(interfaceId)}` + ), + setMethod('DELETE') + ); + +/** + * getLinodeInterfaceFirewalls + * + * Returns information about the firewalls for the specified Linode interface. + * + * @param linodeId { number } The id of a Linode the specified Linode Interface is attached to. + * @param interfaceId { number } The id of the Linode Interface to get the firewalls for + */ +export const getLinodeInterfaceFirewalls = ( + linodeId: number, + interfaceId: number, + params?: Params, + filters?: Filter +) => + Request>( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/interfaces/${encodeURIComponent(interfaceId)}/firewalls` + ), + setMethod('GET'), + setParams(params), + setXFilter(filters) + ); + +/** + * upgradeToLinodeInterface + * + * Upgrades legacy configuration interfaces to new Linode Interfaces. + * This is a POST endpoint. + * + * @param linodeId { number } The id of a Linode to receive the new interface. + */ +export const upgradeToLinodeInterface = ( + linodeId: number, + data: UpgradeInterfacePayload +) => + Request( + setURL( + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( + linodeId + )}/upgrade-interfaces` + ), + setMethod('POST'), + setData(data, UpgradeToLinodeInterfaceSchema) + ); diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 5689c24ba34..418d3bd896f 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -2,11 +2,20 @@ import type { Region, RegionSite } from '../regions'; import type { IPAddress, IPRange } from '../networking/types'; import type { SSHKey } from '../profile/types'; import type { LinodePlacementGroupPayload } from '../placement-groups/types'; +import { InferType } from 'yup'; +import { + CreateLinodeInterfaceSchema, + ModifyLinodeInterfaceSchema, + UpdateLinodeInterfaceSettingsSchema, + UpgradeToLinodeInterfaceSchema, +} from '@linode/validation'; export type Hypervisor = 'kvm' | 'zen'; export type EncryptionStatus = 'enabled' | 'disabled'; +export type InterfaceGenerationType = 'legacy_config' | 'linode'; + export interface LinodeSpecs { disk: number; memory: number; @@ -26,6 +35,7 @@ export interface Linode { region: string; image: string | null; group: string; + interface_generation: InterfaceGenerationType; ipv4: string[]; ipv6: string | null; label: string; @@ -162,6 +172,9 @@ export type LinodeStatus = | 'restoring' | 'stopped'; +// --------------------------------------------------------------------- +// Types relating to legacy interfaces (Configuration profile Interfaces) +// ---------------------------------------------------------------------- export type InterfacePurpose = 'public' | 'vlan' | 'vpc'; export interface ConfigInterfaceIPv4 { @@ -173,6 +186,7 @@ export interface ConfigInterfaceIPv6 { vpc?: string | null; } +// The legacy interface type - for Configuration Profile Interfaces export interface Interface { id: number; label: string | null; @@ -212,9 +226,122 @@ export interface Config { created: string; updated: string; initrd: string | null; - interfaces: Interface[]; + // If a Linode is using the new Linode Interfaces, this field will no longer be present. + interfaces?: Interface[]; +} + +// ---------------------------------------------------------- +// Types relating to new interfaces - Linode Interfaces +// ---------------------------------------------------------- +export interface DefaultRoute { + ipv4?: boolean; + ipv6?: boolean; +} + +export type CreateLinodeInterfacePayload = InferType< + typeof CreateLinodeInterfaceSchema +>; + +export type ModifyLinodeInterfacePayload = InferType< + typeof ModifyLinodeInterfaceSchema +>; + +// GET related types + +// GET object +export interface LinodeInterface { + id: number; + mac_address: string; + default_route: DefaultRoute; + version: number; + created: string; + updated: string; + vpc: VPCInterfaceData | null; + public: PublicInterfaceData | null; + vlan: { + vlan_label: string; + ipam_address: string; + } | null; +} + +export interface LinodeInterfaces { + interfaces: LinodeInterface[]; +} + +export interface VPCInterfaceData { + vpc_id: number; + subnet_id: number; + ipv4: { + addresses: { + address: string; + primary: boolean; + nat_1_1_address?: string; + }[]; + ranges: { range: string }[]; + }; +} + +export interface PublicInterfaceData { + ipv4: { + addresses: { + address: string; + primary: boolean; + }[]; + // shared: string[]; + }; + ipv6: { + addresses: { + address: string; + prefix: string; + }[]; + // shared: string[]; + ranges: { + range: string; + route_target: string; + }[]; + }; +} + +// Other Linode Interface types +export type LinodeInterfaceStatus = 'active' | 'inactive' | 'deleted'; + +export interface LinodeInterfaceHistory { + interface_history_id: number; + interface_id: number; + linode_id: number; + event_id: number; + version: number; + interface_data: string; // will come in as JSON string object that we'll need to parse + status: LinodeInterfaceStatus; + created: string; +} + +export interface LinodeInterfaceSettings { + network_helper: boolean; + default_route: { + ipv4_interface_id?: number | null; + ipv4_eligible_interface_ids: number[]; + ipv6_interface_id?: number | null; + ipv6_eligible_interface_ids: number[]; + }; } +export type LinodeInterfaceSettingsPayload = InferType< + typeof UpdateLinodeInterfaceSettingsSchema +>; + +export type UpgradeInterfacePayload = InferType< + typeof UpgradeToLinodeInterfaceSchema +>; + +export interface UpgradeInterfaceData { + config_id: number; + dry_run: boolean; + interfaces: LinodeInterface[]; +} + +// ---------------------------------------------------------- + export interface DiskDevice { disk_id: null | number; } @@ -423,7 +550,9 @@ export interface CreateLinodeRequest { stackscript_data?: any; /** * If it is deployed from an Image or a Backup and you wish it to remain offline after deployment, set this to false. + * * @default true if the Linode is created with an Image or from a Backup. + * @default false if using new Linode Interfaces and no interfaces are defined */ booted?: boolean; /** @@ -451,7 +580,29 @@ export interface CreateLinodeRequest { /** * An array of Network Interfaces to add to this Linode’s Configuration Profile. */ - interfaces?: InterfacePayload[]; + interfaces?: InterfacePayload[] | CreateLinodeInterfacePayload[]; + /** + * When present, used by the API to determine what type of interface objects (legacy + * config interfaces or new Linode Interfaces) are in the above interfaces field. + * Can either be 'legacy_config' or 'linode'. + * + * If 'legacy_config', interfaces field must be type InterfacePayload[] + * If 'linode', interfaces field must be type CreateLinodeInterfacePayload[] and Linode + * must be created in a region that supports the new interfaces. + * + * Default value on depends on interfaces_for_new_linodes field in AccountSettings object. + */ + interface_generation?: InterfaceGenerationType; + /** + * Default value mirrors network_helper in AccountSettings object. Should only be + * present when using Linode Interfaces. + */ + network_helper?: boolean; + /** + * An array of IPv4 addresses for this Linode + * Must be empty if Linode is configured to use new Linode Interfaces. + */ + ipv4?: string[]; /** * An object containing user-defined data relevant to the creation of Linodes. */ diff --git a/packages/api-v4/src/networking/types.ts b/packages/api-v4/src/networking/types.ts index 7a0d9d5d18b..9508ff35f88 100644 --- a/packages/api-v4/src/networking/types.ts +++ b/packages/api-v4/src/networking/types.ts @@ -7,7 +7,13 @@ export interface IPAddress { public: boolean; rdns: string | null; linode_id: number; + interface_id: number | null; region: string; + vpc_nat_1_1?: { + address: string; + subnet_id: number; + vpc_id: number; + } | null; } export interface IPRangeBaseData { diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts index ecb9b8057d1..dfdd5e8eed1 100644 --- a/packages/api-v4/src/quotas/quotas.ts +++ b/packages/api-v4/src/quotas/quotas.ts @@ -1,7 +1,7 @@ import { Filter, Params, ResourcePage as Page } from 'src/types'; import { API_ROOT } from '../constants'; import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; -import { Quota, QuotaType } from './types'; +import { Quota, QuotaType, QuotaUsage } from './types'; /** * getQuota @@ -34,3 +34,17 @@ export const getQuotas = ( setXFilter(filter), setParams(params) ); + +/** + * getQuotaUsage + * + * Returns the usage for a single quota within a particular service specified by `type`. + * + * @param type { QuotaType } retrieve a quota within this service type. + * @param id { number } the quota ID to look up. + */ +export const getQuotaUsage = (type: QuotaType, id: number) => + Request( + setURL(`${API_ROOT}/${type}/quotas/${id}/usage`), + setMethod('GET') + ); diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts index 23c42f00165..cc7fb35c9c5 100644 --- a/packages/api-v4/src/quotas/types.ts +++ b/packages/api-v4/src/quotas/types.ts @@ -27,12 +27,6 @@ export interface Quota { */ quota_limit: number; - /** - * Current account usage, measured in units specified by the - * `resource_metric` field. - */ - used: number; - /** * The unit of measurement for this service limit. */ @@ -49,8 +43,10 @@ export interface Quota { /** * The region slug to which this limit applies. + * + * OBJ limits are applied by endpoint, not region. */ - region_applied: Region['id'] | 'global'; + region_applied?: Region['id'] | 'global'; /** * The OBJ endpoint type to which this limit applies. @@ -67,4 +63,22 @@ export interface Quota { s3_endpoint?: string; } +/** + * A usage limit for a given Quota based on service metrics such + * as vCPUs, instances or storage size. + */ +export interface QuotaUsage { + /** + * The account-wide limit for this service, measured in units + * specified by the `resource_metric` field. + */ + quota_limit: number; + + /** + * The current account usage, measured in units specified by the + * `resource_metric` field. + */ + used: number; +} + export type QuotaType = 'linode' | 'lke' | 'object-storage'; diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index c477d95f96a..9cbb01bc893 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -9,6 +9,7 @@ export type Capabilities = | 'Cloud Firewall' | 'Disk Encryption' | 'Distributed Plans' + | 'Enhanced Interfaces' | 'GPU Linodes' | 'Kubernetes' | 'Kubernetes Enterprise' diff --git a/packages/api-v4/src/service-transfers/index.ts b/packages/api-v4/src/service-transfers/index.ts new file mode 100644 index 00000000000..71eab4754c8 --- /dev/null +++ b/packages/api-v4/src/service-transfers/index.ts @@ -0,0 +1 @@ +export * from './service-transfers'; diff --git a/packages/api-v4/src/service-transfers/service-transfers.ts b/packages/api-v4/src/service-transfers/service-transfers.ts new file mode 100644 index 00000000000..2a83edcc725 --- /dev/null +++ b/packages/api-v4/src/service-transfers/service-transfers.ts @@ -0,0 +1,81 @@ +import { CreateTransferSchema } from '@linode/validation'; +import { API_ROOT } from '../constants'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; +import { Filter, Params, ResourcePage as Page } from '../types'; +import { + CreateTransferPayload, + EntityTransfer, +} from '../entity-transfers/types'; + +/** + * getServiceTransfers + * + * Returns a paginated list of all Service Transfers which this customer has created or accepted. + */ +export const getServiceTransfers = (params?: Params, filter?: Filter) => + Request>( + setMethod('GET'), + setParams(params), + setXFilter(filter), + setURL(`${API_ROOT}/account/service-transfers`) + ); + +/** + * getServiceTransfer + * + * Get a single Service Transfer by its token (uuid). A Pending transfer + * can be retrieved by any unrestricted user. + * + */ +export const getServiceTransfer = (token: string) => + Request( + setMethod('GET'), + setURL(`${API_ROOT}/account/service-transfers/${encodeURIComponent(token)}`) + ); + +/** + * createServiceTransfer + * + * Creates a pending Service Transfer for one or more entities on + * the sender's account. Only unrestricted users can create a transfer. + */ +export const createServiceTransfer = (data: CreateTransferPayload) => + Request( + setMethod('POST'), + setData(data, CreateTransferSchema), + setURL(`${API_ROOT}/account/service-transfers`) + ); + +/** + * acceptServiceTransfer + * + * Accepts a transfer that has been created by a user on a different account. + */ +export const acceptServiceTransfer = (token: string) => + Request<{}>( + setMethod('POST'), + setURL( + `${API_ROOT}/account/service-transfers/${encodeURIComponent( + token + )}/accept` + ) + ); + +/** + * cancelServiceTransfer + * + * Cancels a pending transfer. Only unrestricted users on the account + * that created the transfer are able to cancel it. + * + */ +export const cancelServiceTransfer = (token: string) => + Request<{}>( + setMethod('DELETE'), + setURL(`${API_ROOT}/account/service-transfers/${encodeURIComponent(token)}`) + ); diff --git a/packages/api-v4/src/vpcs/types.ts b/packages/api-v4/src/vpcs/types.ts index 5df0c4d1d88..cf15d1112dd 100644 --- a/packages/api-v4/src/vpcs/types.ts +++ b/packages/api-v4/src/vpcs/types.ts @@ -1,5 +1,3 @@ -import { Interface } from 'src/linodes'; - export interface VPC { id: number; label: string; @@ -39,9 +37,29 @@ export interface ModifySubnetPayload { label: string; } -export type SubnetLinodeInterfaceData = Pick; +export interface SubnetLinodeInterfaceData { + id: number; + active: boolean; + config_id: number | null; +} export interface SubnetAssignedLinodeData { id: number; interfaces: SubnetLinodeInterfaceData[]; } + +export interface VPCIP { + active: boolean; + address: string | null; + address_range: string | null; + config_id: number | null; + gateway: string | null; + interface_id: number; + linode_id: number; + nat_1_1: string; + prefix: number | null; + region: string; + subnet_id: number; + subnet_mask: string; + vpc_id: number; +} diff --git a/packages/api-v4/src/vpcs/vpcs.ts b/packages/api-v4/src/vpcs/vpcs.ts index 2fefb1471f7..30254700f6e 100644 --- a/packages/api-v4/src/vpcs/vpcs.ts +++ b/packages/api-v4/src/vpcs/vpcs.ts @@ -20,6 +20,7 @@ import { Subnet, UpdateVPCPayload, VPC, + VPCIP, } from './types'; // VPC methods @@ -167,3 +168,29 @@ export const deleteSubnet = (vpcID: number, subnetID: number) => ), setMethod('DELETE') ); + +/** + * getVPCsIPs + * + * Get a paginated list of all VPC IP addresses and address ranges + */ +export const getVPCsIPs = (params?: Params, filter?: Filter) => + Request>( + setURL(`${API_ROOT}/vpcs/ips`), + setMethod('GET'), + setParams(params), + setXFilter(filter) + ); + +/** + * getVPCIPs + * + * Get a paginated list of VPC IP addresses for the specified VPC + */ +export const getVPCIPs = (vpcID: number, params?: Params, filter?: Filter) => + Request>( + setURL(`${API_ROOT}/vpcs/${encodeURIComponent(vpcID)}/ips`), + setMethod('GET'), + setParams(params), + setXFilter(filter) + ); diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 291f9ec2e25..b152f6b6877 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -91,7 +91,9 @@ module.exports = { // for each new features added to the migration router, add its directory here 'src/features/Betas/**/*', 'src/features/Domains/**/*', + 'src/features/Images/**/*', 'src/features/Longview/**/*', + 'src/features/PlacementGroups/**/*', 'src/features/Volumes/**/*', ], rules: { @@ -330,7 +332,6 @@ module.exports = { 'scanjs-rules/call_addEventListener': 'warn', 'scanjs-rules/call_parseFromString': 'error', 'scanjs-rules/new_Function': 'error', - 'scanjs-rules/property_crypto': 'error', 'scanjs-rules/property_geolocation': 'error', // sonar 'sonarjs/cognitive-complexity': 'off', diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 26b9b97e057..986215049ef 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,85 @@ 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-02-11] - v1.136.0 + + +### Added: + +- Labels and Taints to LKE Node Pools ([#11528](https://github.com/linode/manager/pull/11528), [#11553](https://github.com/linode/manager/pull/11553)) +- Firewall assignment on Linode and NodeBalancer detail pages ([#11567](https://github.com/linode/manager/pull/11567)) +- LKE cluster label and id on associated Linode's details page ([#11568](https://github.com/linode/manager/pull/11568)) +- Visual indication for unencrypted images ([#11579](https://github.com/linode/manager/pull/11579)) +- Collapsible Node Pool tables & filterable status ([#11589](https://github.com/linode/manager/pull/11589)) +- Database status display and event notifications for database migration ([#11590](https://github.com/linode/manager/pull/11590)) +- Database migration info banner ([#11595](https://github.com/linode/manager/pull/11595)) + +### Changed: + +- Refactor StackScripts landing page ([#11215](https://github.com/linode/manager/pull/11215)) +- Improve StackScript create and edit forms ([#11532](https://github.com/linode/manager/pull/11532)) +- Don't allow "HTTP Cookie" session stickiness when NodeBalancer config protocol is TCP ([#11534](https://github.com/linode/manager/pull/11534)) +- Make the `RegionMultiSelect` in the "Manage Image Regions" drawer ignore account capabilities ([#11598](https://github.com/linode/manager/pull/11598)) +- Improve region filter loading state in Linodes Landing ([#11550](https://github.com/linode/manager/pull/11550)) + +### Fixed: + +- Buggy Copy Token behavior on LKE details page ([#11592](https://github.com/linode/manager/pull/11592)) +- Longview Detail id param not found (local only) ([#11599](https://github.com/linode/manager/pull/11599)) + +### Tech Stories: + +- Refactor routing for Placement Groups to use Tanstack Router ([#11474](https://github.com/linode/manager/pull/11474)) +- Replace ramda's `pathOr` with custom utility ([#11512](https://github.com/linode/manager/pull/11512)) +- Refactor StackScript Create, Edit, and Details pages ([#11532](https://github.com/linode/manager/pull/11532)) +- Upgrade Vite to v6 ([#11548](https://github.com/linode/manager/pull/11548)) +- Upgrade Vitest to v3 ([#11548](https://github.com/linode/manager/pull/11548)) +- Enable Pendo based on OneTrust cookie consent ([#11564](https://github.com/linode/manager/pull/11564)) +- TanStack Router Migration for Images Feature ([#11578](https://github.com/linode/manager/pull/11578)) +- Removed `imageServiceGen2` and `imageServiceGen2Ga` feature flags ([#11579](https://github.com/linode/manager/pull/11579)) +- Add Feature Flag for Linode Interfaces project ([#11584](https://github.com/linode/manager/pull/11584)) +- Add MSW crud operations for Firewalls and `Get` operations for IP addresses ([#11586](https://github.com/linode/manager/pull/11586)) +- Remove ramda from `DomainRecords` pt2 ([#11587](https://github.com/linode/manager/pull/11587)) +- Remove ramda from `Managed` ([#11593](https://github.com/linode/manager/pull/11593)) +- Remove `disallowImageUploadToNonObjRegions` feature flag ([#11598](https://github.com/linode/manager/pull/11598)) +- Add `ignoreAccountAvailability` prop to `RegionMultiSelect` ([#11598](https://github.com/linode/manager/pull/11598)) +- Update `markdown-it` to v14 ([#11602](https://github.com/linode/manager/pull/11602)) +- Remove `@types/react-beautiful-dnd` dependency ([#11603](https://github.com/linode/manager/pull/11603)) +- Upgrade to Vitest 3.0.5 ([#11612](https://github.com/linode/manager/pull/11612)) +- Refactor `DomainRecordDrawer` to a functional component and use `react-hook-form` ([#11538](https://github.com/linode/manager/pull/11538)) +- Add E2E test coverage for creating linode in a distributed region ([#11572](https://github.com/linode/manager/pull/11572)) + +### Tests: + +- Add Cypress test to check Linode clone with null type ([#11473](https://github.com/linode/manager/pull/11473)) +- Add a test for alerts show details page automation ([#11525](https://github.com/linode/manager/pull/11525)) +- Add test coverage for viewing and deleting Node Pool Labels and Taints ([#11528](https://github.com/linode/manager/pull/11528)) +- Warning notice for unavailable region buckets ([#11530](https://github.com/linode/manager/pull/11530)) +- Add Cypress tests for object storage creation form for restricted user ([#11560](https://github.com/linode/manager/pull/11560)) +- Stop using `--headless=old` Chrome flag to run headless Cypress tests ([#11561](https://github.com/linode/manager/pull/11561)) +- Fix `resize-linode.spec.ts` test failure caused by updated API notification message ([#11561](https://github.com/linode/manager/pull/11561)) +- Add tests for firewall assignment on Linode and NodeBalancer detail pages ([#11567](https://github.com/linode/manager/pull/11567)) +- Add tests for downloading and viewing Kubeconfig file ([#11571](https://github.com/linode/manager/pull/11571)) +- Add Cypress test for Service Transfers empty state ([#11585](https://github.com/linode/manager/pull/11585)) + +### Upcoming Features: + +- Modify Cloud Manager to use OAuth PKCE ([#10600](https://github.com/linode/manager/pull/10600)) +- Add new permissions component for IAM ([#11423](https://github.com/linode/manager/pull/11423)) +- Add event messages for new `interface_create`, `interface_delete`, and `interface_update` events ([#11527](https://github.com/linode/manager/pull/11527)) +- Add new table component for assigned roles in IAM ([#11533](https://github.com/linode/manager/pull/11533)) +- Add support for NodeBalancer UDP Health Check Port ([#11534](https://github.com/linode/manager/pull/11534)) +- Add filtering, pagination and sorting for resources section in CloudPulse alerts show details page ([#11541](https://github.com/linode/manager/pull/11541)) +- Revised validation error messages and tooltip texts for Create Alert form ([#11543](https://github.com/linode/manager/pull/11543)) +- Add placeholder Quotas tab in Accounts page ([#11551](https://github.com/linode/manager/pull/11551)) +- Add new Notification Channel listing section in CloudPulse Alert details page ([#11554](https://github.com/linode/manager/pull/11554)) +- Fix type errors that result from changes to `/v4/networking` endpoints ([#11559](https://github.com/linode/manager/pull/11559)) +- Add billing agreement checkbox to non-US countries for tax id purposes ([#11563](https://github.com/linode/manager/pull/11563)) +- Alerts Listing features: Pagination, Ordering, Searching, Filtering ([#11577](https://github.com/linode/manager/pull/11577)) +- Add scaffolding for new edit resource component for system alerts in CloudPulse Alerts section ([#11583](https://github.com/linode/manager/pull/11583)) +- Add support for quotas usage endpoint ([#11597](https://github.com/linode/manager/pull/11597)) +- Add AddChannelListing and RenderChannelDetails for CloudPulse ([#11547](https://github.com/linode/manager/pull/11547)) + ## [2025-01-28] - v1.135.0 ### Added: diff --git a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx index f686299fcde..5cedacb9062 100644 --- a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx +++ b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx @@ -418,9 +418,11 @@ componentTests('Firewall Rules Table', (mount) => { mount( { mount( { mount( { mount( { cy.url().should('endWith', serviceTransferLandingUrl); }); + /* + * - Confirms the Service Transfers empty state when no service transfers exist on the account. + */ + it('can display empty state when no service transfer exists', () => { + // Mock empty array for all three service transfers. + const pendingTransfers: EntityTransfer[] = []; + const receivedTransfers: EntityTransfer[] = []; + const sentTransfers: EntityTransfer[] = []; + + mockGetEntityTransfers( + pendingTransfers, + receivedTransfers, + sentTransfers + ).as('getTransfers'); + + cy.visitWithLogin(serviceTransferLandingUrl); + + // Wait for 3 requests to transfers endpoint -- each section loads transfers separately. + cy.wait(['@getTransfers', '@getTransfers', '@getTransfers']); + + // Confirm that the "Pending Service Transfers" panel does not exist. + cy.get('[data-qa-panel="Pending Service Transfers"]').should('not.exist'); + + // Confirm that text "No data to display" is in "Received Service Transfers" panel. + cy.get('[data-qa-panel="Received Service Transfers"]') + .should('be.visible') + .within(() => { + cy.get('[data-testid="KeyboardArrowDownIcon"]').click(); + cy.findByText(serviceTransferEmptyState, { exact: false }).should( + 'be.visible' + ); + }); + + // Confirm that text "No data to display" is in "Sent Service Transfers" panel. + cy.get('[data-qa-panel="Sent Service Transfers"]') + .should('be.visible') + .within(() => { + cy.get('[data-testid="KeyboardArrowDownIcon"]').click(); + cy.findByText(serviceTransferEmptyState, { exact: false }).should( + 'be.visible' + ); + }); + }); + /* * - Confirms that pending, received, and sent transfers are shown on landing page. */ diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index 5c3feba46dd..5828c653892 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -1,10 +1,18 @@ -import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; +import { + mockGetAccount, + mockUpdateAccount, + mockUpdateAccountAgreements, +} from 'support/intercepts/account'; import { accountFactory } from 'src/factories/account'; import type { Account } from '@linode/api-v4'; import { ui } from 'support/ui'; -import { TAX_ID_HELPER_TEXT } from 'src/features/Billing/constants'; +import { + TAX_ID_AGREEMENT_TEXT, + TAX_ID_HELPER_TEXT, +} from 'src/features/Billing/constants'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { accountAgreementsFactory } from 'src/factories'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ @@ -49,6 +57,10 @@ const newAccountData = accountFactory.build({ zip: '19108', }); +const newAccountAgreement = accountAgreementsFactory.build({ + billing_agreement: true, +}); + const checkAccountContactDisplay = (accountInfo: Account) => { cy.findByText('Billing Contact').should('be.visible'); cy.findByText(accountInfo['company']).should('be.visible'); @@ -158,11 +170,6 @@ describe('Billing Contact', () => { .click() .clear() .type(newAccountData['phone']); - cy.get('[data-qa-contact-country]').click().type('Afghanistan{enter}'); - cy.findByText(TAX_ID_HELPER_TEXT).should('be.visible'); - cy.get('[data-qa-contact-country]') - .click() - .type('United States{enter}'); cy.get('[data-qa-contact-state-province]') .should('be.visible') .click() @@ -187,4 +194,68 @@ describe('Billing Contact', () => { checkAccountContactDisplay(newAccountData); }); }); + + it('Edit Contact Info: Tax ID Agreement', () => { + mockGetUserPreferences({ maskSensitiveData: false }).as( + 'getUserPreferences' + ); + // mock the user's account data and confirm that it is displayed correctly upon page load + mockGetAccount(accountData).as('getAccount'); + cy.visitWithLogin('/account/billing'); + + // edit the billing contact information + mockUpdateAccount(newAccountData).as('updateAccount'); + mockUpdateAccountAgreements(newAccountAgreement).as( + 'updateAccountAgreements' + ); + cy.get('[data-qa-contact-summary]').within((_contact) => { + checkAccountContactDisplay(accountData); + cy.findByText('Edit').should('be.visible').click(); + }); + + ui.drawer + .findByTitle('Edit Billing Contact Info') + .should('be.visible') + .within(() => { + cy.findByLabelText('City') + .should('be.visible') + .click() + .clear() + .type(newAccountData['city']); + cy.findByLabelText('Postal Code') + .should('be.visible') + .click() + .clear() + .type(newAccountData['zip']); + cy.get('[data-qa-contact-country]').click().type('Afghanistan{enter}'); + cy.findByLabelText('Tax ID') + .should('be.visible') + .click() + .clear() + .type(newAccountData['tax_id']); + cy.findByText(TAX_ID_HELPER_TEXT).should('be.visible'); + cy.findByText(TAX_ID_AGREEMENT_TEXT) + .scrollIntoView() + .should('be.visible'); + cy.findByText('Akamai Privacy Statement.').should('be.visible'); + cy.get('[data-qa-save-contact-info="true"]').should('be.disabled'); + cy.get('[data-testid="tax-id-checkbox"]').click(); + cy.get('[data-qa-save-contact-info="true"]') + .should('be.enabled') + .click() + .then(() => { + cy.wait('@updateAccount').then((xhr) => { + expect(xhr.response?.body).to.eql(newAccountData); + }); + cy.wait('@updateAccountAgreements').then((xhr) => { + expect(xhr.response?.body).to.eql(newAccountAgreement); + }); + }); + }); + + // check the page updates to reflect the edits + cy.get('[data-qa-contact-summary]').within(() => { + checkAccountContactDisplay(newAccountData); + }); + }); }); 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 new file mode 100644 index 00000000000..ff6a37eeb12 --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts @@ -0,0 +1,239 @@ +/** + * @file Integration Tests for the CloudPulse DBaaS Alerts Show Detail Page. + * + * This file contains Cypress tests that validate the display and content of the DBaaS Alerts Show Detail Page in the CloudPulse application. + * It ensures that all alert details, criteria, and resource information are displayed correctly. + */ +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + accountFactory, + alertFactory, + alertRulesFactory, + regionFactory, +} from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; +import type { Flags } from 'src/featureFlags'; + +import { + mockGetAlertDefinitions, + mockGetAllAlertDefinitions, +} from 'support/intercepts/cloudpulse'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { formatDate } from 'src/utilities/formatDate'; +import { + metricOperatorTypeMap, + dimensionOperatorTypeMap, + severityMap, + aggregationTypeMap, +} from 'support/constants/alert'; +import { ui } from 'support/ui'; + +const flags: Partial = { aclp: { enabled: true, beta: true } }; +const mockAccount = accountFactory.build(); +const alertDetails = alertFactory.build({ + service_type: 'dbaas', + severity: 1, + status: 'enabled', + type: 'system', + entity_ids: ['1', '2'], + rule_criteria: { rules: alertRulesFactory.buildList(2) }, +}); +const { + service_type, + severity, + rule_criteria, + id, + label, + description, + created_by, + updated, +} = alertDetails; +const { rules } = rule_criteria; +const regions = [ + regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-ord', + label: 'Chicago, IL', + }), + regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-east', + label: 'US, Newark', + }), +]; + +/** + * Integration tests for the CloudPulse DBaaS Alerts Detail Page, ensuring that the alert details, criteria, and resource information are correctly displayed and validated, including various fields like name, description, status, severity, and trigger conditions. + */ + +describe('Integration Tests for Dbaas Alert Show Detail Page', () => { + beforeEach(() => { + mockAppendFeatureFlags(flags); + mockGetAccount(mockAccount); + mockGetRegions(regions); + mockGetAllAlertDefinitions([alertDetails]).as('getAlertDefinitionsList'); + mockGetAlertDefinitions(service_type, id, alertDetails).as( + 'getDBaaSAlertDefinitions' + ); + }); + + it('navigates to the Show Details page from the list page', () => { + // Navigate to the alert definitions list page with login + cy.visitWithLogin('/monitor/alerts/definitions'); + + // Wait for the alert definitions list API call to complete + cy.wait('@getAlertDefinitionsList'); + + // Locate the alert with the specified label in the table + cy.findByText(label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for Alert ${label}`) + .should('be.visible') + .click(); + }); + + // Select the "Show Details" option from the action menu + ui.actionMenuItem.findByTitle('Show Details').should('be.visible').click(); + + // Verify the URL ends with the expected details page path + cy.url().should('endWith', `/detail/${service_type}/${id}`); + }); + + it('should correctly display the details of the DBaaS alert in the alert details view', () => { + cy.visitWithLogin( + `/monitor/alerts/definitions/detail/${service_type}/${id}` + ); + + // 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.get('[data-qa-item="Severity"]').within(() => { + 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('System').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', + }) + ).should('be.visible'); + }); + + // 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.aggregation_type]}"]` + ) + .should('be.visible') + .should('have.text', aggregationTypeMap[rule.aggregation_type]); + + 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); + }); + + // Validating contents of Dimension Filter + cy.get('[data-qa-item="Dimension Filter"]') + .eq(index) + .within(() => { + (rule.dimension_filters ?? []).forEach((filter, filterIndex) => { + // 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="${filter.value}"]`) + .should('be.visible') + .each(($chip) => { + expect($chip).to.have.text(filter.value); + }); + }); + }); + }); + + // Validating contents of Polling Interval + cy.get('[data-qa-item="Polling Interval"]') + .find('[data-qa-chip]') + .should('be.visible') + .should('have.text', '2 minutes'); + + // Validating contents of Evaluation Periods + cy.get('[data-qa-item="Evaluation Period"]') + .find('[data-qa-chip]') + .should('be.visible') + .should('have.text', '4 minutes'); + + // Validating contents of Trigger Alert + cy.get('[data-qa-chip="All"]') + .should('be.visible') + .should('have.text', 'All'); + + cy.get('[data-qa-chip="4 minutes"]') + .should('be.visible') + .should('have.text', '4 minutes'); + + cy.get('[data-qa-item="criteria are met for"]') + .should('be.visible') + .should('have.text', 'criteria are met for'); + + cy.get('[data-qa-item="consecutive occurrences"]') + .should('be.visible') + .should('have.text', 'consecutive occurrences.'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts index 3cf4fa4f700..f83b057626e 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts @@ -68,4 +68,40 @@ describe('CloudPulse navigation', () => { cy.findByText('Not Found').should('be.visible'); }); + + /* + * - Confirms that manual navigation to the 'Alert' page on the Cloudpulse landing page is disabled, and users are shown a 'Not Found' message.. + */ + it('should display "Not Found" when navigating to alert definitions with feature flag disabled', () => { + mockAppendFeatureFlags({ + aclp: { beta: true, enabled: false }, + }).as('getFeatureFlags'); + + // Attempt to visit the alert definitions page for a specific alert using a manual URL + cy.visitWithLogin('monitor/alerts/definitions'); + + // Wait for the feature flag to be fetched and applied + cy.wait('@getFeatureFlags'); + + // Assert that the 'Not Found' message is displayed, indicating the user cannot access the page + cy.findByText('Not Found').should('be.visible'); + }); + + /* + * - Confirms that manual navigation to the 'Alert Definitions Detail' page on the Cloudpulse landing page is disabled, and users are shown a 'Not Found' message.. + */ + it('should display "Not Found" when manually navigating to alert details with feature flag disabled', () => { + mockAppendFeatureFlags({ + aclp: { beta: true, enabled: false }, + }).as('getFeatureFlags'); + + // Attempt to visit the alert detail page for a specific alert using a manual URL + cy.visitWithLogin('monitor/alerts/definitions/detail/dbaas/20000'); + + // Wait for the feature flag to be fetched and applied + cy.wait('@getFeatureFlags'); + + // Assert that the 'Not Found' message is displayed, indicating the user cannot access the page + cy.findByText('Not Found').should('be.visible'); + }); }); diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index 282a1bf48f5..d3c7b34d712 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -45,6 +45,7 @@ describe('GDPR agreement', () => { mockGetAccountAgreements({ privacy_policy: false, eu_model: false, + billing_agreement: false, }).as('getAgreements'); cy.visitWithLogin('/linodes/create'); @@ -73,6 +74,7 @@ describe('GDPR agreement', () => { mockGetAccountAgreements({ privacy_policy: false, eu_model: true, + billing_agreement: false, }).as('getAgreements'); cy.visitWithLogin('/linodes/create'); @@ -101,6 +103,7 @@ describe('GDPR agreement', () => { mockGetAccountAgreements({ privacy_policy: false, eu_model: false, + billing_agreement: false, }).as('getAgreements'); const rootpass = randomString(32); const linodeLabel = randomLabel(); diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 582af41ebc1..8f8e2ecbf55 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -9,6 +9,7 @@ import { mockDeleteImage, mockGetCustomImages, mockUpdateImage, + mockGetImage, } from 'support/intercepts/images'; import { ui } from 'support/ui'; import { interceptOnce } from 'support/ui/common'; @@ -170,6 +171,7 @@ describe('machine image', () => { }; mockGetCustomImages([mockImage]).as('getImages'); + mockGetImage(mockImage.id, mockImage).as('getImage'); cy.visitWithLogin('/images'); cy.wait('@getImages'); @@ -184,6 +186,7 @@ describe('machine image', () => { }); ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + cy.wait('@getImage'); mockUpdateImage(mockImage.id, mockImageUpdated).as('updateImage'); mockGetCustomImages([mockImageUpdated]).as('getImages'); diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index fd2c8cd8787..72d984d6c4b 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -3,6 +3,7 @@ import { mockGetCustomImages, mockGetRecoveryImages, mockUpdateImageRegions, + mockGetImage, } from 'support/intercepts/images'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; @@ -37,6 +38,7 @@ describe('Manage Image Replicas', () => { mockGetRegions([region1, region2, region3, region4]).as('getRegions'); mockGetCustomImages([image]).as('getImages'); mockGetRecoveryImages([]); + mockGetImage(image.id, image).as('getImage'); cy.visitWithLogin('/images'); cy.wait(['@getImages', '@getRegions']); @@ -54,6 +56,8 @@ describe('Manage Image Replicas', () => { .click(); }); + cy.wait('@getImage'); + // Verify the Manage Replicas drawer opens and contains basic content ui.drawer .findByTitle(`Manage Replicas for ${image.label}`) diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts index 69a637ff321..a9cd4d5ceaa 100644 --- a/packages/manager/cypress/e2e/core/images/search-images.spec.ts +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -68,7 +68,7 @@ describe('Search Images', () => { cy.contains(image2.label).should('be.visible'); // Use the main search bar to search and filter images - cy.get('[id="main-search"').type(image2.label); + ui.mainSearch.find().type(image2.label); ui.autocompletePopper.findByTitle(image2.label).click(); // Confirm that only the second image is shown. diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts new file mode 100644 index 00000000000..6c1908bee79 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts @@ -0,0 +1,52 @@ +import { mockGetCluster, mockGetKubeconfig } from 'support/intercepts/lke'; +import { kubernetesClusterFactory } from 'src/factories'; +import { readDownload } from 'support/util/downloads'; +import { ui } from 'support/ui'; + +const mockKubeconfigContents = '---'; // Valid YAML. +const mockKubeconfigResponse = { + kubeconfig: btoa(mockKubeconfigContents), +}; + +const mockCluster = kubernetesClusterFactory.build(); +const url = `/kubernetes/clusters/${mockCluster.id}`; + +describe('LKE summary page', () => { + beforeEach(() => { + mockGetCluster(mockCluster).as('getCluster'); + mockGetKubeconfig(mockCluster.id, mockKubeconfigResponse).as( + 'getKubeconfig' + ); + }); + + it('can download kubeconfig', () => { + const mockKubeconfigFilename = `${mockCluster.label}-kubeconfig.yaml`; + cy.visitWithLogin(url); + cy.wait(['@getCluster']); + cy.findByText(mockKubeconfigFilename) + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait('@getKubeconfig'); + readDownload(mockKubeconfigFilename).should('eq', mockKubeconfigContents); + }); + + it('can view kubeconfig contents', () => { + cy.visitWithLogin(url); + cy.wait(['@getCluster']); + // open drawer + cy.get('p:contains("View")') + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait('@getKubeconfig'); + ui.drawer.findByTitle('View Kubeconfig').should('be.visible'); + cy.get('code') + .should('be.visible') + .within(() => { + cy.get('span').contains(mockKubeconfigContents); + }); + }); + + // TODO: add test for failure to download yaml file +}); 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 788f19e2d31..984ca1e9083 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -31,13 +31,14 @@ import { mockGetControlPlaneACLError, mockGetTieredKubernetesVersions, mockUpdateClusterError, + mockUpdateNodePoolError, } from 'support/intercepts/lke'; import { mockGetLinodeType, mockGetLinodeTypes, mockGetLinodes, } from 'support/intercepts/linodes'; -import type { PoolNodeResponse, Linode } from '@linode/api-v4'; +import type { PoolNodeResponse, Linode, Taint, Label } from '@linode/api-v4'; import { ui } from 'support/ui'; import { randomIp, randomLabel } from 'support/util/random'; import { getRegionById } from 'support/util/regions'; @@ -952,11 +953,13 @@ describe('LKE cluster updates', () => { // LKE clusters can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); - cy.findByTestId('textfield-input') - .should('be.visible') - .should('have.value', mockCluster.label) - .clear() - .type(`${mockNewCluster.label}{enter}`); + cy.get('[data-qa-edit-field]').within(() => { + cy.findByTestId('textfield-input') + .should('be.visible') + .should('have.value', mockCluster.label) + .clear() + .type(`${mockNewCluster.label}{enter}`); + }); cy.wait('@updateCluster'); @@ -988,11 +991,13 @@ describe('LKE cluster updates', () => { // LKE cluster can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); - cy.findByTestId('textfield-input') - .should('be.visible') - .should('have.value', mockCluster.label) - .clear() - .type(`${mockErrorCluster.label}{enter}`); + cy.get('[data-qa-edit-field]').within(() => { + cy.findByTestId('textfield-input') + .should('be.visible') + .should('have.value', mockCluster.label) + .clear() + .type(`${mockErrorCluster.label}{enter}`); + }); // Error message shows when API request fails. cy.wait('@updateClusterError'); @@ -1013,7 +1018,6 @@ describe('LKE cluster updates', () => { const mockNodes = mockNodePoolInstances.map((linode, i) => kubeLinodeFactory.build({ - id: `id-${i * 5000}`, instance_id: linode.id, status: 'ready', }) @@ -1105,6 +1109,694 @@ describe('LKE cluster updates', () => { ); }); + /* + * - Confirms Labels and Taints button exists for a node pool. + * - Confirms Labels and Taints drawer displays the expected Labels and Taints. + * - Confirms Labels and Taints can be deleted from a node pool. + * - Confirms that Labels and Taints can be added to a node pool. + * - Confirms validation and errors are handled gracefully. + */ + describe('confirms labels and taints functionality for a node pool', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + + const mockType = linodeTypeFactory.build({ label: 'Linode 2 GB' }); + + const mockNodePoolInstances = buildArray(1, () => + linodeFactory.build({ label: randomLabel() }) + ); + + const mockNodes = mockNodePoolInstances.map((linode, i) => + kubeLinodeFactory.build({ + instance_id: linode.id, + status: 'ready', + }) + ); + + const mockNodePoolInitial = nodePoolFactory.build({ + id: 1, + type: mockType.id, + nodes: mockNodes, + labels: { + ['example.com/my-app']: 'teams', + }, + taints: [ + { + effect: 'NoSchedule', + key: 'example.com/my-app', + value: 'teamA', + }, + ], + }); + + const mockDrawerTitle = 'Labels and Taints: Linode 2 GB Plan'; + + beforeEach(() => { + mockGetLinodes(mockNodePoolInstances); + mockGetLinodeType(mockType).as('getType'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( + 'getNodePools' + ); + mockGetKubernetesVersions().as('getVersions'); + mockGetControlPlaneACL(mockCluster.id, { acl: { enabled: false } }).as( + 'getControlPlaneAcl' + ); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + }); + + it('can delete labels and taints', () => { + const mockNodePoolUpdated = nodePoolFactory.build({ + id: 1, + type: mockType.id, + nodes: mockNodes, + taints: [], + labels: {}, + }); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getType', + '@getControlPlaneAcl', + ]); + + mockUpdateNodePool(mockCluster.id, mockNodePoolUpdated).as( + 'updateNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolUpdated]).as( + 'getNodePoolsUpdated' + ); + + // Click "Labels and Taints" button and confirm drawer contents. + ui.button + .findByTitle('Labels and Taints') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(mockDrawerTitle) + .should('be.visible') + .within(() => { + // Confirm drawer opens with the correct CTAs. + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); + + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled'); + + // Confirm that the Labels table exists and is populated with the correct details. + Object.entries(mockNodePoolInitial.labels).forEach(([key, value]) => { + cy.get(`tr[data-qa-label-row="${key}"]`) + .should('be.visible') + .within(() => { + cy.findByText(`${key}: ${value}`).should('be.visible'); + + // Confirm delete button exists, then click it. + ui.button + .findByAttribute('aria-label', `Remove ${key}: ${value}`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm the label is no longer visible. + cy.findByText(`${key}: ${value}`).should('not.exist'); + }); + }); + + // Confirm that the Taints table exists and is populated with the correct details. + mockNodePoolInitial.taints.forEach((taint: Taint) => { + cy.get(`tr[data-qa-taint-row="${taint.key}"]`) + .should('be.visible') + .within(() => { + cy.findByText(`${taint.key}: ${taint.value}`).should( + 'be.visible' + ); + cy.findByText(taint.effect).should('be.visible'); + + // Confirm delete button exists, then click it. + ui.button + .findByAttribute( + 'aria-label', + `Remove ${taint.key}: ${taint.value}` + ) + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm the taint is no longer visible. + cy.findByText(`${taint.key}: ${taint.value}`).should( + 'not.exist' + ); + }); + }); + + // Confirm empty state text displays for both empty tables. + cy.findByText('No labels').should('be.visible'); + cy.findByText('No taints').should('be.visible'); + + // Confirm form can be submitted. + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm request has the correct data. + cy.wait('@updateNodePool').then((xhr) => { + const data = xhr.response?.body; + if (data) { + const actualLabels: Label = data.labels; + const actualTaints: Taint[] = data.taints; + + expect(actualLabels).to.deep.equal(mockNodePoolUpdated.labels); + expect(actualTaints).to.deep.equal(mockNodePoolUpdated.taints); + } + }); + + cy.wait('@getNodePoolsUpdated'); + + // Confirm drawer closes. + cy.findByText(mockDrawerTitle).should('not.exist'); + }); + + it('can add labels and taints', () => { + const mockNewSimpleLabel = 'my-label-key: my-label-value'; + const mockNewDNSLabel = 'my-label-key.io/app: my-label-value'; + const mockNewTaint: Taint = { + key: 'my-taint-key', + value: 'my-taint-value', + effect: 'NoSchedule', + }; + const mockNewDNSTaint: Taint = { + key: 'my-taint-key.io/app', + value: 'my-taint-value', + effect: 'NoSchedule', + }; + const mockNodePoolUpdated = nodePoolFactory.build({ + id: 1, + type: mockType.id, + nodes: mockNodes, + taints: [mockNewTaint, mockNewDNSTaint], + labels: { + 'my-label-key': 'my-label-value', + 'my-label-key.io/app': 'my-label-value', + }, + }); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getType', + '@getControlPlaneAcl', + ]); + + mockUpdateNodePool(mockCluster.id, mockNodePoolUpdated).as( + 'updateNodePool' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolUpdated]).as( + 'getNodePoolsUpdated' + ); + + // Click "Labels and Taints" button and confirm drawer contents. + ui.button + .findByTitle('Labels and Taints') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(mockDrawerTitle) + .should('be.visible') + .within(() => { + // Confirm drawer opens with the correct CTAs. + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); + + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled'); + + // Add a label: + + ui.button + .findByTitle('Add Label') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm form button is disabled and label form displays with the correct CTAs. + ui.button + .findByTitle('Add Label') + .should('be.visible') + .should('be.disabled'); + + // Confirm labels with simple keys and DNS subdomain keys can be added. + [mockNewSimpleLabel, mockNewDNSLabel].forEach((newLabel, index) => { + // Confirm form adds a valid new label. + cy.findByLabelText('Label').click().type(newLabel); + + ui.button.findByTitle('Add').click(); + + // Confirm add form closes and Add Label button is re-enabled. + cy.findByLabelText('Label').should('not.exist'); + cy.findByLabelText('Add').should('not.exist'); + ui.button.findByTitle('Add Label').should('be.enabled'); + + // Confirm new label is visible in table. + cy.get(`tr[data-qa-label-row="${newLabel.split(':')[0]}"]`) + .should('be.visible') + .within(() => { + cy.findByText(newLabel).should('be.visible'); + }); + + if (index === 0) { + ui.button.findByTitle('Add Label').click(); + } + }); + + // Add a taint: + + ui.button + .findByTitle('Add Taint') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm form button is disabled and label form displays with the correct CTAs. + ui.button.findByTitle('Add Taint').should('be.disabled'); + + // Confirm taints with simple keys and DNS subdomain keys can be added. + [mockNewTaint, mockNewDNSTaint].forEach((newTaint, index) => { + // Confirm form adds a valid new taint. + cy.findByLabelText('Taint') + .click() + .type(`${newTaint.key}: ${newTaint.value}`); + + ui.autocomplete.findByLabel('Effect').click(); + + ui.autocompletePopper + .findByTitle(newTaint.effect) + .should('be.visible') + .should('be.enabled') + .click(); + + ui.button.findByTitle('Add').click(); + + // Confirm add form closes and Add Taint button is re-enabled. + cy.findByLabelText('Taint').should('not.exist'); + cy.findByLabelText('Add').should('not.exist'); + ui.button.findByTitle('Add Taint').should('be.enabled'); + + // Confirm new taint is visible in table. + cy.get(`tr[data-qa-taint-row="${newTaint.key}"]`) + .should('be.visible') + .within(() => { + cy.findByText(`${newTaint.key}: ${newTaint.value}`).should( + 'be.visible' + ); + cy.findByText(newTaint.effect).should('be.visible'); + }); + + if (index === 0) { + ui.button.findByTitle('Add Taint').click(); + } + }); + + // Confirm form can be submitted. + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm request has the correct data. + cy.wait('@updateNodePool').then((xhr) => { + const data = xhr.response?.body; + if (data) { + const actualLabels: Label = data.labels; + const actualTaints: Taint[] = data.taints; + console.log({ actualTaints }, { actualLabels }); + + expect(actualLabels).to.deep.equal(mockNodePoolUpdated.labels); + expect(actualTaints).to.deep.equal(mockNodePoolUpdated.taints); + } + }); + + cy.wait('@getNodePoolsUpdated'); + + // Confirm drawer closes. + cy.findByText(mockDrawerTitle).should('not.exist'); + }); + + it('can handle validation and errors for labels and taints', () => { + const invalidDNSSubdomainLabel = `my-app/${randomString(129)}`; + const invalidLabels = [ + 'label with spaces', + 'key-and-no-value', + randomString(64), + invalidDNSSubdomainLabel, + 'valid-key: invalid value', + '%invalid-character: value', + 'example.com/myapp: %invalid-character', + 'kubernetes.io: value', + 'linode.com: value', + ]; + + const invalidTaintKeys = [ + randomString(254), + 'key with spaces', + '!invalid-characters', + ]; + const invalidTaintValues = [ + `key:${randomString(64)}`, + 'key: kubernetes.io', + 'key: linode.com', + 'key:value with spaces', + ]; + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getType', + '@getControlPlaneAcl', + ]); + const mockErrorMessage = 'API Error'; + + mockUpdateNodePoolError( + mockCluster.id, + mockNodePoolInitial, + mockErrorMessage + ).as('updateNodePoolError'); + + // Click "Labels and Taints" button and confirm drawer contents. + ui.button + .findByTitle('Labels and Taints') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(mockDrawerTitle) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Add Label').click(); + + // Try to submit without adding a label. + ui.button.findByTitle('Add').click(); + + // Confirm error validation for invalid label input. + cy.findByText('Labels must be valid key-value pairs.').should( + 'be.visible' + ); + + invalidLabels.forEach((invalidLabel) => { + cy.findByLabelText('Label').click().clear().type(invalidLabel); + + // Try to submit with invalid label. + ui.button.findByTitle('Add').click(); + + // Confirm error validation for invalid label input. + cy.findByText('Labels must be valid key-value pairs.').should( + 'be.visible' + ); + }); + + // Submit a valid label to enable the 'Save Changes' button. + cy.findByLabelText('Label') + .click() + .clear() + .type('mockKey: mockValue'); + + ui.button.findByTitle('Add').click(); + + ui.button.findByTitle('Add Taint').click(); + + // Try to submit without adding a taint. + ui.button.findByTitle('Add').click(); + + // Confirm error validation for invalid taint input. + cy.findByText('Key is required.').should('be.visible'); + + invalidTaintKeys.forEach((invalidTaintKey, index) => { + cy.findByLabelText('Taint').click().clear().type(invalidTaintKey); + + // Try to submit taint with invalid key. + ui.button.findByTitle('Add').click(); + + if (index === 0) { + cy.findByText('Key must be between 1 and 253 characters.').should( + 'be.visible' + ); + } else { + cy.findByText(/Key must start with a letter or number/).should( + 'be.visible' + ); + } + }); + + invalidTaintValues.forEach((invalidTaintValue, index) => { + cy.findByLabelText('Taint').click().clear().type(invalidTaintValue); + + // Try to submit taint with invalid value. + ui.button.findByTitle('Add').click(); + + if (index === 0) { + cy.findByText( + 'Value must be between 0 and 63 characters.' + ).should('be.visible'); + } else if (index === invalidTaintValues.length - 1) { + cy.findByText(/Value must start with a letter or number/).should( + 'be.visible' + ); + } else { + cy.findByText( + 'Value cannot be "kubernetes.io" or "linode.com".' + ).should('be.visible'); + } + }); + + ui.button.findByAttribute('data-testid', 'cancel-taint').click(); + + // Try to submit form, but mock an API error. + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm error message shows when API request fails. + cy.wait('@updateNodePoolError'); + cy.findAllByText(mockErrorMessage).should('be.visible'); + }); + }); + + it('does not collapse the accordion when an action button is clicked in the accordion header', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + const mockSingleNodePool = mockNodePools[0]; + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockSingleNodePool]).as( + 'getNodePools' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools']); + + cy.get(`[data-qa-node-pool-id="${mockSingleNodePool.id}"]`).within(() => { + // Accordion should be expanded by default + cy.get(`[data-qa-panel-summary]`).should( + 'have.attr', + 'aria-expanded', + 'true' + ); + + // Click on a disabled button + cy.get('[data-testid="node-pool-actions"]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Delete Pool') + .should('be.visible') + .should('be.disabled') + .click(); + }); + + // Check that the accordion is still expanded + cy.get(`[data-qa-panel-summary]`).should( + 'have.attr', + 'aria-expanded', + 'true' + ); + + // Click on an action button + cy.get('[data-testid="node-pool-actions"]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Autoscale Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + // Exit dialog + ui.dialog + .findByTitle('Autoscale Pool') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get(`[data-qa-node-pool-id="${mockSingleNodePool.id}"]`).within(() => { + // Check that the accordion is still expanded + cy.get(`[data-qa-panel-summary]`).should( + 'have.attr', + 'aria-expanded', + 'true' + ); + + // Accordion should close on non-action button clicks + cy.get('[data-qa-panel-subheading]').click(); + cy.get(`[data-qa-panel-summary]`).should( + 'have.attr', + 'aria-expanded', + 'false' + ); + }); + }); + + it('filters the node tables based on selected status filter', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + const mockNodePools = [ + nodePoolFactory.build({ + count: 4, + nodes: [ + ...kubeLinodeFactory.buildList(3), + kubeLinodeFactory.build({ status: 'not_ready' }), + ], + }), + nodePoolFactory.build({ + nodes: kubeLinodeFactory.buildList(2), + }), + ]; + const mockLinodes: Linode[] = [ + linodeFactory.build({ + id: mockNodePools[0].nodes[0].instance_id ?? undefined, + }), + linodeFactory.build({ + id: mockNodePools[0].nodes[1].instance_id ?? undefined, + }), + linodeFactory.build({ + id: mockNodePools[0].nodes[2].instance_id ?? undefined, + status: 'offline', + }), + linodeFactory.build({ + id: mockNodePools[0].nodes[3].instance_id ?? undefined, + status: 'provisioning', + }), + linodeFactory.build({ + id: mockNodePools[1].nodes[0].instance_id ?? undefined, + }), + linodeFactory.build({ + id: mockNodePools[1].nodes[1].instance_id ?? undefined, + status: 'offline', + }), + ]; + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockGetLinodes(mockLinodes).as('getLinodes'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePools', '@getLinodes']); + + // Filter is initially set to Show All nodes + cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 4); + }); + cy.get(`[data-qa-node-pool-id="${mockNodePools[1].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 2); + }); + + // Filter by Running status + ui.autocomplete.findByLabel('Status').click(); + ui.autocompletePopper.findByTitle('Running').should('be.visible').click(); + + // Only Running nodes should be displayed + cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 2); + }); + cy.get(`[data-qa-node-pool-id="${mockNodePools[1].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 1); + }); + + // Filter by Offline status + ui.autocomplete.findByLabel('Status').click(); + ui.autocompletePopper.findByTitle('Offline').should('be.visible').click(); + + // Only Offline nodes should be displayed + cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 1); + }); + cy.get(`[data-qa-node-pool-id="${mockNodePools[1].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 1); + }); + + // Filter by Provisioning status + ui.autocomplete.findByLabel('Status').click(); + ui.autocompletePopper + .findByTitle('Provisioning') + .should('be.visible') + .click(); + + // Only Provisioning nodes should be displayed + cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 1); + }); + cy.get(`[data-qa-node-pool-id="${mockNodePools[1].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 0); + }); + + // Filter by Show All status + ui.autocomplete.findByLabel('Status').click(); + ui.autocompletePopper.findByTitle('Show All').should('be.visible').click(); + + // All nodes are displayed + cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 4); + }); + cy.get(`[data-qa-node-pool-id="${mockNodePools[1].id}"]`).within(() => { + cy.get('[data-qa-node-row]').should('have.length', 2); + }); + }); + describe('LKE cluster updates for DC-specific prices', () => { /* * - Confirms node pool resize UI flow using mocked API responses. diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 0f698c50e20..e03dbca74c4 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -1,11 +1,23 @@ -import { linodeFactory, createLinodeRequestFactory } from '@src/factories'; +import { + createLinodeRequestFactory, + linodeConfigFactory, + LinodeConfigInterfaceFactory, + linodeFactory, + VLANFactory, + volumeFactory, +} from '@src/factories'; import { interceptCloneLinode, mockGetLinodeDetails, mockGetLinodes, mockGetLinodeType, mockGetLinodeTypes, + mockCreateLinode, + mockCloneLinode, + mockGetLinodeVolumes, } from 'support/intercepts/linodes'; +import { linodeCreatePage } from 'support/ui/pages'; +import { mockGetVLANs } from 'support/intercepts/vlans'; import { ui } from 'support/ui'; import { dcPricingMockLinodeTypes, @@ -14,7 +26,12 @@ import { dcPricingDocsUrl, } from 'support/constants/dc-specific-pricing'; import { chooseRegion, getRegionById } from 'support/util/regions'; -import { randomLabel } from 'support/util/random'; +import { + randomLabel, + randomNumber, + randomString, + randomIp, +} from 'support/util/random'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; @@ -23,6 +40,7 @@ import { LINODE_CREATE_TIMEOUT, } from 'support/constants/linodes'; import type { Linode } from '@linode/api-v4'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; /** * Returns the Cloud Manager URL to clone a given Linode. @@ -33,7 +51,7 @@ import type { Linode } from '@linode/api-v4'; */ const getLinodeCloneUrl = (linode: Linode): string => { const regionQuery = `®ionID=${linode.region}`; - const typeQuery = `&typeID=${linode.type}`; + const typeQuery = linode.type ? `&typeID=${linode.type}` : ''; return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; @@ -116,6 +134,164 @@ describe('clone linode', () => { }); }); + /* + * - Confirms Linode Clone flow can handle null type gracefully. + * - Confirms that Linode (mock) can be cloned successfully. + */ + it('can clone a Linode with null type', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + status: 'offline', + type: null, + }); + const mockVolume = volumeFactory.build(); + const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + ipam_address: null, + purpose: 'public', + }); + const mockConfig = linodeConfigFactory.build({ + id: randomNumber(), + interfaces: [ + // The order of this array is significant. Index 0 (eth0) should be public. + mockPublicConfigInterface, + ], + }); + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + const linodeNullTypePayload = createLinodeRequestFactory.build({ + label: mockLinode.label, + region: mockLinodeRegion.id, + booted: false, + }); + const newLinodeLabel = `${linodeNullTypePayload.label}-clone`; + const clonedLinode = { + ...mockLinode, + id: mockLinode.id + 1, + label: newLinodeLabel, + }; + + mockGetVLANs([mockVlan]); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeVolumes(clonedLinode.id, [mockVolume]).as('getLinodeVolumes'); + mockGetLinodeConfigs(clonedLinode.id, [mockConfig]).as('getLinodeConfigs'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and select existing VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(mockVlan.label) + .should('be.visible') + .click(); + + cy.findByLabelText(/IPAM Address/) + .should('be.enabled') + .type(mockVlan.cidr_block); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal( + mockVlan.cidr_block + ); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + + mockCloneLinode(mockLinode.id, clonedLinode).as('cloneLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + + // Wait for Linode to boot, then initiate clone flow. + cy.findByText('OFFLINE').should('be.visible'); + + ui.actionMenu + .findByTitle(`Action menu for Linode ${mockLinode.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); + cy.url().should('endWith', getLinodeCloneUrl(mockLinode)); + + // Select clone region and Linode type. + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionId(mockLinodeRegion.id).click(); + + cy.findByText('Shared CPU').should('be.visible').click(); + + cy.get('[id="g6-standard-1"]') + .closest('[data-qa-radio]') + .should('be.visible') + .click(); + + // Confirm summary displays expected information and begin clone. + cy.findByText(`Summary ${newLinodeLabel}`).should('be.visible'); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@cloneLinode').then((xhr) => { + const newLinodeId = xhr.response?.body?.id; + assert.equal(xhr.response?.statusCode, 200); + cy.url().should('endWith', `linodes/${newLinodeId}`); + }); + + cy.wait(['@getLinodeVolumes', '@getLinodeConfigs']); + ui.toast.assertMessage(`Your Linode ${newLinodeLabel} is being created.`); + }); + /* * - Confirms DC-specific pricing UI flow works as expected during Linode clone. * - Confirms that pricing docs link is shown in "Region" section. diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts new file mode 100644 index 00000000000..9c142dca96c --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts @@ -0,0 +1,88 @@ +import { Region } from '@linode/api-v4'; +import { linodeFactory, linodeTypeFactory, regionFactory } from 'src/factories'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockCreateLinode, + mockGetLinodeTypes, +} from 'support/intercepts/linodes'; +import { + mockGetRegionAvailability, + mockGetRegions, +} from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomString } from 'support/util/random'; +import { extendRegion } from 'support/util/regions'; + +describe('Create Linode in Distributed Region', () => { + /* + * - Confirms Linode create flow can be completed with a distributed region + * - Creates a basic Nanode and confirms interactions succeed and outgoing request contains expected data. + */ + it('should be able to select a distributed region', () => { + // create mocks + const mockRegionOptions: Partial = { + capabilities: ['Linodes', 'Distributed Plans'], + site_type: 'distributed', + }; + const mockRegion = extendRegion(regionFactory.build(mockRegionOptions)); + const mockLinodeTypes = [ + linodeTypeFactory.build({ + id: 'nanode-edge-1', + label: 'Nanode 1GB', + class: 'nanode', + }), + ]; + const mockLinode = linodeFactory.build({ + label: randomLabel(), + region: mockRegion.id, + }); + const rootPass = randomString(32); + + mockAppendFeatureFlags({ + gecko2: { + enabled: true, + la: true, + }, + }).as('getFeatureFlags'); + mockGetRegions([mockRegion]).as('getRegions'); + mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); + mockGetRegionAvailability(mockRegion.id, []).as('getRegionAvailability'); + mockCreateLinode(mockLinode).as('createLinode'); + + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); + + // Pick a region from the distributed region list + cy.findByTestId('region').within(() => { + ui.tabList.findTabByTitle('Distributed').should('be.visible').click(); + linodeCreatePage.selectRegionById(mockRegion.id); + }); + + cy.wait(['@getRegionAvailability']); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setRootPassword(rootPass); + + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + cy.findByRole('row', { name: /Nanode 1 GB/i }) + .should('be.visible') + .click(); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Submit form to create Linode and confirm that outgoing API request + // contains expected user data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const regionId = requestPayload['region']; + expect(regionId).to.equal(mockLinode.region); + }); + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); +}); 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 08842e8f196..f558d3cea76 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 @@ -77,7 +77,7 @@ describe('Create Linode with VPCs', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ id: mockInterface.id, active: true }], + interfaces: [{ id: mockInterface.id, active: true, config_id: 1 }], }, ], }; @@ -204,7 +204,7 @@ describe('Create Linode with VPCs', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ id: mockInterface.id, active: true }], + interfaces: [{ id: mockInterface.id, active: true, config_id: 1 }], }, ], }; 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 903c5932224..58fa3a05cd6 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -593,10 +593,11 @@ describe('Linode Config management', () => { }); // Mock config with public internet eth0, VLAN eth1, and VPC eth2. + const mockConfigInterfaces = mockConfig.interfaces ?? []; const mockConfigWithVpc: Config = { ...mockConfig, interfaces: [ - ...mockConfig.interfaces, + ...mockConfigInterfaces, LinodeConfigInterfaceFactoryWithVPC.build({ label: undefined, vpc_id: mockVPC.id, diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts index 465afc41c42..69169fb881d 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -1,4 +1,9 @@ -import { linodeFactory, ipAddressFactory } from '@src/factories'; +import { + linodeFactory, + ipAddressFactory, + firewallFactory, + firewallDeviceFactory, +} from '@src/factories'; import type { IPRange } from '@linode/api-v4'; @@ -9,8 +14,12 @@ import { } from 'support/intercepts/linodes'; import { mockUpdateIPAddress } from 'support/intercepts/networking'; import { ui } from 'support/ui'; +import { + mockAddFirewallDevice, + mockGetFirewalls, +} from 'support/intercepts/firewalls'; -describe('linode networking', () => { +describe('IP Addresses', () => { const mockLinode = linodeFactory.build(); const linodeIPv4 = mockLinode.ipv4[0]; const mockRDNS = `${linodeIPv4}.ip.linodeusercontent.com`; @@ -132,3 +141,88 @@ describe('linode networking', () => { }); }); }); + +describe('Firewalls', () => { + it('allows the user to assign a Firewall from the Linode details page', () => { + const linode = linodeFactory.build(); + const firewalls = firewallFactory.buildList(3); + const firewallToAttach = firewalls[1]; + const firewallDevice = firewallDeviceFactory.build({ + entity: { id: linode.id, type: 'linode' }, + }); + + mockGetLinodeDetails(linode.id, linode).as('getLinode'); + mockGetLinodeFirewalls(linode.id, []).as('getLinodeFirewalls'); + mockGetFirewalls(firewalls).as('getFirewalls'); + mockAddFirewallDevice(firewallToAttach.id, firewallDevice).as( + 'addFirewallDevice' + ); + + cy.visitWithLogin(`/linodes/${linode.id}/networking`); + + cy.wait(['@getLinode', '@getLinodeFirewalls']); + + cy.findByText('No Firewalls are assigned.').should('be.visible'); + + ui.button + .findByTitle('Add Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + // Firewalls should fetch when the drawer's contents are mounted + cy.wait('@getFirewalls'); + + mockGetLinodeFirewalls(linode.id, [firewallToAttach]).as( + 'getLinodeFirewalls' + ); + + ui.drawer.findByTitle('Add Firewall').within(() => { + cy.findByLabelText('Firewall').should('be.visible').click(); + + // Verify all firewalls show in the Select + for (const firewall of firewalls) { + ui.autocompletePopper + .findByTitle(firewall.label) + .should('be.visible') + .should('be.enabled'); + } + + ui.autocompletePopper.findByTitle(firewallToAttach.label).click(); + + ui.buttonGroup.find().within(() => { + ui.button + .findByTitle('Add Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + // Verify the request has the correct payload + cy.wait('@addFirewallDevice').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload.id).to.equal(linode.id); + expect(requestPayload.type).to.equal('linode'); + }); + + ui.toast.assertMessage('Successfully assigned Firewall'); + + // The Linode's firewalls list should be invalidated after the new firewall device was added + cy.wait('@getLinodeFirewalls'); + + // Verify the firewall shows up in the table + cy.findByText(firewallToAttach.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Unassign').should('be.visible').should('be.enabled'); + }); + + // The "Add Firewall" button should now be disabled beause the Linode has a firewall attached + ui.button + .findByTitle('Add Firewall') + .should('be.visible') + .should('be.disabled'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index 4242ba01f9d..650805c76ec 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -51,7 +51,7 @@ describe('resize linode', () => { cy.wait('@linodeResize'); cy.contains( - "Your linode will be warm resized and will automatically attempt to power off and restore to it's previous state." + 'Your linode will be warm resized and will automatically attempt to power off and restore to its previous state.' ).should('be.visible'); }); }); @@ -155,7 +155,7 @@ describe('resize linode', () => { }); }); - it.only('resizes a linode by decreasing size', () => { + it('resizes a linode by decreasing size', () => { // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the 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 fff6fce7576..5fa1ff3dda9 100644 --- a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts @@ -29,15 +29,15 @@ describe('Search Linodes', () => { cy.findByText(linode.label).should('be.visible'); // Use the main search bar to search and filter linode by label - cy.get('[id="main-search"').type(linode.label); + ui.mainSearch.find().type(linode.label); ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); // Use the main search bar to search and filter linode by id value - cy.get('[id="main-search"').clear().type(`${linode.id}`); + ui.mainSearch.find().clear().type(`${linode.id}`); ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); // Use the main search bar to search and filter linode by id: pattern - cy.get('[id="main-search"').clear().type(`id:${linode.id}`); + ui.mainSearch.find().clear().type(`id:${linode.id}`); ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index 9f2dd3731d9..506bb09b592 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts @@ -14,9 +14,7 @@ const confirmDeletion = (linodeLabel: string) => { cy.findByText(linodeLabel).should('not.exist'); // Confirm the linode instance is removed - cy.findByText('Search Products, IP Addresses, Tags...') - .click() - .type(`${linodeLabel}{enter}`); + ui.mainSearch.find().type(`${linodeLabel}{enter}`); cy.findByText('You searched for ...').should('be.visible'); cy.findByText('Sorry, no results for this one.').should('be.visible'); }; diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index 91d28a545ab..3b34d6f8f4e 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -133,9 +133,7 @@ describe('linode landing checks', () => { cy.get('[data-qa-search-icon="true"]') .should('be.visible') .should('be.visible'); - cy.findByText('Search Products, IP Addresses, Tags...').should( - 'be.visible' - ); + ui.mainSearch.find().should('be.visible'); cy.findByLabelText('Help & Support') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts new file mode 100644 index 00000000000..3356f5b72f7 --- /dev/null +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts @@ -0,0 +1,101 @@ +import { + firewallDeviceFactory, + firewallFactory, + nodeBalancerFactory, +} from 'src/factories'; +import { + mockAddFirewallDevice, + mockGetFirewalls, +} from 'support/intercepts/firewalls'; +import { + mockGetNodeBalancer, + mockGetNodeBalancerFirewalls, +} from 'support/intercepts/nodebalancers'; +import { ui } from 'support/ui'; + +describe('Firewalls', () => { + it('allows the user to assign a Firewall from the NodeBalancer settings page', () => { + const nodebalancer = nodeBalancerFactory.build(); + const firewalls = firewallFactory.buildList(3); + const firewallToAttach = firewalls[1]; + const firewallDevice = firewallDeviceFactory.build({ + entity: { id: nodebalancer.id, type: 'nodebalancer' }, + }); + + mockGetNodeBalancer(nodebalancer).as('getNodeBalancer'); + mockGetNodeBalancerFirewalls(nodebalancer.id, []).as( + 'getNodeBalancerFirewalls' + ); + mockGetFirewalls(firewalls).as('getFirewalls'); + mockAddFirewallDevice(firewallToAttach.id, firewallDevice).as( + 'addFirewallDevice' + ); + + cy.visitWithLogin(`/nodebalancers/${nodebalancer.id}/settings`); + + cy.wait(['@getNodeBalancer', '@getNodeBalancerFirewalls']); + + cy.findByText('No Firewalls are assigned.').should('be.visible'); + + ui.button + .findByTitle('Add Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + // Firewalls should fetch when the drawer's contents are mounted + cy.wait('@getFirewalls'); + + mockGetNodeBalancerFirewalls(nodebalancer.id, [firewallToAttach]).as( + 'getNodeBalancerFirewalls' + ); + + ui.drawer.findByTitle('Add Firewall').within(() => { + cy.findByLabelText('Firewall').should('be.visible').click(); + + // Verify all firewalls show in the Select + for (const firewall of firewalls) { + ui.autocompletePopper + .findByTitle(firewall.label) + .should('be.visible') + .should('be.enabled'); + } + + ui.autocompletePopper.findByTitle(firewallToAttach.label).click(); + + ui.buttonGroup.find().within(() => { + ui.button + .findByTitle('Add Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + // Verify the request has the correct payload + cy.wait('@addFirewallDevice').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload.id).to.equal(nodebalancer.id); + expect(requestPayload.type).to.equal('nodebalancer'); + }); + + ui.toast.assertMessage('Successfully assigned Firewall'); + + // The NodeBalancer's firewalls list should be invalidated after the new firewall device was added + cy.wait('@getNodeBalancerFirewalls'); + + // Verify the firewall shows up in the table + cy.findByText(firewallToAttach.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Unassign').should('be.visible').should('be.enabled'); + }); + + // The "Add Firewall" button should now be disabled beause the NodeBalancer has a firewall attached + ui.button + .findByTitle('Add Firewall') + .should('be.visible') + .should('be.disabled'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index 63c1e8080cf..002eb96f629 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -17,8 +17,14 @@ const deployNodeBalancer = () => { cy.get('[data-qa-deploy-nodebalancer]').click(); }; -import { nodeBalancerFactory } from 'src/factories'; +import { + linodeFactory, + nodeBalancerFactory, + regionFactory, +} from 'src/factories'; import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetLinodes } from 'support/intercepts/linodes'; const createNodeBalancerWithUI = ( nodeBal: NodeBalancer, @@ -115,48 +121,53 @@ describe('create NodeBalancer', () => { * - Confirms session stickiness field displays error if protocol is not HTTP or HTTPS. */ it('displays API errors for NodeBalancer Create form fields', () => { - const region = chooseRegion(); - const linodePayload = { - region: region.id, - // NodeBalancers require Linodes with private IPs. - private_ip: true, - }; - cy.defer(() => createTestLinode(linodePayload)).then((linode) => { - const nodeBal = nodeBalancerFactory.build({ - label: `${randomLabel()}-^`, - ipv4: linode.ipv4[1], - region: region.id, - }); + const region = regionFactory.build({ capabilities: ['NodeBalancers'] }); + const linode = linodeFactory.build({ ipv4: ['192.168.1.213'] }); - // catch request - interceptCreateNodeBalancer().as('createNodeBalancer'); + mockGetRegions([region]); + mockGetLinodes([linode]); + interceptCreateNodeBalancer().as('createNodeBalancer'); - createNodeBalancerWithUI(nodeBal); - cy.findByText(`Label can't contain special characters or spaces.`).should( - 'be.visible' - ); - cy.get('[id="nodebalancer-label"]') - .should('be.visible') - .click() - .clear() - .type(randomLabel()); - - cy.get('[data-qa-protocol-select="true"]').click().type('TCP{enter}'); - - cy.get('[data-qa-session-stickiness-select]') - .click() - .type('HTTP Cookie{enter}'); - - deployNodeBalancer(); - const errMessage = `Stickiness http_cookie requires protocol 'http' or 'https'`; - cy.wait('@createNodeBalancer') - .its('response.body') - .should('deep.equal', { - errors: [{ field: 'configs[0].stickiness', reason: errMessage }], - }); + cy.visitWithLogin('/nodebalancers/create'); - cy.findByText(errMessage).should('be.visible'); - }); + cy.findByLabelText('NodeBalancer Label') + .should('be.visible') + .type('my-nodebalancer-1'); + + ui.autocomplete.findByLabel('Region').should('be.visible').click(); + + ui.autocompletePopper + .findByTitle(region.id, { exact: false }) + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByLabelText('Label').type('my-node-1'); + + cy.findByLabelText('IP Address').click().type(linode.ipv4[0]); + + ui.autocompletePopper.findByTitle(linode.label).click(); + + ui.button + .findByTitle('Create NodeBalancer') + .scrollIntoView() + .should('be.enabled') + .should('be.visible') + .click(); + + const expectedError = + 'Address Restricted: IP must not be within 192.168.0.0/17'; + + cy.wait('@createNodeBalancer') + .its('response.body') + .should('deep.equal', { + errors: [ + { field: 'region', reason: 'region is not valid' }, + { field: 'configs[0].nodes[0].address', reason: expectedError }, + ], + }); + + cy.findByText(expectedError).should('be.visible'); }); /* diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts index 057130a1a5f..d7da620f401 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts @@ -1,7 +1,9 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetProfile } from 'support/intercepts/profile'; import { mockGetAccessKeys } from 'support/intercepts/object-storage'; import { accountFactory, objectStorageKeyFactory } from 'src/factories'; +import { profileFactory } from 'src/factories/profile'; import { ui } from 'support/ui'; describe('Object Storage gen2 access keys tests', () => { @@ -85,3 +87,55 @@ describe('Object Storage gen2 access keys tests', () => { }); }); }); + +/** + * When a restricted user navigates to object-storage/access-keys/create, an error is shown in the "Create Access Key" drawer noting that the user does not have access key creation permissions + */ +describe('Object Storage Gen2 create access key modal has disabled fields for restricted user', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: true }, + }).as('getFeatureFlags'); + mockGetAccount( + accountFactory.build({ + capabilities: [ + 'Object Storage', + 'Object Storage Endpoint Types', + 'Object Storage Access Key Regions', + ], + }) + ).as('getAccount'); + // restricted user + mockGetProfile( + profileFactory.build({ + email: 'mock-user@linode.com', + restricted: true, + }) + ).as('getProfile'); + }); + + // access keys creation + it('create access keys form', () => { + cy.visitWithLogin('/object-storage/access-keys/create'); + + cy.wait(['@getFeatureFlags', '@getAccount', '@getProfile']); + // error message + ui.drawer + .findByTitle('Create Access Key') + .should('be.visible') + .within(() => { + cy.findByText( + /You don't have bucket_access to create an Access Key./ + ).should('be.visible'); + // label + cy.findByLabelText(/Label.*/) + .should('be.visible') + .should('be.disabled'); + // region + ui.regionSelect.find().should('be.visible').should('be.disabled'); + // submit button is disabled + cy.findByTestId('submit').should('be.visible').should('be.disabled'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts index 100cda5dbac..69aca1c76c6 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts @@ -8,6 +8,7 @@ import { mockGetBucketAccess, mockCreateBucketError, } from 'support/intercepts/object-storage'; +import { mockGetProfile } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { checkRateLimitsTable } from 'support/util/object-storage-gen2'; @@ -18,6 +19,7 @@ import { objectStorageEndpointsFactory, regionFactory, } from 'src/factories'; +import { profileFactory } from 'src/factories/profile'; import { chooseRegion } from 'support/util/regions'; import type { ACLType, ObjectStorageEndpoint } from '@linode/api-v4'; @@ -716,3 +718,55 @@ describe('Object Storage Gen2 create bucket tests', () => { }); }); }); + +/** + * When a restricted user navigates to object-storage/buckets/create, an error is shown in the "Create Bucket" drawer noting that the user does not have bucket creation permissions + */ +describe('Object Storage Gen2 create bucket modal has disabled fields for restricted user', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: true }, + }).as('getFeatureFlags'); + mockGetAccount( + accountFactory.build({ + capabilities: [ + 'Object Storage', + 'Object Storage Endpoint Types', + 'Object Storage Access Key Regions', + ], + }) + ).as('getAccount'); + // restricted user + mockGetProfile( + profileFactory.build({ + email: 'mock-user@linode.com', + restricted: true, + }) + ).as('getProfile'); + }); + + // bucket creation + it('create bucket form', () => { + cy.visitWithLogin('/object-storage/buckets/create'); + cy.wait(['@getFeatureFlags', '@getAccount', '@getProfile']); + + // error message + ui.drawer + .findByTitle('Create Bucket') + .should('be.visible') + .within(() => { + cy.findByText(/You don't have permissions to create a Bucket./).should( + 'be.visible' + ); + cy.findByLabelText(/Label.*/) + .should('be.visible') + .should('be.disabled'); + ui.regionSelect.find().should('be.visible').should('be.disabled'); + // submit button should be enabled + cy.findByTestId('create-bucket-button') + .should('be.visible') + .should('be.enabled'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts index 79778512a08..b5f94c08487 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts @@ -8,14 +8,17 @@ import { regionFactory, } from 'src/factories'; import { chooseRegion } from 'support/util/regions'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ObjectStorageEndpoint } from '@linode/api-v4'; import { randomItem, randomLabel } from 'support/util/random'; +import { extendRegion } from 'support/util/regions'; import { mockCreateBucket, mockGetBucket, mockGetBucketObjectFilename, mockGetBucketObjects, mockGetBucketsForRegion, + mockGetBucketsForRegionError, mockGetObjectStorageEndpoints, mockUploadBucketObject, mockUploadBucketObjectS3, @@ -364,4 +367,116 @@ describe('Object Storage Gen2 bucket object tests', () => { checkBucketObjectDetailsDrawer(bucketFilename, endpointTypeE3); }); + + it('displays successfully fetched buckets, warning message for single failed fetch', () => { + const mockRegions = regionFactory + .buildList(2, { + capabilities: ['Object Storage'], + }) + .map((region) => extendRegion(region)); + mockGetRegions(mockRegions).as('getRegions'); + const mockEndpoints = mockRegions.map((mockRegion) => { + return objectStorageEndpointsFactory.build({ + endpoint_type: 'E2', + region: mockRegion.id, + s3_endpoint: `${mockRegion.id}.linodeobjects.com`, + }); + }); + + mockGetObjectStorageEndpoints(mockEndpoints).as('getEndpoints'); + const mockBucket1 = objectStorageBucketFactoryGen2.build({ + label: randomLabel(), + region: mockRegions[0].id, + }); + // this bucket should display + mockGetBucketsForRegion(mockRegions[0].id, [mockBucket1]).as( + 'getBucketsForRegion' + ); + mockGetBucketsForRegionError(mockRegions[1].id).as( + 'getBucketsForRegionError' + ); + + cy.visitWithLogin('/object-storage/buckets'); + cy.wait([ + '@getRegions', + '@getEndpoints', + '@getBucketsForRegion', + '@getBucketsForRegionError', + ]); + // table with retrieved bucket + cy.findByText(mockBucket1.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockRegions[0].label).should('be.visible'); + }); + // warning message + cy.findByTestId('notice-warning-important').within(() => { + cy.contains( + `There was an error loading buckets in ${mockRegions[1].label}` + ); + }); + cy.contains( + `If you have buckets in ${mockRegions[1].label}, you may not see them listed below.` + ); + }); + + it('displays successfully fetched buckets, warning message for multiple failed fetches', () => { + const mockRegions = regionFactory.buildList(3, { + capabilities: ['Object Storage'], + }); + mockGetRegions(mockRegions).as('getRegions'); + const mockEndpoints = mockRegions.map((mockRegion) => { + return objectStorageEndpointsFactory.build({ + endpoint_type: 'E2', + region: mockRegion.id, + s3_endpoint: `${mockRegion.id}.linodeobjects.com`, + }); + }); + + mockGetObjectStorageEndpoints(mockEndpoints).as('getEndpoints'); + const mockBucket1 = objectStorageBucketFactoryGen2.build({ + label: randomLabel(), + region: mockRegions[0].id, + }); + // this bucket should display + mockGetBucketsForRegion(mockRegions[0].id, [mockBucket1]).as( + 'getBucketsForRegion' + ); + // force errors for 2 regions' buckets + mockGetBucketsForRegionError(mockRegions[1].id).as( + 'getBucketsForRegionError0' + ); + mockGetBucketsForRegionError(mockRegions[2].id).as( + 'getBucketsForRegionError1' + ); + cy.visitWithLogin('/object-storage/buckets'); + cy.wait([ + '@getRegions', + '@getEndpoints', + '@getBucketsForRegion', + '@getBucketsForRegionError0', + '@getBucketsForRegionError1', + ]); + // table with retrieved bucket + cy.get('table tbody tr').should('have.length', 1); + // warning message + cy.findByTestId('notice-warning-important').within(() => { + cy.contains( + 'There was an error loading buckets in the following regions:' + ); + const strError1 = `${mockRegions[1].country.toUpperCase()}, ${ + mockRegions[1].label + }`; + const strError2 = `${mockRegions[2].country.toUpperCase()}, ${ + mockRegions[2].label + }`; + cy.get('ul>li').eq(0).contains(strError1); + cy.get('ul>li').eq(1).contains(strError2); + // bottom of warning message + cy.contains( + 'If you have buckets in these regions, you may not see them listed below.' + ); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts index d065bf1140f..f2e121e73f5 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -5,6 +5,7 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockDeletePlacementGroup, + mockGetPlacementGroup, mockGetPlacementGroups, mockUnassignPlacementGroupLinodes, mockDeletePlacementGroupError, @@ -62,6 +63,7 @@ describe('Placement Group deletion', () => { }); mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); cy.visitWithLogin('/placement-groups'); cy.wait('@getPlacementGroups'); @@ -172,9 +174,10 @@ describe('Placement Group deletion', () => { mockGetPlacementGroups([mockPlacementGroup, secondMockPlacementGroup]).as( 'getPlacementGroups' ); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); cy.visitWithLogin('/placement-groups'); - cy.wait(['@getPlacementGroups', '@getLinodes']); + cy.wait(['@getPlacementGroups']); // Click "Delete" button next to the mock Placement Group, and initially mock // an API error response and confirm that the error message is displayed in the @@ -190,72 +193,30 @@ describe('Placement Group deletion', () => { .click(); }); - // The Placement Groups landing page fires off a Linode GET request upon - // clicking the "Delete" button so that Cloud knows which Linodes are assigned - // to the selected Placement Group. - cy.wait('@getLinodes'); - mockUnassignPlacementGroupLinodesError( mockPlacementGroup.id, PlacementGroupErrorMessage ).as('UnassignPlacementGroupError'); - // Close dialog and re-open it. This is a workaround to prevent Cypress - // failures triggered by React re-rendering after fetching Linodes. - // - // Tanstack Query is configured to respond with cached data for the `useAllLinodes` - // query hook while awaiting the HTTP request response. Because the Placement - // Groups landing page fetches Linodes upon opening the deletion modal, there - // is a brief period of time where Linode labels are rendered using cached data, - // then re-rendered after the real API request resolves. This re-render occasionally - // triggers Cypress failures. - // - // Opening the deletion modal for the same Placement Group a second time - // does not trigger another HTTP GET request, this helps circumvent the - // issue because the cached/problematic HTTP request is already long resolved - // and there is less risk of a re-render occurring while Cypress interacts - // with the dialog. - // - // TODO Consider removing this workaround after M3-8717 is implemented. ui.dialog .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) .should('be.visible') .within(() => { - ui.drawerCloseButton.find().click(); - }); - - cy.findByText(mockPlacementGroup.label) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) - .should('be.visible') - .within(() => { - cy.get('[data-qa-selection-list]') - .should('be.visible') - .within(() => { - // Select the first Linode to unassign - const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; - cy.findByText(mockLinodeToUnassign.label) - .closest('li') - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Unassign') - .should('be.visible') - .should('be.enabled') - .click(); - }); - }); + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); cy.wait('@UnassignPlacementGroupError'); cy.findByText(PlacementGroupErrorMessage).should('be.visible'); @@ -296,6 +257,9 @@ describe('Placement Group deletion', () => { placementGroupAfterUnassignment, secondMockPlacementGroup, ]).as('getPlacementGroups'); + mockGetPlacementGroup(placementGroupAfterUnassignment).as( + 'getPlacementGroups' + ); cy.findByText(mockLinode.label) .should('be.visible') @@ -308,10 +272,7 @@ describe('Placement Group deletion', () => { .click(); }); - // Cloud fires off 2 requests to fetch Linodes: once before the unassignment, - // and again after. Wait for both of these requests to resolve to reduce the - // risk of a re-render occurring when unassigning the next Linode. - cy.wait(['@unassignLinode', '@getLinodes', '@getLinodes']); + cy.wait(['@unassignLinode']); cy.findByText(mockLinode.label).should('not.exist'); }); }); @@ -363,6 +324,7 @@ describe('Placement Group deletion', () => { }); mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); cy.visitWithLogin('/placement-groups'); cy.wait('@getPlacementGroups'); @@ -488,9 +450,10 @@ describe('Placement Group deletion', () => { mockGetPlacementGroups([mockPlacementGroup, secondMockPlacementGroup]).as( 'getPlacementGroups' ); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); cy.visitWithLogin('/placement-groups'); - cy.wait(['@getPlacementGroups', '@getLinodes']); + cy.wait(['@getPlacementGroups']); // Click "Delete" button next to the mock Placement Group. cy.findByText(mockPlacementGroup.label) @@ -504,36 +467,12 @@ describe('Placement Group deletion', () => { .click(); }); - // The Placement Groups landing page fires off a Linode GET request upon - // clicking the "Delete" button so that Cloud knows which Linodes are assigned - // to the selected Placement Group. - cy.wait('@getLinodes'); - // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. mockUnassignPlacementGroupLinodesError( mockPlacementGroup.id, PlacementGroupErrorMessage ).as('UnassignPlacementGroupError'); - ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) - .should('be.visible') - .within(() => { - ui.drawerCloseButton.find().should('be.visible').click(); - }); - - // Click "Delete" button next to the mock Placement Group again. - cy.findByText(mockPlacementGroup.label) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - ui.dialog .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts index 0c52a148848..3f0bd28aa7d 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts @@ -378,6 +378,7 @@ describe('Placement Groups Linode assignment', () => { mockGetRegions(mockRegions); mockGetLinodes(mockLinodes); + mockGetLinodeDetails(mockLinodeUnassigned.id, mockLinodeUnassigned); mockGetPlacementGroups([mockPlacementGroup]); mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); diff --git a/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts index b857c69e1ca..de6c903c887 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts @@ -4,6 +4,7 @@ import { randomLabel, randomNumber } from 'support/util/random'; import { + mockGetPlacementGroup, mockGetPlacementGroups, mockUpdatePlacementGroup, mockUpdatePlacementGroupError, @@ -48,6 +49,7 @@ describe('Placement Group update label flow', () => { }; mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); mockUpdatePlacementGroup( mockPlacementGroup.id, @@ -114,6 +116,7 @@ describe('Placement Group update label flow', () => { }; mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockGetPlacementGroup(mockPlacementGroup).as('getPlacementGroup'); mockUpdatePlacementGroupError( mockPlacementGroup.id, diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 7d3b490d032..98992a90d5e 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -188,7 +188,6 @@ describe('Create stackscripts', () => { const stackscriptLabel = randomLabel(); const stackscriptDesc = randomPhrase(); const stackscriptImage = 'Alpine 3.19'; - const stackscriptImageTag = 'alpine3.19'; const linodeLabel = randomLabel(); const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); @@ -243,21 +242,18 @@ describe('Create stackscripts', () => { .should('be.enabled') .click(); - // Confirm the user is redirected to landing page and StackScript is shown. - cy.wait('@createStackScript'); - cy.url().should('endWith', '/stackscripts/account'); - cy.wait('@getStackScripts'); - - cy.findByText(stackscriptLabel) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText(stackscriptDesc).should('be.visible'); - cy.findByText(stackscriptImageTag).should('be.visible'); - }); + cy.wait('@createStackScript').then((intercept) => { + // Confirm the user is redirected to the StackScript details page + cy.url().should( + 'endWith', + `/stackscripts/${intercept.response?.body.id}` + ); - // Navigate to StackScript details page and click deploy Linode button. - cy.findByText(stackscriptLabel).should('be.visible').click(); + // Confirm a success toast shows + ui.toast.assertMessage( + `Successfully created StackScript ${intercept.response?.body.label}` + ); + }); ui.button .findByTitle('Deploy New Linode') @@ -336,8 +332,13 @@ describe('Create stackscripts', () => { .should('be.enabled') .click(); - cy.wait('@createStackScript'); - cy.url().should('endWith', '/stackscripts/account'); + // Confirm the user is redirected to the StackScript details page + cy.wait('@createStackScript').then((intercept) => { + cy.url().should( + 'endWith', + `/stackscripts/${intercept.response?.body.id}` + ); + }); cy.wait('@getAllImages').then((res) => { // Fetch Images from response data and filter out Kubernetes images. @@ -347,18 +348,6 @@ describe('Create stackscripts', () => { 'public' ); - cy.wait('@getStackScripts'); - cy.findByText(stackscriptLabel) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText(stackscriptDesc).should('be.visible'); - cy.findByText(stackscriptImage).should('be.visible'); - }); - - // Navigate to StackScript details page and click deploy Linode button. - cy.findByText(stackscriptLabel).should('be.visible').click(); - ui.button .findByTitle('Deploy New Linode') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts index 4ae8be80f21..757533ee169 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts @@ -1,8 +1,7 @@ import type { StackScript } from '@linode/api-v4'; -import { Profile, getImages, getProfile } from '@linode/api-v4'; +import { Profile, getProfile } from '@linode/api-v4'; import { stackScriptFactory } from 'src/factories'; -import { isLinodeKubeImageId } from 'src/store/image/image.helpers'; import { formatDate } from 'src/utilities/formatDate'; import { authenticate } from 'support/api/authentication'; @@ -15,12 +14,9 @@ import { } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { depaginate } from 'support/util/paginate'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import type { Image } from '@linode/api-v4'; - const mockStackScripts: StackScript[] = [ stackScriptFactory.build({ id: 443929, @@ -106,7 +102,10 @@ describe('Community Stackscripts integration tests', () => { cy.visitWithLogin('/stackscripts/community'); cy.wait('@getStackScripts'); - cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist'); + // Confirm that empty state is not shown. + cy.get('[data-qa-placeholder-container="resources-section"]').should( + 'not.exist' + ); cy.findByText('Automate deployment scripts').should('not.exist'); cy.defer(getProfile, 'getting profile').then((profile: Profile) => { @@ -138,7 +137,7 @@ describe('Community Stackscripts integration tests', () => { // Search the corresponding community stack script mockGetStackScripts([stackScript]).as('getFilteredStackScripts'); - cy.get('[id="search-by-label,-username,-or-description"]') + cy.findByPlaceholderText('Search by Label, Username, or Description') .click() .type(`${stackScript.label}{enter}`); cy.wait('@getFilteredStackScripts'); @@ -194,69 +193,39 @@ describe('Community Stackscripts integration tests', () => { interceptGetStackScripts().as('getStackScripts'); // Fetch all public Images to later use while filtering StackScripts. - cy.defer(() => - depaginate((page) => getImages({ page }, { is_public: true })) - ).then((publicImages: Image[]) => { - cy.visitWithLogin('/stackscripts/community'); - cy.wait('@getStackScripts'); - - // Confirm that empty state is not shown. - cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist'); - cy.findByText('Automate deployment scripts').should('not.exist'); - - // Confirm that scrolling to the bottom of the StackScripts list causes - // pagination to occur automatically. Perform this check 3 times. - for (let i = 0; i < 3; i += 1) { - cy.findByLabelText('List of StackScripts') - .should('be.visible') - .within(() => { - // Scroll to the bottom of the StackScripts list, confirm Cloud fetches StackScripts, - // then confirm that list updates with the new StackScripts shown. - cy.get('tr').last().scrollIntoView(); - cy.wait('@getStackScripts').then((xhr) => { - const stackScripts = xhr.response?.body['data'] as - | StackScript[] - | undefined; - - if (!stackScripts) { - throw new Error( - 'Unexpected response received when fetching StackScripts' - ); - } - - // Cloud Manager hides certain StackScripts from the landing page (although they can - // still be found via search). It does this if either condition is met: - // - // - The StackScript is only compatible with deprecated Images - // - The StackScript is only compatible with LKE Images - // - // As a consequence, we can't use the API response directly to assert - // that content is shown in the list. We need to apply identical filters - // to the response first, then assert the content using that data. - const filteredStackScripts = stackScripts.filter( - (stackScript: StackScript) => { - const hasNonDeprecatedImages = stackScript.images.some( - (stackScriptImage) => { - return !!publicImages.find( - (publicImage) => publicImage.id === stackScriptImage - ); - } - ); - - const usesKubeImage = stackScript.images.some( - (stackScriptImage) => isLinodeKubeImageId(stackScriptImage) - ); - return hasNonDeprecatedImages && !usesKubeImage; - } - ); + cy.visitWithLogin('/stackscripts/community'); + cy.wait('@getStackScripts'); - cy.contains( - `${filteredStackScripts[0].username} / ${filteredStackScripts[0].label}` - ).should('be.visible'); - }); - }); - } - }); + // Confirm that empty state is not shown. + cy.get('[data-qa-placeholder-container="resources-section"]').should( + 'not.exist' + ); + cy.findByText('Automate deployment scripts').should('not.exist'); + + // Confirm that scrolling to the bottom of the StackScripts list causes + // pagination to occur automatically. Perform this check 3 times. + for (let i = 0; i < 3; i += 1) { + cy.findByLabelText('List of StackScripts') + .should('be.visible') + .within(() => { + // Scroll to the bottom of the StackScripts list, confirm Cloud fetches StackScripts, + // then confirm that list updates with the new StackScripts shown. + cy.get('tr').last().scrollIntoView(); + cy.wait('@getStackScripts').then((xhr) => { + const stackScripts = xhr.response?.body['data'] as + | StackScript[] + | undefined; + + if (!stackScripts) { + throw new Error( + 'Unexpected response received when fetching StackScripts' + ); + } + + cy.contains(`${stackScripts[0].username} / ${stackScripts[0].label}`).should('be.visible'); + }); + }); + } }); /* @@ -271,13 +240,16 @@ describe('Community Stackscripts integration tests', () => { cy.visitWithLogin('/stackscripts/community'); cy.wait('@getStackScripts'); - cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist'); + // Confirm that empty state is not shown. + cy.get('[data-qa-placeholder-container="resources-section"]').should( + 'not.exist' + ); cy.findByText('Automate deployment scripts').should('not.exist'); cy.get('tr').then((value) => { const rowCount = Cypress.$(value).length - 1; // Remove the table title row - cy.get('[id="search-by-label,-username,-or-description"]') + cy.findByPlaceholderText('Search by Label, Username, or Description') .click() .type(`${stackScript.label}{enter}`); cy.get(`[data-qa-table-row="${stackScript.label}"]`).should('be.visible'); @@ -311,7 +283,7 @@ describe('Community Stackscripts integration tests', () => { cy.visitWithLogin('/stackscripts/community'); cy.wait(['@getStackScripts', '@getPreferences']); - cy.get('[id="search-by-label,-username,-or-description"]') + cy.findByPlaceholderText('Search by Label, Username, or Description') .click() .type(`${stackScriptName}{enter}`); cy.get(`[data-qa-table-row="${stackScriptName}"]`) diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts index dd039c7bb1d..ee132f35ea3 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts @@ -15,7 +15,8 @@ describe('Display stackscripts', () => { cy.wait('@getStackScripts'); cy.findByText('Automate deployment scripts').should('be.visible'); - cy.get('[data-qa-stackscript-empty-msg="true"]') + + cy.get('[data-qa-placeholder-container="resources-section"]') .should('be.visible') .within(() => { ui.button diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index 45ced001433..da67fcc7ddd 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -197,15 +197,10 @@ describe('Update stackscripts', () => { .should('be.enabled') .click(); cy.wait('@updateStackScript'); - cy.url().should('endWith', '/stackscripts/account'); - cy.wait('@getStackScripts'); - - cy.findByText(stackscriptLabel) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText(stackscriptDesc).should('be.visible'); - }); + ui.toast.assertMessage( + `Successfully updated StackScript ${updatedStackScripts[0].label}` + ); + cy.url().should('endWith', `/stackscripts/${updatedStackScripts[0].id}`); }); /* @@ -232,7 +227,7 @@ describe('Update stackscripts', () => { .should('be.visible') .click(); ui.dialog - .findByTitle('Woah, just a word of caution...') + .findByTitle(`Make StackScript ${stackScripts[0].label} Public?`) .should('be.visible') .within(() => { ui.button.findByTitle('Cancel').should('be.visible').click(); @@ -267,11 +262,11 @@ describe('Update stackscripts', () => { 'mockGetStackScripts' ); ui.dialog - .findByTitle('Woah, just a word of caution...') + .findByTitle(`Make StackScript ${stackScripts[0].label} Public?`) .should('be.visible') .within(() => { ui.button - .findByTitle('Yes, make me a star!') + .findByTitle('Confirm') .should('be.visible') .click(); }); diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts index 4b95b1a407f..fbf120ce824 100644 --- a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts @@ -52,7 +52,7 @@ describe('Search Volumes', () => { cy.findByText(volume2.label).should('be.visible'); // Use the main search bar to search and filter volumes - cy.get('[id="main-search"').type(volume2.label); + ui.mainSearch.find().type(volume2.label); ui.autocompletePopper.findByTitle(volume2.label).click(); // Confirm that only the second volume is shown. 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 aeada2e14d3..4ad53ea2022 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 @@ -211,15 +211,18 @@ describe('VPC assign/unassign flows', () => { subnets: [mockSubnet], }); + const vpcInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + subnet_id: mockSubnet.id, + }); const mockLinodeConfig = linodeConfigFactory.build({ - interfaces: [ - LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - subnet_id: mockSubnet.id, - }), - ], + interfaces: [vpcInterface], }); + const mockLinodeConfigInterfaces = mockLinodeConfig.interfaces ?? [ + vpcInterface, + ]; + mockGetVPCs(mockVPCs).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); @@ -304,12 +307,12 @@ describe('VPC assign/unassign flows', () => { mockDeleteLinodeConfigInterface( mockLinode.id, mockLinodeConfig.id, - mockLinodeConfig.interfaces[0].id + mockLinodeConfigInterfaces[0].id ).as('deleteLinodeConfigInterface1'); mockDeleteLinodeConfigInterface( mockSecondLinode.id, mockLinodeConfig.id, - mockLinodeConfig.interfaces[0].id + mockLinodeConfigInterfaces[0].id ).as('deleteLinodeConfigInterface2'); ui.button .findByTitle('Unassign Linodes') diff --git a/packages/manager/cypress/support/constants/alert.ts b/packages/manager/cypress/support/constants/alert.ts new file mode 100644 index 00000000000..56cb2210a32 --- /dev/null +++ b/packages/manager/cypress/support/constants/alert.ts @@ -0,0 +1,38 @@ +import type { + AlertSeverityType, + DimensionFilterOperatorType, + MetricAggregationType, + MetricOperatorType, +} from '@linode/api-v4'; + +export const dimensionOperatorTypeMap: Record< + DimensionFilterOperatorType, + string +> = { + endswith: 'ends with', + eq: 'equals', + neq: 'not equals', + startswith: 'starts with', +}; + +export const metricOperatorTypeMap: Record = { + eq: '=', + gt: '>', + gte: '>=', + lt: '<', + lte: '<=', +}; +export const severityMap: Record = { + 0: 'Severe', + 1: 'Medium', + 2: 'Low', + 3: 'Info', +}; + +export const aggregationTypeMap: Record = { + avg: 'Average', + count: 'Count', + max: 'Maximum', + min: 'Minimum', + sum: 'Sum', +}; diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index a3ae28ffbbf..fb193793309 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -573,6 +573,23 @@ export const mockGetAccountAgreements = ( ); }; +/** + * Intercepts POST request to update account agreements and mocks response. + * + * @param agreements - Agreements with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateAccountAgreements = ( + agreements: Agreements +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`account/agreements`), + makeResponse(agreements) + ); +}; + /** * Intercepts GET request to fetch child accounts and mocks the response. * diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts index f17e884787a..c3bece04795 100644 --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts @@ -10,6 +10,7 @@ import { paginateResponse } from 'support/util/paginate'; import { randomString } from 'support/util/random'; import { makeResponse } from 'support/util/response'; +import type { Alert } from '@linode/api-v4'; import type { CloudPulseMetricsResponse, Dashboard, @@ -259,3 +260,50 @@ export const mockGetCloudPulseDashboardByIdError = ( makeErrorResponse(errorMessage, status) ); }; + +/** + * Mocks the API response for retrieving alert definitions for a given service type and alert ID. + * This is useful for testing the behavior of the system when fetching alert definitions. + * + * @param {string} serviceType - The type of the service for which we are mocking the alert definition (e.g., 'dbaas'). + * @param {number} id - The unique identifier for the alert definition to be retrieved. + * @param {Alert} alert - The mock alert object that should be returned by the API in place of a real response. + * + * @returns {Cypress.Chainable} A Cypress chainable object that represents the intercepted API call. + */ + +export const mockGetAlertDefinitions = ( + serviceType: string, + id: number, + alert: Alert +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/monitor/services/${serviceType}/alert-definitions/${id}`), + makeResponse(alert) + ); +}; + +/** + * Mocks the API response for retrieving all alert definitions from the monitoring service. + * This function intercepts a GET request to fetch alert definitions and returns a mock + * response, simulating the behavior of the real API by providing a list of alert definitions. + * + * The mock response is paginated, with a page size of 500, allowing the test to simulate + * the scenario where the system is retrieving a large set of alert definitions. + * + * @param {Alert[]} alert - An array of `Alert` objects to mock as the response. This should + * represent the alert definitions being fetched by the API. + * + * @returns {Cypress.Chainable} - A Cypress chainable object that represents the intercepted + */ + +export const mockGetAllAlertDefinitions = ( + alert: Alert[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('/monitor/alert-definitions?page_size=500'), + paginateResponse(alert) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/firewalls.ts b/packages/manager/cypress/support/intercepts/firewalls.ts index 7ece27835b7..6ffe43ada71 100644 --- a/packages/manager/cypress/support/intercepts/firewalls.ts +++ b/packages/manager/cypress/support/intercepts/firewalls.ts @@ -5,7 +5,11 @@ import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; -import type { Firewall, FirewallTemplate } from '@linode/api-v4'; +import type { + Firewall, + FirewallDevice, + FirewallTemplate, +} from '@linode/api-v4'; /** * Intercepts GET request to fetch Firewalls. @@ -102,6 +106,22 @@ export const interceptUpdateFirewallLinodes = ( ); }; +/** + * Mocks the POST request to add a Firewall device. + * + * @returns Cypress chainable. + */ +export const mockAddFirewallDevice = ( + firewallId: number, + firewallDevice: FirewallDevice +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`networking/firewalls/${firewallId}/devices`), + firewallDevice + ); +}; + /** * Intercepts GET request to fetch a Firewall template and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 5511492da28..d46bcc8dd1b 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -417,6 +417,24 @@ export const interceptCloneLinode = ( return cy.intercept('POST', apiMatcher(`linode/instances/${linodeId}/clone`)); }; +/** + * Intercepts POST request to clone a Linode and mock responses. + * + * @param linodeId - ID of Linode being cloned. + * + * @returns Cypress chainable. + */ +export const mockCloneLinode = ( + linodeId: number, + linode: Linode +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/clone`), + makeResponse(linode) + ); +}; + /** * Intercepts POST request to enable backups for a Linode. * diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 88905b33b38..6e66861c374 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -529,3 +529,26 @@ export const mockUpdateClusterError = ( makeErrorResponse(errorMessage, statusCode) ); }; + +/** + * Intercepts PUT request to update an LKE cluster node pool and mocks an error response. + * + * @param clusterId - ID of cluster for which to intercept PUT request. + * @param nodePoolId - Numeric ID of node pool for which to mock response. + * @param errorMessage - Optional error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateNodePoolError = ( + clusterId: number, + nodePool: KubeNodePoolResponse, + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`lke/clusters/${clusterId}/pools/${nodePool.id}`), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/nodebalancers.ts b/packages/manager/cypress/support/intercepts/nodebalancers.ts index bed40e652c5..75cf8771abd 100644 --- a/packages/manager/cypress/support/intercepts/nodebalancers.ts +++ b/packages/manager/cypress/support/intercepts/nodebalancers.ts @@ -5,7 +5,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; -import type { NodeBalancer } from '@linode/api-v4'; +import type { Firewall, NodeBalancer } from '@linode/api-v4'; /** * Intercepts GET request to mock nodeBalancer data. @@ -24,6 +24,42 @@ export const mockGetNodeBalancers = ( ); }; +/** + * Intercepts GET request to mock a nodeBalancer. + * + * @param nodeBalancer - an mock nodeBalancer object + * + * @returns Cypress chainable. + */ +export const mockGetNodeBalancer = ( + nodeBalancer: NodeBalancer +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`nodebalancers/${nodeBalancer.id}`), + nodeBalancer + ); +}; + +/** + * Mocks GET request to get a NodeBalancer's firewalls. + * + * @param nodeBalancerId - ID of the NodeBalancer to get firewalls associated with it. + * @param firewalls - the firewalls with which to mock the response. + * + * @returns Cypress Chainable. + */ +export const mockGetNodeBalancerFirewalls = ( + nodeBalancerId: number, + firewalls: Firewall[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`nodebalancers/${nodeBalancerId}/firewalls`), + paginateResponse(firewalls) + ); +}; + /** * Intercepts POST request to intercept nodeBalancer data. * diff --git a/packages/manager/cypress/support/intercepts/object-storage.ts b/packages/manager/cypress/support/intercepts/object-storage.ts index 7c304178e47..d7ab4307d61 100644 --- a/packages/manager/cypress/support/intercepts/object-storage.ts +++ b/packages/manager/cypress/support/intercepts/object-storage.ts @@ -74,6 +74,27 @@ export const mockGetBucketsForRegion = ( ); }; +/** + * Intercepts POST request to create a bucket and mocks an error response. + * + * @param errorMessage - Optional error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetBucketsForRegionError = ( + regionId: string, + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + console.log('mockGetBucketsForRegionError', regionId); + return cy.intercept( + 'GET', + apiMatcher(`object-storage/buckets/${regionId}*`), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts POST request to create bucket. * diff --git a/packages/manager/cypress/support/plugins/configure-browser.ts b/packages/manager/cypress/support/plugins/configure-browser.ts index 3d0c57802d3..1fa1efdc5bb 100644 --- a/packages/manager/cypress/support/plugins/configure-browser.ts +++ b/packages/manager/cypress/support/plugins/configure-browser.ts @@ -53,21 +53,8 @@ export const configureBrowser: CypressPlugin = (on, _config) => { }, }; - // Disable Chrome's new headless implementation. - // This attempts to resolve indefinite test hanging. - // - // See also: https://github.com/cypress-io/cypress/issues/27264 - if (browser.name === 'chrome' && browser.isHeadless) { - // If present, remove the `--headless=new` command line argument. - launchOptions.args = launchOptions.args.filter((arg: string) => { - return arg !== '--headless=new'; - }); - // Append `--headless=old` and `--disable-dev-shm-usage` args. - launchOptions.args.push('--headless=old'); - launchOptions.args.push('--disable-dev-shm-usage'); - } - displayBrowserInfo(browser, launchOptions); + return launchOptions; }); }; diff --git a/packages/manager/cypress/support/ui/index.ts b/packages/manager/cypress/support/ui/index.ts index 1cba6746bcb..8004445a568 100644 --- a/packages/manager/cypress/support/ui/index.ts +++ b/packages/manager/cypress/support/ui/index.ts @@ -10,6 +10,7 @@ import * as entityHeader from './entity-header'; import * as fileUpload from './file-upload'; import * as heading from './heading'; import * as landingPageEmptyStateResources from './landing-page-empty-state-resources'; +import * as mainSearch from './main-search'; import * as nav from './nav'; import * as pagination from './pagination'; import * as select from './select'; @@ -32,6 +33,7 @@ export const ui = { ...fileUpload, ...heading, ...landingPageEmptyStateResources, + ...mainSearch, ...nav, ...pagination, ...select, diff --git a/packages/manager/cypress/support/ui/main-search.ts b/packages/manager/cypress/support/ui/main-search.ts new file mode 100644 index 00000000000..7b4fbae2bec --- /dev/null +++ b/packages/manager/cypress/support/ui/main-search.ts @@ -0,0 +1,16 @@ +/** + * Drawer UI element. + * + * Useful for validating content, filling out forms, etc. that appear within + * a drawer. + */ +export const mainSearch = { + /** + * Finds a drawer. + * + * @returns Cypress chainable. + */ + find: (): Cypress.Chainable => { + return cy.get('[data-qa-main-search]'); + }, +}; diff --git a/packages/manager/package.json b/packages/manager/package.json index 430b6b8ee03..1ebb1a097f9 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.135.0", + "version": "1.136.0", "private": true, "type": "module", "bugs": { @@ -58,7 +58,7 @@ "libphonenumber-js": "^1.10.6", "logic-query-parser": "^0.0.5", "luxon": "3.4.4", - "markdown-it": "^12.3.2", + "markdown-it": "^14.1.0", "md5": "^2.2.1", "notistack": "^3.0.1", "qrcode.react": "^0.8.0", @@ -118,6 +118,7 @@ ] }, "devDependencies": { + "@4tw/cypress-drag-drop": "^2.2.5", "@linode/eslint-plugin-cloud-manager": "^0.0.5", "@storybook/addon-a11y": "^8.4.7", "@storybook/addon-actions": "^8.4.7", @@ -133,7 +134,7 @@ "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/theming": "^8.4.7", - "@swc/core": "^1.3.1", + "@swc/core": "^1.10.9", "@testing-library/cypress": "^10.0.2", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", @@ -148,14 +149,13 @@ "@types/highlight.js": "~10.1.0", "@types/jspdf": "^1.3.3", "@types/luxon": "3.4.2", - "@types/markdown-it": "^10.0.2", + "@types/markdown-it": "^14.1.2", "@types/md5": "^2.1.32", "@types/mocha": "^10.0.2", "@types/node": "^20.17.0", "@types/qrcode.react": "^0.8.0", "@types/ramda": "0.25.16", "@types/react": "^18.2.55", - "@types/react-beautiful-dnd": "^13.0.0", "@types/react-csv": "^1.1.3", "@types/react-dom": "^18.2.18", "@types/react-redux": "~7.1.7", @@ -169,10 +169,9 @@ "@types/zxcvbn": "^4.4.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@4tw/cypress-drag-drop": "^2.2.5", - "@vitejs/plugin-react-swc": "^3.7.0", - "@vitest/coverage-v8": "^2.1.1", - "@vitest/ui": "^2.1.1", + "@vitejs/plugin-react-swc": "^3.7.2", + "@vitest/coverage-v8": "^3.0.3", + "@vitest/ui": "^3.0.3", "chai-string": "^1.5.0", "css-mediaquery": "^0.1.2", "cypress": "13.11.0", @@ -205,7 +204,7 @@ "redux-mock-store": "^1.5.3", "storybook": "^8.4.7", "storybook-dark-mode": "4.0.1", - "vite": "^5.4.6", + "vite": "^6.0.11", "vite-plugin-svgr": "^3.2.0" }, "browserslist": [ diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 8a8628951f4..c36c851f962 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -32,7 +32,6 @@ import { switchAccountSessionContext } from './context/switchAccountSessionConte import { useIsACLPEnabled } from './features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsIAMEnabled } from './features/IAM/Shared/utilities'; -import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { useAccountSettings } from './queries/account/settings'; import { useProfile } from './queries/profile/profile'; @@ -131,7 +130,6 @@ const LinodesRoutes = React.lazy(() => default: module.LinodesRoutes, })) ); -const Images = React.lazy(() => import('src/features/Images')); const Kubernetes = React.lazy(() => import('src/features/Kubernetes').then((module) => ({ default: module.Kubernetes, @@ -179,11 +177,6 @@ const AccountActivationLanding = React.lazy( const Firewalls = React.lazy(() => import('src/features/Firewalls')); const Databases = React.lazy(() => import('src/features/Databases')); const VPC = React.lazy(() => import('src/features/VPCs')); -const PlacementGroups = React.lazy(() => - import('src/features/PlacementGroups').then((module) => ({ - default: module.PlacementGroups, - })) -); const CloudPulse = React.lazy(() => import('src/features/CloudPulse/CloudPulseLanding').then((module) => ({ @@ -229,7 +222,6 @@ export const MainContent = () => { const username = profile?.username || ''; const { isDatabasesEnabled } = useIsDatabasesEnabled(); - const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { data: accountSettings } = useAccountSettings(); const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; @@ -327,18 +319,11 @@ export const MainContent = () => { }> - {isPlacementGroupsEnabled && ( - - )} - Not Encrypted - - + + diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index 3465a79c883..d7e0f633591 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -27,6 +27,11 @@ export interface ActionMenuProps { * A function that is called when the Menu is opened. Useful for analytics. */ onOpen?: () => void; + /** + * If true, stop event propagation when handling clicks + * Ex: If the action menu is in an accordion, we don't want the click also opening/closing the accordion + */ + stopClickPropagation?: boolean; } /** @@ -35,7 +40,7 @@ export interface ActionMenuProps { * No more than 8 items should be displayed within an action menu. */ export const ActionMenu = React.memo((props: ActionMenuProps) => { - const { actionsList, ariaLabel, onOpen } = props; + const { actionsList, ariaLabel, onOpen, stopClickPropagation } = props; const menuId = convertToKebabCase(ariaLabel); const buttonId = `${convertToKebabCase(ariaLabel)}-button`; @@ -44,13 +49,19 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent) => { + if (stopClickPropagation) { + event.stopPropagation(); + } setAnchorEl(event.currentTarget); if (onOpen) { onOpen(); } }; - const handleClose = () => { + const handleClose = (event: React.MouseEvent) => { + if (stopClickPropagation) { + event.stopPropagation(); + } setAnchorEl(null); }; @@ -131,11 +142,14 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { > {actionsList.map((a, idx) => ( { + onClick={(e) => { if (!a.disabled) { - handleClose(); + handleClose(e); a.onClick(); } + if (stopClickPropagation) { + e.stopPropagation(); + } }} data-qa-action-menu-item={a.title} data-testid={a.title} diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx index bd844227670..4c2fa94d1db 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx @@ -9,7 +9,7 @@ import { CollapsibleRow } from './CollapsibleRow'; export interface TableItem { InnerTable: JSX.Element; OuterTableCells: JSX.Element; - id: number; + id: number | string; label: string; } diff --git a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.test.tsx b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.test.tsx index 608446ae196..93a0464762f 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.test.tsx +++ b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.test.tsx @@ -1,7 +1,11 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; -import { firewallFactory, firewallTemplateFactory } from 'src/factories'; +import { + firewallFactory, + firewallRuleFactory, + firewallTemplateFactory, +} from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -11,7 +15,23 @@ import { GenerateFirewallDialog } from './GenerateFirewallDialog'; describe('GenerateFirewallButton', () => { it('Can successfully generate a firewall', async () => { const firewalls = firewallFactory.buildList(2); - const template = firewallTemplateFactory.build(); + const template = firewallTemplateFactory.build({ + rules: { + // due to an updated firewallTemplateFactory, we need to specify values for this test + inbound: [ + firewallRuleFactory.build({ + description: 'firewall-rule-1 description', + label: 'firewall-rule-1', + }), + ], + outbound: [ + firewallRuleFactory.build({ + description: 'firewall-rule-2 description', + label: 'firewall-rule-2', + }), + ], + }, + }); const createFirewallCallback = vi.fn(); const onClose = vi.fn(); const onFirewallGenerated = vi.fn(); @@ -55,14 +75,14 @@ describe('GenerateFirewallButton', () => { expect(onFirewallGenerated).toHaveBeenCalledWith( expect.objectContaining({ label: `${template.slug}-1`, - rules: template.rules, + rules: { ...template.rules, fingerprint: '8a545843', version: 1 }, }) ); expect(createFirewallCallback).toHaveBeenCalledWith( expect.objectContaining({ label: `${template.slug}-1`, - rules: template.rules, + rules: { ...template.rules, fingerprint: '8a545843', version: 1 }, }) ); }); diff --git a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts index 814ff4bac84..434c4f04cd0 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts +++ b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts @@ -4,13 +4,17 @@ import { firewallQueries } from 'src/queries/firewalls'; import { useCreateFirewall } from 'src/queries/firewalls'; import type { DialogState } from './GenerateFirewallDialog'; -import type { CreateFirewallPayload, Firewall } from '@linode/api-v4'; +import type { + CreateFirewallPayload, + Firewall, + FirewallTemplateSlug, +} from '@linode/api-v4'; import type { QueryClient } from '@tanstack/react-query'; export const useCreateFirewallFromTemplate = (options: { onFirewallGenerated?: (firewall: Firewall) => void; setDialogState: (state: DialogState) => void; - templateSlug: string; + templateSlug: FirewallTemplateSlug; }) => { const { onFirewallGenerated, setDialogState, templateSlug } = options; const queryClient = useQueryClient(); @@ -44,7 +48,7 @@ export const useCreateFirewallFromTemplate = (options: { const createFirewallFromTemplate = async (options: { createFirewall: (firewall: CreateFirewallPayload) => Promise; queryClient: QueryClient; - templateSlug: string; + templateSlug: FirewallTemplateSlug; updateProgress: (progress: number | undefined) => void; }): Promise => { const { createFirewall, queryClient, templateSlug, updateProgress } = options; @@ -66,7 +70,7 @@ const createFirewallFromTemplate = async (options: { }; const getUniqueFirewallLabel = ( - templateSlug: string, + templateSlug: FirewallTemplateSlug, firewalls: Firewall[] ) => { let iterator = 1; @@ -79,7 +83,10 @@ const getUniqueFirewallLabel = ( return firewallLabelFromSlug(templateSlug, iterator); }; -const firewallLabelFromSlug = (slug: string, iterator: number) => { +const firewallLabelFromSlug = ( + slug: FirewallTemplateSlug, + iterator: number +) => { const MAX_LABEL_LENGTH = 32; const iteratorSuffix = `-${iterator}`; return ( diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.tsx index 701022c458d..4560d7e00ca 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.tsx @@ -15,7 +15,7 @@ export interface LandingHeaderProps { analyticsLabel?: string; betaFeedbackLink?: string; breadcrumbDataAttrs?: { [key: string]: boolean }; - breadcrumbProps?: BreadcrumbProps; + breadcrumbProps?: Partial; buttonDataAttrs?: { [key: string]: boolean | string }; createButtonText?: string; disabledBreadcrumbEditButton?: boolean; diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index 82b5a2a9801..82fbe939791 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -1,5 +1,5 @@ import { DateTime } from 'luxon'; -import { equals, pathOr, sort } from 'ramda'; +import { equals, sort } from 'ramda'; import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; @@ -9,6 +9,7 @@ import { useMutatePreferences, usePreferences, } from 'src/queries/profile/preferences'; +import { pathOr } from 'src/utilities/pathOr'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { sortByArrayLength, @@ -138,8 +139,16 @@ export const sortData = (orderBy: string, order: Order) => { } /** basically, if orderByProp exists, do a pathOr with that instead */ - const aValue = pathOr('', !!orderByProp ? orderByProp : [orderBy], a); - const bValue = pathOr('', !!orderByProp ? orderByProp : [orderBy], b); + const aValue = pathOr( + '', + !!orderByProp ? orderByProp : [orderBy], + a + ); + const bValue = pathOr( + '', + !!orderByProp ? orderByProp : [orderBy], + b + ); if (Array.isArray(aValue) && Array.isArray(bValue)) { return sortByArrayLength(aValue, bValue, order); diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index aa43f1935d0..312b9cbdc0a 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -39,6 +39,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { errorText, forcefullyShownRegionIds, helperText, + ignoreAccountAvailability, isClearable, label, onChange, @@ -54,7 +55,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { const { data: accountAvailability, isLoading: accountAvailabilityLoading, - } = useAllAccountAvailabilitiesQuery(); + } = useAllAccountAvailabilitiesQuery(!ignoreAccountAvailability); const regionOptions = getRegionOptions({ currentCapability, @@ -77,6 +78,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { acc[region.id] = disabledRegionsFromProps[region.id]; } if ( + !ignoreAccountAvailability && isRegionOptionUnavailable({ accountAvailabilityData: accountAvailability, currentCapability, diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 2621f2921a4..a3290d0091d 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -78,6 +78,11 @@ export interface RegionMultiSelectProps */ forcefullyShownRegionIds?: Set; helperText?: string; + /** + * Ignores account availability information when rendering region options + * @default false + */ + ignoreAccountAvailability?: boolean; isClearable?: boolean; label?: string; onChange: (ids: string[]) => void; diff --git a/packages/manager/src/components/StackScript/StackScript.tsx b/packages/manager/src/components/StackScript/StackScript.tsx index 7ad79f8138c..aac786a306a 100644 --- a/packages/manager/src/components/StackScript/StackScript.tsx +++ b/packages/manager/src/components/StackScript/StackScript.tsx @@ -72,12 +72,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ fontSize: '1rem', marginTop: theme.spacing(1), }, - root: { - '.detailsWrapper &': { - padding: theme.spacing(4), - }, - backgroundColor: theme.bg.bgPaper, - }, })); export interface StackScriptProps { @@ -160,7 +154,7 @@ export const StackScript = React.memo((props: StackScriptProps) => { : `/stackscripts/community?${queryString}`; return ( -
+
( const foundClient = longviewStats[supplyClientID(ownProps)]; return { - longviewClientData: pathOr({}, ['data'], foundClient), + longviewClientData: foundClient?.data ?? {}, longviewClientDataError: path(['error'], foundClient), - longviewClientDataLoading: pathOr(true, ['loading'], foundClient), + longviewClientDataLoading: foundClient?.loading ?? true, longviewClientLastUpdated: path(['lastUpdated'], foundClient), }; }, diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index d1cf640975b..88aacc04163 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -24,10 +24,9 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'blockStorageEncryption', label: 'Block Storage Encryption (BSE)' }, { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, { flag: 'gecko2', label: 'Gecko' }, - { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, - { flag: 'imageServiceGen2Ga', label: 'Image Service Gen2 GA' }, { flag: 'limitsEvolution', label: 'Limits Evolution' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, + { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, { flag: 'lkeEnterprise', label: 'LKE-Enterprise' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts index 563a50e194d..71c233fd23c 100644 --- a/packages/manager/src/dev-tools/load.ts +++ b/packages/manager/src/dev-tools/load.ts @@ -92,6 +92,10 @@ export async function loadDevTools( ...initialContext.eventQueue, ...(seedContext?.eventQueue || []), ], + firewallDevices: [ + ...initialContext.firewallDevices, + ...(seedContext?.firewallDevices || []), + ], firewalls: [ ...initialContext.firewalls, ...(seedContext?.firewalls || []), diff --git a/packages/manager/src/factories/accountAgreements.ts b/packages/manager/src/factories/accountAgreements.ts index e14f1db920f..6a0babe12f3 100644 --- a/packages/manager/src/factories/accountAgreements.ts +++ b/packages/manager/src/factories/accountAgreements.ts @@ -1,7 +1,9 @@ -import { Agreements } from '@linode/api-v4/lib/account'; import Factory from 'src/factories/factoryProxy'; +import type { Agreements } from '@linode/api-v4/lib/account'; + export const accountAgreementsFactory = Factory.Sync.makeFactory({ + billing_agreement: false, eu_model: false, privacy_policy: true, }); diff --git a/packages/manager/src/factories/accountPermissions.ts b/packages/manager/src/factories/accountPermissions.ts index dd74ffa3b8a..b7005845fb7 100644 --- a/packages/manager/src/factories/accountPermissions.ts +++ b/packages/manager/src/factories/accountPermissions.ts @@ -1,6 +1,58 @@ import Factory from 'src/factories/factoryProxy'; -import type { IamAccountPermissions } from '@linode/api-v4'; +import type { + IamAccess, + IamAccountPermissions, + PermissionType, +} from '@linode/api-v4'; + +interface CreateResourceRoles { + accountAdmin?: string[]; + admin?: string[]; + contributor?: string[]; + creator?: string[]; + viewer?: string[]; +} + +const createResourceRoles = ( + resourceType: string, + { + accountAdmin = [], + admin = [], + contributor = [], + creator = [], + viewer = [], + }: CreateResourceRoles +) => ({ + resource_type: resourceType, + roles: [ + accountAdmin.length && { + description: `Access to perform any supported action on all ${resourceType} instances`, + name: `account_${resourceType}_admin`, + permissions: accountAdmin as PermissionType[], + }, + admin.length && { + description: `Access to administer a ${resourceType} instance`, + name: `${resourceType}_admin`, + permissions: admin as PermissionType[], + }, + contributor.length && { + description: `Access to update a ${resourceType} instance`, + name: `${resourceType}_contributor`, + permissions: contributor as PermissionType[], + }, + creator.length && { + description: `Access to create a ${resourceType} instance`, + name: `${resourceType}_creator`, + permissions: creator as PermissionType[], + }, + viewer.length && { + description: `Access to view a ${resourceType} instance`, + name: `${resourceType}_viewer`, + permissions: viewer as PermissionType[], + }, + ], +}); export const accountPermissionsFactory = Factory.Sync.makeFactory( { @@ -8,63 +60,654 @@ export const accountPermissionsFactory = Factory.Sync.makeFactory( { backups_enabled: false, + interfaces_for_new_linodes: 'legacy_config', longview_subscription: null, managed: false, network_helper: false, diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index 58669164b9e..cebee47bf9f 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -28,7 +28,20 @@ export const alertRulesFactory = Factory.Sync.makeFactory({ - channels: [], + channels: [ + { + id: '1', + label: 'sample1', + type: 'channel', + url: '', + }, + { + id: '2', + label: 'sample2', + type: 'channel', + url: '', + }, + ], created: new Date().toISOString(), created_by: 'user1', description: 'Test description', diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 715007bb1ef..35376a722a1 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -20,6 +20,8 @@ export const possibleStatuses: DatabaseStatus[] = [ 'active', 'degraded', 'failed', + 'migrating', + 'migrated', 'provisioning', 'resizing', 'restoring', diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index dfd962df981..91381702e18 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -6,7 +6,9 @@ import type { FirewallDeviceEntityType, FirewallRuleType, FirewallRules, + FirewallSettings, FirewallTemplate, + FirewallTemplateRules, } from '@linode/api-v4/lib/firewalls/types'; export const firewallRuleFactory = Factory.Sync.makeFactory({ @@ -22,12 +24,23 @@ export const firewallRuleFactory = Factory.Sync.makeFactory({ }); export const firewallRulesFactory = Factory.Sync.makeFactory({ + fingerprint: '8a545843', inbound: firewallRuleFactory.buildList(1), inbound_policy: 'DROP', outbound: firewallRuleFactory.buildList(1), outbound_policy: 'ACCEPT', + version: 1, }); +export const firewallTemplateRulesFactory = Factory.Sync.makeFactory( + { + inbound: firewallRuleFactory.buildList(1), + inbound_policy: 'DROP', + outbound: firewallRuleFactory.buildList(1), + outbound_policy: 'ACCEPT', + } +); + export const firewallFactory = Factory.Sync.makeFactory({ created: '2020-01-01 00:00:00', entities: [ @@ -60,7 +73,18 @@ export const firewallDeviceFactory = Factory.Sync.makeFactory({ export const firewallTemplateFactory = Factory.Sync.makeFactory( { - rules: firewallRulesFactory.build(), - slug: Factory.each((i) => `template-${i}`), + rules: firewallTemplateRulesFactory.build(), + slug: 'akamai-non-prod', + } +); + +export const firewallSettingsFactory = Factory.Sync.makeFactory( + { + default_firewall_ids: { + linode: 1, + nodebalancer: 1, + public_interface: 1, + vpc_interface: 1, + }, } ); diff --git a/packages/manager/src/factories/kubernetesCluster.ts b/packages/manager/src/factories/kubernetesCluster.ts index 517398b703b..34f06196e27 100644 --- a/packages/manager/src/factories/kubernetesCluster.ts +++ b/packages/manager/src/factories/kubernetesCluster.ts @@ -29,8 +29,16 @@ export const nodePoolFactory = Factory.Sync.makeFactory({ count: 3, disk_encryption: 'enabled', id: Factory.each((id) => id), + labels: {}, nodes: kubeLinodeFactory.buildList(3), tags: [], + taints: [ + { + effect: 'NoExecute', + key: 'example.com/my-app', + value: 'my-taint', + }, + ], type: 'g6-standard-1', }); diff --git a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts index 4f041afec55..ce6d4aedd42 100644 --- a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts +++ b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts @@ -1,6 +1,7 @@ -import { Interface } from '@linode/api-v4/lib/linodes/types'; import Factory from 'src/factories/factoryProxy'; +import type { Interface } from '@linode/api-v4/lib/linodes/types'; + export const LinodeConfigInterfaceFactory = Factory.Sync.makeFactory( { active: false, diff --git a/packages/manager/src/factories/linodeInterface.ts b/packages/manager/src/factories/linodeInterface.ts new file mode 100644 index 00000000000..707822435dc --- /dev/null +++ b/packages/manager/src/factories/linodeInterface.ts @@ -0,0 +1,81 @@ +// Factories for the new Linode Interfaces type + +import Factory from 'src/factories/factoryProxy'; + +import type { LinodeInterface } from '@linode/api-v4'; + +export const linodeInterfaceFactoryVlan = Factory.Sync.makeFactory( + { + created: '2020-01-01 00:00:00', + default_route: { + ipv4: true, + }, + id: Factory.each((i) => i), + mac_address: 'a4:ac:39:b7:6e:42', + public: null, + updated: '2020-01-01 00:00:00', + version: 1, + vlan: { + ipam_address: '192.168.0.1', + vlan_label: 'vlan-interface', + }, + vpc: null, + } +); + +export const linodeInterfaceFactoryVPC = Factory.Sync.makeFactory( + { + created: '2020-01-01 00:00:00', + default_route: { + ipv4: true, + }, + id: Factory.each((i) => i), + mac_address: 'a4:ac:39:b7:6e:42', + public: null, + updated: '2020-01-01 00:00:00', + version: 1, + vlan: null, + vpc: { + ipv4: { + addresses: [ + { + address: '10.0.0.0', + primary: true, + }, + ], + ranges: [], + }, + subnet_id: 1, + vpc_id: 1, + }, + } +); + +export const linodeInterfaceFactoryPublic = Factory.Sync.makeFactory( + { + created: '2020-01-01 00:00:00', + default_route: { + ipv4: true, + }, + id: Factory.each((i) => i), + mac_address: 'a4:ac:39:b7:6e:42', + public: { + ipv4: { + addresses: [ + { + address: '10.0.0.0', + primary: true, + }, + ], + }, + ipv6: { + addresses: [], + ranges: [], + }, + }, + updated: '2020-01-01 00:00:00', + version: 1, + vlan: null, + vpc: null, + } +); diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index b6c0e921ccf..2774bbbffc8 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -71,6 +71,7 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ { address: '10.11.12.13', gateway: '10.11.12.13', + interface_id: null, linode_id: 1, prefix: 24, public: true, @@ -95,6 +96,7 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ link_local: { address: '2001:DB8::0000', gateway: 'fe80::1', + interface_id: null, linode_id: 1, prefix: 64, public: false, @@ -106,6 +108,7 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ slaac: { address: '2001:DB8::0000', gateway: 'fe80::1', + interface_id: null, linode_id: 1, prefix: 64, public: true, @@ -288,6 +291,7 @@ export const linodeFactory = Factory.Sync.makeFactory({ hypervisor: 'kvm', id: Factory.each((i) => i), image: 'linode/debian12', + interface_generation: 'legacy_config', ipv4: ['50.116.6.212', '192.168.203.1'], ipv6: '2600:3c00::f03c:92ff:fee2:6c40/64', label: Factory.each((i) => `linode-${i}`), diff --git a/packages/manager/src/factories/networking.ts b/packages/manager/src/factories/networking.ts index 74a29840383..157a1579930 100644 --- a/packages/manager/src/factories/networking.ts +++ b/packages/manager/src/factories/networking.ts @@ -1,9 +1,11 @@ -import { IPAddress } from '@linode/api-v4/lib/networking'; import Factory from 'src/factories/factoryProxy'; +import type { IPAddress } from '@linode/api-v4/lib/networking'; + export const ipAddressFactory = Factory.Sync.makeFactory({ address: Factory.each((id) => `192.168.1.${id}`), gateway: Factory.each((id) => `192.168.1.${id + 1}`), + interface_id: Factory.each((id) => id), linode_id: Factory.each((id) => id), prefix: 24, public: true, diff --git a/packages/manager/src/factories/quotas.ts b/packages/manager/src/factories/quotas.ts index 739c62ccc36..1f40db6c0b9 100644 --- a/packages/manager/src/factories/quotas.ts +++ b/packages/manager/src/factories/quotas.ts @@ -1,6 +1,6 @@ import Factory from 'src/factories/factoryProxy'; -import type { Quota } from '@linode/api-v4/lib/quotas/types'; +import type { Quota, QuotaUsage } from '@linode/api-v4/lib/quotas/types'; export const quotaFactory = Factory.Sync.makeFactory({ description: 'Maximimum number of vCPUs allowed', @@ -9,5 +9,9 @@ export const quotaFactory = Factory.Sync.makeFactory({ quota_name: 'Linode Dedicated vCPUs', region_applied: 'us-east', resource_metric: 'CPU', +}); + +export const quotaUsageFactory = Factory.Sync.makeFactory({ + quota_limit: 50, used: 25, }); diff --git a/packages/manager/src/factories/subnets.ts b/packages/manager/src/factories/subnets.ts index c2c611b4de5..2d9160b73af 100644 --- a/packages/manager/src/factories/subnets.ts +++ b/packages/manager/src/factories/subnets.ts @@ -11,10 +11,13 @@ import type { export const subnetAssignedLinodeDataFactory = Factory.Sync.makeFactory( { id: Factory.each((i) => i), - interfaces: Array.from({ length: 5 }, () => ({ - active: false, - id: Math.floor(Math.random() * 100), - })), + interfaces: Factory.each((i) => + Array.from({ length: 5 }, (_, arrIdx) => ({ + active: false, + config_id: i * 10 + arrIdx, + id: i * 10 + arrIdx, + })) + ), } ); @@ -23,10 +26,12 @@ export const subnetFactory = Factory.Sync.makeFactory({ id: Factory.each((i) => i), ipv4: '0.0.0.0/0', label: Factory.each((i) => `subnet-${i}`), - linodes: Array.from({ length: 5 }, () => - subnetAssignedLinodeDataFactory.build({ - id: Math.floor(Math.random() * 100), - }) + linodes: Factory.each((i) => + Array.from({ length: 5 }, (_, arrIdx) => + subnetAssignedLinodeDataFactory.build({ + id: i * 10 + arrIdx, + }) + ) ), updated: '2023-07-12T16:08:53', }); diff --git a/packages/manager/src/factories/userPermissions.ts b/packages/manager/src/factories/userPermissions.ts index eadd1c31b79..ec66e13f35e 100644 --- a/packages/manager/src/factories/userPermissions.ts +++ b/packages/manager/src/factories/userPermissions.ts @@ -1,12 +1,14 @@ -import { IamUserPermissions } from '@linode/api-v4'; import Factory from 'src/factories/factoryProxy'; +import type { IamUserPermissions } from '@linode/api-v4'; + export const userPermissionsFactory = Factory.Sync.makeFactory( { account_access: [ 'account_linode_admin', 'linode_creator', 'firewall_creator', + 'account_admin', ], resource_access: [ { @@ -15,9 +17,9 @@ export const userPermissionsFactory = Factory.Sync.makeFactory default: module.UsersLanding, })) ); +const Quotas = React.lazy(() => + import('./Quotas').then((module) => ({ default: module.Quotas })) +); const GlobalSettings = React.lazy(() => import('./GlobalSettings')); const MaintenanceLanding = React.lazy( () => import('./Maintenance/MaintenanceLanding') @@ -50,6 +54,7 @@ const AccountLanding = () => { const location = useLocation(); const { data: account } = useAccount(); const { data: profile } = useProfile(); + const { limitsEvolution } = useFlags(); const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const sessionContext = React.useContext(switchAccountSessionContext); @@ -59,6 +64,8 @@ const AccountLanding = () => { const isChildUser = profile?.user_type === 'child'; const isParentUser = profile?.user_type === 'parent'; + const showQuotasTab = limitsEvolution?.enabled ?? false; + const isReadOnly = useRestrictedGlobalGrantCheck({ globalGrantType: 'account_access', @@ -80,6 +87,14 @@ const AccountLanding = () => { routeName: '/account/users', title: 'Users & Grants', }, + ...(showQuotasTab + ? [ + { + routeName: '/account/quotas', + title: 'Quotas', + }, + ] + : []), { routeName: '/account/login-history', title: 'Login History', @@ -193,6 +208,11 @@ const AccountLanding = () => { + {showQuotasTab && ( + + + + )} diff --git a/packages/manager/src/features/Account/Quotas.tsx b/packages/manager/src/features/Account/Quotas.tsx new file mode 100644 index 00000000000..ef2c78c8930 --- /dev/null +++ b/packages/manager/src/features/Account/Quotas.tsx @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Autocomplete, Divider, Paper, Stack, Typography } from '@linode/ui'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { DocsLink } from 'src/components/DocsLink/DocsLink'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; + +import type { Theme } from '@mui/material'; + +export const Quotas = () => { + // @ts-expect-error TODO: this is a placeholder to be replaced with the actual query + const [lastUpdatedDate, setLastUpdatedDate] = React.useState(Date.now()); + + return ( + <> + + ({ + marginTop: theme.spacing(2), + })} + variant="outlined" + > + }> + + + + + + Quotas + + + Last updated:{' '} + + + + {/* TODO: update once link is available */} + + + + + + + ); +}; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx index 74bde43185d..9b91a46fe90 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -1,4 +1,4 @@ -import { Notice, TextField } from '@linode/ui'; +import { Checkbox, Notice, TextField, Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import { allCountries } from 'country-region-data'; import { useFormik } from 'formik'; @@ -7,13 +7,19 @@ import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import EnhancedSelect from 'src/components/EnhancedSelect/Select'; +import { Link } from 'src/components/Link'; +import { reportException } from 'src/exceptionReporting'; import { getRestrictedResourceText, useIsTaxIdEnabled, } from 'src/features/Account/utils'; -import { TAX_ID_HELPER_TEXT } from 'src/features/Billing/constants'; +import { + TAX_ID_AGREEMENT_TEXT, + TAX_ID_HELPER_TEXT, +} from 'src/features/Billing/constants'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount, useMutateAccount } from 'src/queries/account/account'; +import { useMutateAccountAgreements } from 'src/queries/account/agreements'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -31,9 +37,13 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { const { data: account } = useAccount(); const { error, isPending, mutateAsync } = useMutateAccount(); const { data: notifications, refetch } = useNotificationsQuery(); + const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { classes } = useStyles(); const emailRef = React.useRef(); const { data: profile } = useProfile(); + const [billingAgreementChecked, setBillingAgreementChecked] = React.useState( + false + ); const { isTaxIdEnabled } = useIsTaxIdEnabled(); const isChildUser = profile?.user_type === 'child'; const isParentUser = profile?.user_type === 'parent'; @@ -69,6 +79,24 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { await mutateAsync(clonedValues); + if (billingAgreementChecked) { + try { + await updateAccountAgreements({ billing_agreement: true }); + } catch (error) { + let customErrorMessage = + 'Expected to sign billing agreement, but the request resulted in an error'; + const apiErrorMessage = error?.[0]?.reason; + + if (apiErrorMessage) { + customErrorMessage += `: ${apiErrorMessage}`; + } + + reportException(error, { + message: customErrorMessage, + }); + } + } + // If there's a "billing_email_bounce" notification on the account, and // the user has just updated their email, re-request notifications to // potentially clear the email bounce notification. @@ -122,14 +150,14 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { * - region[0] is the readable name of the region (e.g. "Alabama") * - region[1] is the ISO 3166-2 code of the region (e.g. "AL") */ - const countryResults: Item[] = allCountries.map((country) => { + const countryResults: Item[] = (allCountries || []).map((country) => { return { label: country[0], value: country[1], }; }); - const currentCountryResult = allCountries.filter((country) => + const currentCountryResult = (allCountries || []).filter((country) => formik.values.country ? country[1] === formik.values.country : country[1] === account?.country @@ -177,6 +205,8 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { formik.setFieldValue('tax_id', ''); }; + const nonUSCountry = isTaxIdEnabled && formik.values.country !== 'US'; + return (
{ + {nonUSCountry && ( + theme.tokens.spacing[60]} + xs={12} + > + + setBillingAgreementChecked(!billingAgreementChecked) + } + sx={(theme) => ({ + marginRight: theme.tokens.spacing[40], + padding: 0, + })} + checked={billingAgreementChecked} + data-testid="tax-id-checkbox" + id="taxIdAgreementCheckbox" + /> + + {TAX_ID_AGREEMENT_TEXT}{' '} + + Akamai Privacy Statement. + + + + )} { if (isVolume) { const [volLabel, volID] = descChunks[1].split(' '); - return `${descChunks[0]}\r\n${truncateLabel(volLabel)} ${pathOr( - '', - [2], - descChunks - )}\r\n${volID}`; + return `${descChunks[0]}\r\n${truncateLabel(volLabel)} ${ + descChunks?.[2] ?? '' + }\r\n${volID}`; } if (isBackup) { diff --git a/packages/manager/src/features/Billing/constants.ts b/packages/manager/src/features/Billing/constants.ts index e77dda6fbb6..ae6bc59b9f5 100644 --- a/packages/manager/src/features/Billing/constants.ts +++ b/packages/manager/src/features/Billing/constants.ts @@ -2,3 +2,5 @@ export const ADD_PAYMENT_METHOD = 'Add Payment Method'; export const EDIT_BILLING_CONTACT = 'Edit'; export const TAX_ID_HELPER_TEXT = 'Tax Identification Numbers (TIN) are set by the national authorities and they have different names in different countries. Enter a TIN valid for the country of your billing address. It will be validated.'; +export const TAX_ID_AGREEMENT_TEXT = + 'I have reviewed and confirm the accuracy of the above information. I understand that the submission of inaccurate information will lead to billing errors and may result in our assessment of additional fees to your account. Information shared with Akamai is subject to the'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx index 27fa75df515..d4d7361a50d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { alertFactory, linodeFactory, + notificationChannelFactory, regionFactory, serviceTypesFactory, } from 'src/factories/'; @@ -12,6 +13,7 @@ import { AlertDetail } from './AlertDetail'; // Mock Data const alertDetails = alertFactory.build({ service_type: 'linode' }); +const notificationChannels = notificationChannelFactory.buildList(3); const linodes = linodeFactory.buildList(3); const regions = regionFactory.buildList(3); @@ -19,6 +21,7 @@ const regions = regionFactory.buildList(3); // Mock Queries const queryMocks = vi.hoisted(() => ({ useAlertDefinitionQuery: vi.fn(), + useAllAlertNotificationChannelsQuery: vi.fn(), useCloudPulseServiceTypes: vi.fn(), useRegionsQuery: vi.fn(), useResourcesQuery: vi.fn(), @@ -27,6 +30,8 @@ const queryMocks = vi.hoisted(() => ({ vi.mock('src/queries/cloudpulse/alerts', () => ({ ...vi.importActual('src/queries/cloudpulse/alerts'), useAlertDefinitionQuery: queryMocks.useAlertDefinitionQuery, + useAllAlertNotificationChannelsQuery: + queryMocks.useAllAlertNotificationChannelsQuery, })); vi.mock('src/queries/cloudpulse/services', () => { @@ -67,6 +72,11 @@ beforeEach(() => { isError: false, isFetching: false, }); + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: notificationChannels, + isError: false, + isFetching: false, + }); }); describe('AlertDetail component tests', () => { @@ -113,6 +123,7 @@ describe('AlertDetail component tests', () => { expect(getByText('Overview')).toBeInTheDocument(); expect(getByText('Criteria')).toBeInTheDocument(); // validate if criteria is present expect(getByText('Resources')).toBeInTheDocument(); // validate if resources is present + expect(getByText('Notification Channels')).toBeInTheDocument(); // validate if notification channels is present expect(getByText('Name:')).toBeInTheDocument(); expect(getByText('Description:')).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx index 3b81a48bdf2..9e7c723b3e4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx @@ -12,9 +12,10 @@ import { useAlertDefinitionQuery } from 'src/queries/cloudpulse/alerts'; import { AlertResources } from '../AlertsResources/AlertsResources'; import { getAlertBoxStyles } from '../Utils/utils'; import { AlertDetailCriteria } from './AlertDetailCriteria'; +import { AlertDetailNotification } from './AlertDetailNotification'; import { AlertDetailOverview } from './AlertDetailOverview'; -interface RouteParams { +export interface AlertRouteParams { /** * The id of the alert for which the data needs to be shown */ @@ -26,7 +27,7 @@ interface RouteParams { } export const AlertDetail = () => { - const { alertId, serviceType } = useParams(); + const { alertId, serviceType } = useParams(); const { data: alertDetails, isError, isFetching } = useAlertDefinitionQuery( Number(alertId), @@ -96,6 +97,7 @@ export const AlertDetail = () => { { ...getAlertBoxStyles(theme), overflow: 'auto', }} + data-qa-section="Criteria" flexBasis="50%" maxHeight={sectionMaxHeight} > @@ -124,6 +127,16 @@ export const AlertDetail = () => { serviceType={serviceType} /> + + id)} + /> + ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx index 73af8b4e528..e16e8f95061 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx @@ -31,25 +31,33 @@ export const AlertDetailCriteria = React.memo((props: CriteriaProps) => { () => ( <> - + Trigger Alert When: - + criteria are met for - + consecutive occurrences. diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.test.tsx new file mode 100644 index 00000000000..3fde1ab5981 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertDetailNotification } from './AlertDetailNotification'; + +const notificationChannels = notificationChannelFactory.buildList(3, { + content: { + email: { + email_addresses: ['1@test.com', '2@test.com'], + }, + }, +}); + +const queryMocks = vi.hoisted(() => ({ + useAllAlertNotificationChannelsQuery: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/alerts', () => ({ + ...vi.importActual('src/queries/cloudpulse/alerts'), + useAllAlertNotificationChannelsQuery: + queryMocks.useAllAlertNotificationChannelsQuery, +})); + +const notificationChannel = 'Notification Channels'; +const errorText = 'Failed to load notification channels.'; +const noDataText = 'No notification channels to display.'; + +beforeEach(() => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: notificationChannels, + isError: false, + isFetching: false, + }); +}); + +describe('AlertDetailNotification component tests', () => { + it('should render the alert detail notification channels successfully', () => { + const { getAllByText, getByText } = renderWithTheme( + + ); + + expect(getByText(notificationChannel)).toBeInTheDocument(); + expect(getAllByText('Email').length).toBe(notificationChannels.length); + expect(getAllByText('1@test.com').length).toBe(notificationChannels.length); + expect(getAllByText('2@test.com').length).toBe(notificationChannels.length); + + notificationChannels.forEach((channel) => { + expect(getByText(channel.label)).toBeInTheDocument(); + }); + }); + + it('should render the error state if api throws error', () => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: makeResourcePage(notificationChannels), + isError: true, + isFetching: false, + }); + const { getByText } = renderWithTheme( + + ); + + expect(getByText(notificationChannel)).toBeInTheDocument(); + expect(getByText(errorText)).toBeInTheDocument(); + }); + + it('should render the no details message if api returns empty response', () => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: [], + isError: false, + isFetching: false, + }); + const { getByText } = renderWithTheme( + + ); + + expect(getByText(notificationChannel)).toBeInTheDocument(); + expect(getByText(noDataText)).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.tsx new file mode 100644 index 00000000000..f9065ef4527 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailNotification.tsx @@ -0,0 +1,111 @@ +import { CircleProgress, Stack, Typography } from '@linode/ui'; +import { Divider, Grid } from '@mui/material'; +import React from 'react'; + +import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { useAllAlertNotificationChannelsQuery } from 'src/queries/cloudpulse/alerts'; + +import { convertStringToCamelCasesWithSpaces } from '../../Utils/utils'; +import { getChipLabels } from '../Utils/utils'; +import { StyledPlaceholder } from './AlertDetail'; +import { AlertDetailRow } from './AlertDetailRow'; +import { DisplayAlertDetailChips } from './DisplayAlertDetailChips'; + +import type { Filter } from '@linode/api-v4'; + +interface NotificationChannelProps { + /** + * List of channel IDs associated with the alert. + * These IDs are used to fetch and display notification channels. + */ + channelIds: string[]; +} +export const AlertDetailNotification = React.memo( + (props: NotificationChannelProps) => { + const { channelIds } = props; + + // Construct filter for API request based on channel IDs + const channelIdOrFilter: Filter = { + '+or': channelIds.map((id) => ({ id })), + }; + + const { + data: channels, + isError, + isFetching, + } = useAllAlertNotificationChannelsQuery({}, channelIdOrFilter); + + // Handle loading, error, and empty state scenarios + if (isFetching) { + return getAlertNotificationMessage(); + } + if (isError) { + return getAlertNotificationMessage( + + ); + } + if (!channels?.length) { + return getAlertNotificationMessage( + + ); + } + + return ( + + + Notification Channels + + + {channels.map((notificationChannel, index) => { + const { channel_type, id, label } = notificationChannel; + return ( + + + + + + + {channels.length > 1 && index !== channels.length - 1 && ( + + + + )} + + ); + })} + + + ); + } +); + +/** + * Returns a common UI structure for loading, error, or empty states. + * @param content - A React component to display (e.g., CircleProgress, ErrorState, or Placeholder). + */ +const getAlertNotificationMessage = (messageComponent: React.ReactNode) => { + return ( + + Notification Channels + {messageComponent} + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx index 02d6c63af0b..0c58a037156 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx @@ -45,7 +45,7 @@ export const AlertDetailRow = React.memo((props: AlertDetailRowProps) => { const theme = useTheme(); return ( - + {label}: diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx index c3fff1256ed..e7fc2efd492 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx @@ -49,7 +49,7 @@ export const DisplayAlertDetailChips = React.memo( : []; const theme = useTheme(); return ( - + {chipValues.map((value, index) => ( @@ -78,6 +78,7 @@ export const DisplayAlertDetailChips = React.memo( length: value.length, mergeChips, })} + data-qa-chip={label} label={label} variant="outlined" /> diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx index e9743f733bb..17422740458 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx @@ -4,6 +4,7 @@ import { Route, Switch } from 'react-router-dom'; import { AlertDetail } from '../AlertsDetail/AlertDetail'; import { AlertListing } from '../AlertsListing/AlertListing'; import { CreateAlertDefinition } from '../CreateAlert/CreateAlertDefinition'; +import { EditAlertResources } from '../EditAlert/EditAlertResources'; export const AlertDefinitionLanding = () => { return ( @@ -19,6 +20,12 @@ export const AlertDefinitionLanding = () => { > + + + { }} > - {pathname === `${url}/definitions` && ( - - - - )} void; + + /** + * Callback for edit alerts action + */ + handleEdit: () => void; } export interface AlertActionMenuProps { + /** + * The label of the alert + */ + alertLabel: string; /** * Type of the alert */ @@ -25,11 +34,11 @@ export interface AlertActionMenuProps { } export const AlertActionMenu = (props: AlertActionMenuProps) => { - const { alertType, handlers } = props; + const { alertLabel, alertType, handlers } = props; return ( ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx new file mode 100644 index 00000000000..02041cdb81a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { alertFactory } from 'src/factories'; +import { formatDate } from 'src/utilities/formatDate'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertsListTable } from './AlertListTable'; + +describe('Alert List Table test', () => { + it('should render the alert landing table ', async () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Alert Name')).toBeVisible(); + expect(getByText('Service')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Last Modified')).toBeVisible(); + expect(getByText('Created By')).toBeVisible(); + }); + + it('should render the error message', async () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Error in fetching the alerts')).toBeVisible(); + }); + + it('should render the alert row', async () => { + const updated = new Date().toISOString(); + const { getByText } = renderWithTheme( + + ); + expect(getByText('Test Alert')).toBeVisible(); + expect(getByText('Linode')).toBeVisible(); + expect(getByText('Enabled')).toBeVisible(); + expect(getByText('user1')).toBeVisible(); + expect( + getByText( + formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + }) + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx new file mode 100644 index 00000000000..312238fd5ca --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx @@ -0,0 +1,124 @@ +import { Grid, TableBody, TableHead } from '@mui/material'; +import * as React from 'react'; +import { useHistory } from 'react-router-dom'; + +import OrderBy from 'src/components/OrderBy'; +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableCell } from 'src/components/TableCell'; +import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; +import { TableRow } from 'src/components/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { AlertTableRow } from './AlertTableRow'; +import { AlertListingTableLabelMap } from './constants'; + +import type { Item } from '../constants'; +import type { APIError, Alert, AlertServiceType } from '@linode/api-v4'; + +export interface AlertsListTableProps { + /** + * The list of alerts to display + */ + alerts: Alert[]; + /** + * An error to display if there was an issue fetching the alerts + */ + error?: APIError[]; + /** + * A boolean indicating whether the alerts are loading + */ + isLoading: boolean; + /** + * The list of services to display in the table + */ + services: Item[]; +} + +export const AlertsListTable = React.memo((props: AlertsListTableProps) => { + const { alerts, error, isLoading, services } = props; + const _error = error + ? getAPIErrorOrDefault(error, 'Error in fetching the alerts.') + : undefined; + const history = useHistory(); + + const handleDetails = ({ id: _id, service_type: serviceType }: Alert) => { + history.push(`${location.pathname}/detail/${serviceType}/${_id}`); + }; + + const handleEdit = ({ id, service_type: serviceType }: Alert) => { + history.push(`${location.pathname}/edit/${serviceType}/${id}`); + }; + + return ( + + {({ data: orderedData, handleOrderChange, order, orderBy }) => ( + + {({ + count, + data: paginatedAndOrderedAlerts, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + <> + + + + + {AlertListingTableLabelMap.map((value) => ( + + {value.colName} + + ))} + + + + + + {paginatedAndOrderedAlerts?.map((alert) => ( + handleDetails(alert), + handleEdit: () => handleEdit(alert), + }} + alert={alert} + key={alert.id} + services={services} + /> + ))} + +
+
+ + + )} +
+ )} +
+ ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx index ca5f7d926c2..ca8a45108da 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.test.tsx @@ -1,13 +1,15 @@ +import { act, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { alertFactory } from 'src/factories'; +import { alertFactory } from 'src/factories/cloudpulse/alerts'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AlertListing } from './AlertListing'; const queryMocks = vi.hoisted(() => ({ useAllAlertDefinitionsQuery: vi.fn().mockReturnValue({}), + useCloudPulseServiceTypes: vi.fn().mockReturnValue({}), })); vi.mock('src/queries/cloudpulse/alerts', async () => { @@ -18,62 +20,151 @@ vi.mock('src/queries/cloudpulse/alerts', async () => { }; }); +vi.mock('src/queries/cloudpulse/services', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/services'); + return { + ...actual, + useCloudPulseServiceTypes: queryMocks.useCloudPulseServiceTypes, + }; +}); + const mockResponse = alertFactory.buildList(3); +const serviceTypes = [ + { + label: 'Databases', + service_type: 'dbaas', + }, + { + label: 'Linode', + service_type: 'linode', + }, +]; describe('Alert Listing', () => { - it('should render the error message', () => { + it('should render the alert landing table with items', async () => { queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ - data: undefined, - error: 'an error happened', - isError: true, + data: mockResponse, + isError: false, isLoading: false, + status: 'success', }); - const { getAllByText } = renderWithTheme(); - getAllByText('Error in fetching the alerts.'); + const { getByText } = renderWithTheme(); + expect(getByText('Alert Name')).toBeVisible(); + expect(getByText('Service')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Last Modified')).toBeVisible(); + expect(getByText('Created By')).toBeVisible(); }); - it('should render the alert landing table with items', () => { + it('should render the alert row', async () => { queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ data: mockResponse, isError: false, isLoading: false, status: 'success', }); - const { getAllByLabelText, getByText } = renderWithTheme(); - expect(getByText('Alert Name')).toBeInTheDocument(); - expect(getByText('Service')).toBeInTheDocument(); - expect(getByText('Status')).toBeInTheDocument(); - expect(getByText('Last Modified')).toBeInTheDocument(); - expect(getByText('Created By')).toBeInTheDocument(); - expect(getAllByLabelText('Action menu for Alert').length).toBe(3); + + const { getByText } = renderWithTheme(); + expect(getByText(mockResponse[0].label)).toBeVisible(); + expect(getByText(mockResponse[1].label)).toBeVisible(); + expect(getByText(mockResponse[2].label)).toBeVisible(); }); - it('should render the alert row', () => { + it('should filter the alerts with service filter', async () => { + const linodeAlert = alertFactory.build({ service_type: 'linode' }); + const dbaasAlert = alertFactory.build({ service_type: 'dbaas' }); queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ - data: mockResponse, + data: [linodeAlert, dbaasAlert], isError: false, isLoading: false, status: 'success', }); - const { getByText } = renderWithTheme(); - expect(getByText(mockResponse[0].label)).toBeInTheDocument(); - expect(getByText(mockResponse[1].label)).toBeInTheDocument(); - expect(getByText(mockResponse[2].label)).toBeInTheDocument(); + queryMocks.useCloudPulseServiceTypes.mockReturnValue({ + data: { data: serviceTypes }, + isError: false, + isLoading: false, + status: 'success', + }); + + const { getByRole, getByTestId, getByText, queryByText } = renderWithTheme( + + ); + const serviceFilter = getByTestId('alert-service-filter'); + expect(getByText(linodeAlert.label)).toBeVisible(); + expect(getByText(dbaasAlert.label)).toBeVisible(); + + await userEvent.click( + within(serviceFilter).getByRole('button', { name: 'Open' }) + ); + await waitFor(() => { + getByRole('option', { name: 'Databases' }); + getByRole('option', { name: 'Linode' }); + }); + await act(async () => { + await userEvent.click(getByRole('option', { name: 'Databases' })); + }); + + await waitFor(() => { + expect(queryByText(linodeAlert.label)).not.toBeInTheDocument(); + expect(getByText(dbaasAlert.label)).toBeVisible(); + }); }); - it('should have the show details action item present inside action menu', async () => { + it('should filter the alerts with status filter', async () => { + const enabledAlert = alertFactory.build({ status: 'enabled' }); + const disabledAlert = alertFactory.build({ status: 'disabled' }); queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ - data: mockResponse, + data: [enabledAlert, disabledAlert], isError: false, isLoading: false, status: 'success', }); - const { getAllByLabelText, getByTestId } = renderWithTheme( + + const { getByRole, getByTestId, getByText, queryByText } = renderWithTheme( ); - const firstActionMenu = getAllByLabelText('Action menu for Alert')[0]; - await userEvent.click(firstActionMenu); - expect(getByTestId('Show Details')).toBeInTheDocument(); + const statusFilter = getByTestId('alert-status-filter'); + expect(getByText(enabledAlert.label)).toBeVisible(); + expect(getByText(disabledAlert.label)).toBeVisible(); + + await userEvent.click( + within(statusFilter).getByRole('button', { name: 'Open' }) + ); + + await waitFor(() => { + getByRole('option', { name: 'Enabled' }); + getByRole('option', { name: 'Disabled' }); + }); + + await act(async () => { + await userEvent.click(getByRole('option', { name: 'Enabled' })); + }); + await waitFor(() => { + expect(getByText(enabledAlert.label)).toBeVisible(); + expect(queryByText(disabledAlert.label)).not.toBeInTheDocument(); + }); + }); + + it('should filter the alerts with search text', async () => { + const alert1 = alertFactory.build({ label: 'alert1' }); + const alert2 = alertFactory.build({ label: 'alert2' }); + + queryMocks.useAllAlertDefinitionsQuery.mockReturnValue({ + data: [alert1, alert2], + isError: false, + isLoading: false, + status: 'success', + }); + const { getByPlaceholderText, getByText, queryByText } = renderWithTheme( + + ); + const searchInput = getByPlaceholderText('Search for Alerts'); + await userEvent.type(searchInput, 'alert1'); + + await waitFor(() => { + expect(getByText(alert1.label)).toBeVisible(); + expect(queryByText(alert2.label)).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx index da5b9dfec2e..3911fce0b2b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx @@ -1,88 +1,239 @@ -import { Paper } from '@linode/ui'; -import { Grid } from '@mui/material'; -import React from 'react'; -import { useHistory } from 'react-router-dom'; - -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 { TableRowError } from 'src/components/TableRowError/TableRowError'; -import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; -import { TableSortCell } from 'src/components/TableSortCell'; +import { Autocomplete, Box, Button, Stack } from '@linode/ui'; +import * as React from 'react'; +import { useHistory, useRouteMatch } from 'react-router-dom'; + +import AlertsIcon from 'src/assets/icons/entityIcons/alerts.svg'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { StyledPlaceholder } from 'src/features/StackScripts/StackScriptBase/StackScriptBase.styles'; import { useAllAlertDefinitionsQuery } from 'src/queries/cloudpulse/alerts'; +import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; -import { AlertTableRow } from './AlertTableRow'; -import { AlertListingTableLabelMap } from './constants'; +import { alertStatusOptions } from '../constants'; +import { AlertsListTable } from './AlertListTable'; -import type { Alert } from '@linode/api-v4'; +import type { Item } from '../constants'; +import type { Alert, AlertServiceType, AlertStatusType } from '@linode/api-v4'; -export const AlertListing = () => { - // These are dummy order value and handleOrder methods, will replace them in the next PR - const order = 'asc'; - const handleOrderChange = () => { - return 'asc'; - }; - const { data: alerts, isError, isLoading } = useAllAlertDefinitionsQuery(); +const searchAndSelectSx = { + md: '300px', + sm: '500px', + xs: '300px', +}; +export const AlertListing = () => { + const { url } = useRouteMatch(); const history = useHistory(); + const { data: alerts, error, isLoading } = useAllAlertDefinitionsQuery(); + const { + data: serviceOptions, + error: serviceTypesError, + isLoading: serviceTypesLoading, + } = useCloudPulseServiceTypes(true); + + const getServicesList = React.useMemo((): Item< + string, + AlertServiceType + >[] => { + return serviceOptions && serviceOptions.data.length > 0 + ? serviceOptions.data.map((service) => ({ + label: service.label, + value: service.service_type as AlertServiceType, + })) + : []; + }, [serviceOptions]); + + const [searchText, setSearchText] = React.useState(''); + + const [serviceFilters, setServiceFilters] = React.useState< + Item[] + >([]); + const [statusFilters, setStatusFilters] = React.useState< + Item[] + >([]); + + const serviceFilteredAlerts = React.useMemo(() => { + if (serviceFilters && serviceFilters.length !== 0 && alerts) { + return alerts.filter((alert: Alert) => { + return serviceFilters.some( + (serviceFilter) => serviceFilter.value === alert.service_type + ); + }); + } + + return alerts; + }, [serviceFilters, alerts]); + + const statusFilteredAlerts = React.useMemo(() => { + if (statusFilters && statusFilters.length !== 0 && alerts) { + return alerts.filter((alert: Alert) => { + return statusFilters.some( + (statusFilter) => statusFilter.value === alert.status + ); + }); + } + return alerts; + }, [statusFilters, alerts]); - const handleDetails = ({ id, service_type: serviceType }: Alert) => { - history.push(`${location.pathname}/detail/${serviceType}/${id}`); - }; + const getAlertsList = React.useMemo(() => { + if (!alerts) { + return []; + } + let filteredAlerts = alerts; - if (alerts?.length === 0) { + if (serviceFilters && serviceFilters.length > 0) { + filteredAlerts = serviceFilteredAlerts ?? []; + } + + if (statusFilters && statusFilters.length > 0) { + filteredAlerts = statusFilteredAlerts ?? []; + } + + if (serviceFilters.length > 0 && statusFilters.length > 0) { + filteredAlerts = filteredAlerts.filter((alert) => { + return ( + serviceFilters.some( + (serviceFilter) => serviceFilter.value === alert.service_type + ) && + statusFilters.some( + (statusFilter) => statusFilter.value === alert.status + ) + ); + }); + } + + if (searchText) { + filteredAlerts = filteredAlerts.filter((alert: Alert) => { + return alert.label.toLowerCase().includes(searchText.toLowerCase()); + }); + } + + return filteredAlerts; + }, [ + alerts, + searchText, + serviceFilteredAlerts, + serviceFilters, + statusFilteredAlerts, + statusFilters, + ]); + + if (alerts && alerts.length == 0) { return ( - - - - - + { + history.push(`${url}/create`); + }, + }, + ]} + icon={AlertsIcon} + isEntity + renderAsSecondary + subtitle="Create alerts that notifies you of the potential issues within your systems to cut downtime and maintain the performance of your infrastructure." + title="" + /> ); } + return ( - - - - - {AlertListingTableLabelMap.map((value) => ( - - {value.colName} - - ))} - - - - - {isError && ( - - )} - {isLoading && } - {alerts?.map((alert) => ( - handleDetails(alert), - }} - alert={alert} - key={alert.id} - /> - ))} - -
-
+ + + + + { + setServiceFilters(selected); + }} + sx={{ + width: searchAndSelectSx, + }} + autoHighlight + data-qa-filter="alert-service-filter" + data-testid="alert-service-filter" + label="" + limitTags={2} + loading={serviceTypesLoading} + multiple + noMarginTop + options={getServicesList} + placeholder={serviceFilters.length > 0 ? '' : 'Select a Service'} + value={serviceFilters} + /> + { + setStatusFilters(selected); + }} + sx={{ + width: searchAndSelectSx, + }} + autoHighlight + data-qa-filter="alert-status-filter" + data-testid="alert-status-filter" + label="" + multiple + noMarginTop + options={alertStatusOptions} + placeholder={statusFilters.length > 0 ? '' : 'Select a Status'} + value={statusFilters} + /> + + + + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx index 077a498c097..31d81b05984 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -1,11 +1,27 @@ +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; import * as React from 'react'; +import { Router } from 'react-router-dom'; -import { alertFactory } from 'src/factories'; +import { alertFactory } from 'src/factories/cloudpulse/alerts'; import { capitalize } from 'src/utilities/capitalize'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; import { AlertTableRow } from './AlertTableRow'; +import type { Item } from '../constants'; +import type { AlertServiceType } from '@linode/api-v4'; + +const mockServices: Item[] = [ + { + label: 'Linode', + value: 'linode', + }, + { + label: 'Databases', + value: 'dbaas', + }, +]; describe('Alert Row', () => { it('should render an alert row', async () => { const alert = alertFactory.build(); @@ -13,31 +29,77 @@ describe('Alert Row', () => { ); const { getByText } = renderWithTheme(wrapWithTableBody(renderedAlert)); expect(getByText(alert.label)).toBeVisible(); }); - /** - * As of now the styling for the status 'enabled' is decided, in the future if they decide on the - other styles possible status values, will update them and test them accordingly. - */ it('should render the status field in green color if status is enabled', () => { - const statusValue = 'enabled'; - const alert = alertFactory.build({ status: statusValue }); + const alert = alertFactory.build({ status: 'enabled' }); const renderedAlert = ( ); + const { getByTestId, getByText } = renderWithTheme( + wrapWithTableBody(renderedAlert) + ); + expect(getByText(capitalize('enabled'))).toBeVisible(); + + expect(getComputedStyle(getByTestId('status-icon')).backgroundColor).toBe( + 'rgb(0, 176, 80)' + ); + }); + + it('alert labels should have hyperlinks to the details page', () => { + const alert = alertFactory.build({ status: 'enabled' }); + const history = createMemoryHistory(); + history.push('/monitor/alerts/definitions'); + const link = `/monitor/alerts/definitions/detail/${alert.service_type}/${alert.id}`; + const renderedAlert = ( + + + + ); const { getByText } = renderWithTheme(wrapWithTableBody(renderedAlert)); - const statusElement = getByText(capitalize(statusValue)); - expect(getComputedStyle(statusElement).color).toBe('rgb(0, 176, 80)'); + + const labelElement = getByText(alert.label); + expect(labelElement.closest('a')).toHaveAttribute('href', link); + }); + + it('should have the show details action item present inside action menu', async () => { + const alert = alertFactory.build({ status: 'enabled' }); + const { getAllByLabelText, getByTestId } = renderWithTheme( + + ); + const firstActionMenu = getAllByLabelText( + `Action menu for Alert ${alert.label}` + )[0]; + await userEvent.click(firstActionMenu); + expect(getByTestId('Show Details')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx index f16e20086de..69da9181971 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx @@ -1,16 +1,19 @@ -import { Typography } from '@linode/ui'; -import { useTheme } from '@mui/material'; +import { Box } from '@linode/ui'; import * as React from 'react'; +import { useLocation } from 'react-router-dom'; -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 { capitalize } from 'src/utilities/capitalize'; +import { formatDate } from 'src/utilities/formatDate'; import { AlertActionMenu } from './AlertActionMenu'; +import type { Item } from '../constants'; import type { ActionHandlers } from './AlertActionMenu'; -import type { Alert } from '@linode/api-v4'; +import type { Alert, AlertServiceType, AlertStatusType } from '@linode/api-v4'; interface Props { /** @@ -21,33 +24,53 @@ interface Props { * The callback handlers for clicking an action menu item like Show Details, Delete, etc. */ handlers: ActionHandlers; + /** + * services list for the reverse mapping to display the labels from the alert service values + */ + services: Item[]; } +const getStatus = (status: AlertStatusType) => { + if (status === 'enabled') { + return 'active'; + } else if (status === 'disabled') { + return 'inactive'; + } + return 'other'; +}; + export const AlertTableRow = (props: Props) => { - const { alert, handlers } = props; + const { alert, handlers, services } = props; + const location = useLocation(); const { created_by, id, label, service_type, status, type, updated } = alert; - const theme = useTheme(); return ( - {label} - {service_type} - + + {label} + + + + + {capitalize(status)} - + - + {services.find((service) => service.value === service_type)?.label} {created_by} - - + + {formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + })} + + + ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts index 057155cffb4..bd18ba2a2e8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts @@ -1,22 +1,22 @@ export const AlertListingTableLabelMap = [ { colName: 'Alert Name', - label: 'alertName', - }, - { - colName: 'Service', - label: 'service', + label: 'label', }, { colName: 'Status', label: 'status', }, { - colName: 'Last Modified', - label: 'lastModified', + colName: 'Service', + label: 'service_type', }, { colName: 'Created By', - label: 'createdBy', + label: 'created_by', + }, + { + colName: 'Last Modified', + label: 'updated', }, ]; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx index 684c804eff9..b2124bcc409 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx @@ -1,9 +1,12 @@ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { linodeFactory, regionFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AlertResources } from './AlertsResources'; +import { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; vi.mock('src/queries/cloudpulse/resources', () => ({ ...vi.importActual('src/queries/cloudpulse/resources'), @@ -22,14 +25,32 @@ const queryMocks = vi.hoisted(() => ({ const regions = regionFactory.buildList(3); -const linodes = linodeFactory.buildList(3); +const linodes = linodeFactory.buildList(3).map((value, index) => { + return { + ...value, + region: regions[index].id, // lets assign the regions from region factory to linode instances here + }; +}); const searchPlaceholder = 'Search for a Region or Resource'; const regionPlaceholder = 'Select Regions'; +const checkedAttribute = 'data-qa-checked'; +const cloudPulseResources: CloudPulseResources[] = linodes.map((linode) => { + return { + id: String(linode.id), + label: linode.label, + region: linode.region, + }; +}); + +beforeAll(() => { + window.scrollTo = vi.fn(); // mock for scrollTo and scroll + window.scroll = vi.fn(); +}); beforeEach(() => { queryMocks.useResourcesQuery.mockReturnValue({ - data: linodes, + data: cloudPulseResources, isError: false, isFetching: false, }); @@ -75,4 +96,137 @@ describe('AlertResources component tests', () => { getByText('Table data is unavailable. Please try again later.') ).toBeInTheDocument(); }); + it('should handle search, region filter functionality', async () => { + const { + getByPlaceholderText, + getByRole, + getByTestId, + getByText, + queryByText, + } = renderWithTheme( + + ); + // Get the search input box + const searchInput = getByPlaceholderText(searchPlaceholder); + await userEvent.type(searchInput, linodes[1].label); + // Wait for search results to update + await waitFor(() => { + expect(queryByText(linodes[0].label)).not.toBeInTheDocument(); + expect(getByText(linodes[1].label)).toBeInTheDocument(); + }); + // clear the search input + await userEvent.clear(searchInput); + await waitFor(() => { + expect(getByText(linodes[0].label)).toBeInTheDocument(); + expect(getByText(linodes[1].label)).toBeInTheDocument(); + }); + // search with invalid text and a region + await userEvent.type(searchInput, 'dummy'); + await userEvent.click(getByRole('button', { name: 'Open' })); + await userEvent.click(getByTestId(regions[0].id)); + await userEvent.click(getByRole('button', { name: 'Close' })); + await waitFor(() => { + expect(queryByText(linodes[0].label)).not.toBeInTheDocument(); + expect(queryByText(linodes[1].label)).not.toBeInTheDocument(); + }); + // now clear the search input and the region filter will be applied + await userEvent.clear(searchInput); + await waitFor(() => { + expect(getByText(linodes[0].label)).toBeInTheDocument(); + expect(queryByText(linodes[1].label)).not.toBeInTheDocument(); + }); + }); + + it('should handle sorting correctly', async () => { + const { getByTestId } = renderWithTheme( + + ); + const resourceColumn = getByTestId('resource'); // get the resource header column + await userEvent.click(resourceColumn); + + const tableBody = getByTestId('alert_resources_content'); + let rows = Array.from(tableBody.querySelectorAll('tr')); + expect( + rows + .map(({ textContent }) => textContent) + .every((text, index) => { + return text?.includes(linodes[linodes.length - 1 - index].label); + }) + ).toBe(true); + + await userEvent.click(resourceColumn); // again reverse the sorting + rows = Array.from(tableBody.querySelectorAll('tr')); + expect( + rows + .map(({ textContent }) => textContent) + .every((text, index) => text?.includes(linodes[index].label)) + ).toBe(true); + + const regionColumn = getByTestId('region'); // get the region header column + + await userEvent.click(regionColumn); // sort ascending for region + rows = Array.from(tableBody.querySelectorAll('tr')); // refetch + expect( + rows + .map(({ textContent }) => textContent) + .every((text, index) => + text?.includes(linodes[linodes.length - 1 - index].region) + ) + ).toBe(true); + + await userEvent.click(regionColumn); // reverse the sorting + rows = Array.from(tableBody.querySelectorAll('tr')); + expect( + rows + .map(({ textContent }) => textContent) + .every((text, index) => text?.includes(linodes[index].region)) // validation + ).toBe(true); + }); + + it('should handle selection correctly and publish', async () => { + const handleResourcesSelection = vi.fn(); + + const { getByTestId } = renderWithTheme( + + ); + // validate, by default selections are there + expect(getByTestId('select_item_1')).toHaveAttribute( + checkedAttribute, + 'true' + ); + expect(getByTestId('select_item_3')).toHaveAttribute( + checkedAttribute, + 'false' + ); + + // validate it selects 3 + await userEvent.click(getByTestId('select_item_3')); + expect(getByTestId('select_item_3')).toHaveAttribute( + checkedAttribute, + 'true' + ); + expect(handleResourcesSelection).toHaveBeenCalledWith(['1', '2', '3']); + + // unselect 3 and test + await userEvent.click(getByTestId('select_item_3')); + // validate it gets unselected + expect(getByTestId('select_item_3')).toHaveAttribute( + checkedAttribute, + 'false' + ); + expect(handleResourcesSelection).toHaveBeenLastCalledWith(['1', '2']); + + // click select all + await userEvent.click(getByTestId('select_all_in_page_1')); + expect(handleResourcesSelection).toHaveBeenLastCalledWith(['1', '2', '3']); + + // click select all again to unselect all + await userEvent.click(getByTestId('select_all_in_page_1')); + expect(handleResourcesSelection).toHaveBeenLastCalledWith([]); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 4ca342bbda8..011f251797a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -2,17 +2,22 @@ import { CircleProgress, Stack, Typography } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; +import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { useRegionsQuery } from 'src/queries/regions/regions'; +import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; import { + getFilteredResources, getRegionOptions, getRegionsIdRegionMap, + scrollToElement, } from '../Utils/AlertResourceUtils'; import { AlertsRegionFilter } from './AlertsRegionFilter'; import { DisplayAlertResources } from './DisplayAlertResources'; +import type { AlertInstance } from './DisplayAlertResources'; import type { Region } from '@linode/api-v4'; export interface AlertResourcesProp { @@ -20,12 +25,21 @@ export interface AlertResourcesProp { * The label of the alert to be displayed */ alertLabel?: string; - /** * The set of resource ids associated with the alerts, that needs to be displayed */ alertResourceIds: string[]; + /** + * Callback for publishing the selected resources + */ + handleResourcesSelection?: (resources: string[]) => void; + + /** + * This controls whether we need to show the checkbox in case of editing the resources + */ + isSelectionsNeeded?: boolean; + /** * The service type associated with the alerts like DBaaS, Linode etc., */ @@ -33,10 +47,18 @@ export interface AlertResourcesProp { } export const AlertResources = React.memo((props: AlertResourcesProp) => { - const { alertLabel, alertResourceIds, serviceType } = props; + const { + alertLabel, + alertResourceIds, + handleResourcesSelection, + isSelectionsNeeded, + serviceType, + } = props; const [searchText, setSearchText] = React.useState(); - - const [, setFilteredRegions] = React.useState(); + const [filteredRegions, setFilteredRegions] = React.useState(); + const [selectedResources, setSelectedResources] = React.useState( + alertResourceIds + ); const { data: regions, @@ -55,6 +77,19 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { serviceType === 'dbaas' ? { platform: 'rdbms-default' } : {} ); + const computedSelectedResources = React.useMemo(() => { + if (!isSelectionsNeeded || !resources) { + return alertResourceIds; + } + return resources + .filter(({ id }) => alertResourceIds.includes(id)) + .map(({ id }) => id); + }, [resources, isSelectionsNeeded, alertResourceIds]); + + React.useEffect(() => { + setSelectedResources(computedSelectedResources); + }, [computedSelectedResources]); + // A map linking region IDs to their corresponding region objects, used for quick lookup when displaying data in the table. const regionsIdToRegionMap: Map = React.useMemo(() => { return getRegionsIdRegionMap(regions); @@ -64,26 +99,89 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { const regionOptions: Region[] = React.useMemo(() => { return getRegionOptions({ data: resources, + isAdditionOrDeletionNeeded: isSelectionsNeeded, regionsIdToRegionMap, resourceIds: alertResourceIds, }); - }, [resources, alertResourceIds, regionsIdToRegionMap]); + }, [resources, alertResourceIds, regionsIdToRegionMap, isSelectionsNeeded]); + + const isDataLoadingError = isRegionsError || isResourcesError; const handleSearchTextChange = (searchText: string) => { setSearchText(searchText); }; const handleFilteredRegionsChange = (selectedRegions: string[]) => { - setFilteredRegions(selectedRegions); + setFilteredRegions( + selectedRegions.map( + (region) => + regionsIdToRegionMap.get(region) + ? `${regionsIdToRegionMap.get(region)?.label} (${region})` + : region // Stores filtered regions in the format `region.label (region.id)` that is displayed and filtered in the table + ) + ); }; + /** + * Filters resources based on the provided resource IDs, search text, and filtered regions. + */ + const filteredResources: AlertInstance[] = React.useMemo(() => { + return getFilteredResources({ + data: resources, + filteredRegions, + isAdditionOrDeletionNeeded: isSelectionsNeeded, + regionsIdToRegionMap, + resourceIds: alertResourceIds, + searchText, + selectedResources, + }); + }, [ + resources, + filteredRegions, + isSelectionsNeeded, + regionsIdToRegionMap, + alertResourceIds, + searchText, + selectedResources, + ]); + + const handleSelection = React.useCallback( + (ids: string[], isSelectionAction: boolean) => { + setSelectedResources((prevSelected) => { + const updatedSelection = isSelectionAction + ? [...prevSelected, ...ids.filter((id) => !prevSelected.includes(id))] + : prevSelected.filter((resource) => !ids.includes(resource)); + + handleResourcesSelection?.(updatedSelection); + return updatedSelection; + }); + }, + [handleResourcesSelection] + ); + const titleRef = React.useRef(null); // Reference to the component title, used for scrolling to the title when the table's page size or page number changes. + const isNoResources = + !isDataLoadingError && !isSelectionsNeeded && alertResourceIds.length === 0; if (isResourcesFetching || isRegionsFetching) { return ; } - const isDataLoadingError = isRegionsError || isResourcesError; + if (isNoResources) { + return ( + + + {alertLabel || 'Resources'} + {/* It can be either the passed alert label or just Resources */} + + + + ); + } return ( @@ -91,7 +189,6 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { {alertLabel || 'Resources'} {/* It can be either the passed alert label or just Resources */} - @@ -115,7 +212,13 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { - + scrollToElement(titleRef.current)} + /> diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx index f8f3bee12e0..039da3a8b50 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx @@ -1,5 +1,9 @@ +import { Checkbox } from '@linode/ui'; import React from 'react'; +import { sortData } from 'src/components/OrderBy'; +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -8,59 +12,249 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableSortCell } from 'src/components/TableSortCell'; +import { isAllPageSelected, isSomeSelected } from '../Utils/AlertResourceUtils'; + +import type { Order } from 'src/hooks/useOrder'; + +export interface AlertInstance { + /** + * Indicates if the instance is selected or not + */ + checked?: boolean; + /** + * The id of the instance + */ + id: string; + /** + * The label of the instance + */ + label: string; + /** + * The region associated with the instance + */ + region: string; +} + export interface DisplayAlertResourceProp { + /** + * The resources that needs to be displayed + */ + filteredResources: AlertInstance[] | undefined; + + /** + * Callback for clicking on check box + */ + handleSelection?: (id: string[], isSelectAction: boolean) => void; + /** * A flag indicating if there was an error loading the data. If true, the error message * (specified by `errorText`) will be displayed in the table. */ isDataLoadingError?: boolean; + + /** + * This controls whether to show the selection check box or not + */ + isSelectionsNeeded?: boolean; + + /** + * Callback to scroll till the element required on page change change or sorting change + */ + scrollToElement: () => void; } export const DisplayAlertResources = React.memo( (props: DisplayAlertResourceProp) => { - const { isDataLoadingError } = props; + const { + filteredResources, + handleSelection, + isDataLoadingError, + isSelectionsNeeded, + scrollToElement, + } = props; + const pageSize = 25; + + const [sorting, setSorting] = React.useState<{ + order: Order; + orderBy: string; + }>({ + order: 'asc', + orderBy: 'label', // default order to be asc and orderBy will be label + }); + // Holds the sorted data based on the selected sort order and column + const sortedData = React.useMemo(() => { + return sortData( + sorting.orderBy, + sorting.order + )(filteredResources ?? []); + }, [filteredResources, sorting]); + + const scrollToGivenElement = React.useCallback(() => { + requestAnimationFrame(() => { + scrollToElement(); + }); + }, [scrollToElement]); + + const handleSort = React.useCallback( + ( + orderBy: string, + order: Order | undefined, + handlePageChange: (page: number) => void + ) => { + if (!order) { + return; + } + + setSorting({ + order, + orderBy, + }); + handlePageChange(1); // Moves to the first page when the sort order or column changes + scrollToGivenElement(); + }, + [scrollToGivenElement] + ); + + const handlePageNumberChange = React.useCallback( + (handlePageChange: (page: number) => void, pageNumber: number) => { + handlePageChange(pageNumber); // Moves to the requested page number + scrollToGivenElement(); + }, + [scrollToGivenElement] + ); + + const handleSelectionChange = React.useCallback( + (id: string[], isSelectionAction: boolean) => { + if (handleSelection) { + handleSelection(id, isSelectionAction); + } + }, + [handleSelection] + ); return ( - - - - {}} // TODO: Implement sorting logic for this column. - label="label" - > - Resource - - {}} // TODO: Implement sorting logic for this column. - label="region" - > - Region - - - - - {isDataLoadingError && ( - - )} - {!isDataLoadingError && ( - // Placeholder cell to maintain table structure before body content is implemented. - - - {/* TODO: Populate the table body with resource data and implement sorting and pagination in future PRs. */} - - )} - -
+ + {({ + count, + data: paginatedData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + <> + + + + {isSelectionsNeeded && ( + + + handleSelectionChange( + paginatedData.map(({ id }) => id), + !isAllPageSelected(paginatedData) + ) + } + sx={{ + p: 0, + }} + checked={isAllPageSelected(paginatedData)} + data-testid={`select_all_in_page_${page}`} + /> + + )} + { + handleSort(orderBy, order, handlePageChange); + }} + active={sorting.orderBy === 'label'} + data-qa-header="resource" + data-testid="resource" + direction={sorting.order} + label="label" + > + Resource + + { + handleSort(orderBy, order, handlePageChange); + }} + active={sorting.orderBy === 'region'} + data-qa-header="region" + data-testid="region" + direction={sorting.order} + label="region" + > + Region + + + + + {!isDataLoadingError && + paginatedData.map(({ checked, id, label, region }, index) => ( + + {isSelectionsNeeded && ( + + { + handleSelectionChange([id], !checked); + }} + sx={{ + p: 0, + }} + checked={checked} + data-testid={`select_item_${id}`} + /> + + )} + + {label} + + + {region} + + + ))} + {isDataLoadingError && ( + + )} + {paginatedData.length === 0 && ( + + + No data to display. + + + )} + +
+ {!isDataLoadingError && paginatedData.length !== 0 && ( + { + handlePageNumberChange(handlePageChange, page); + }} + handleSizeChange={(pageSize) => { + handlePageSizeChange(pageSize); + handlePageNumberChange(handlePageChange, 1); // Moves to the first page after page size change + scrollToGivenElement(); + }} + count={count} + eventCategory="alerts_resources" + page={page} + pageSize={pageSize} + /> + )} + + )} +
); } ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index ea1a737bafe..7bfc2da1995 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -24,6 +24,9 @@ describe('AlertDefinition Create', () => { expect(getByLabelText('Aggregation Type')).toBeVisible(); expect(getByLabelText('Operator')).toBeVisible(); expect(getByLabelText('Threshold')).toBeVisible(); + expect(getByLabelText('Evaluation Period')).toBeVisible(); + expect(getByLabelText('Polling Interval')).toBeVisible(); + expect(getByText('3. Notification Channels')).toBeVisible(); }); it('should be able to enter a value in the textbox', async () => { @@ -38,30 +41,29 @@ describe('AlertDefinition Create', () => { }); it('should render client side validation errors', async () => { + const errorMessage = 'This field is required.'; const user = userEvent.setup(); const container = renderWithTheme(); const input = container.getByLabelText('Threshold'); + await user.click( + container.getByRole('button', { name: 'Add dimension filter' }) + ); const submitButton = container.getByText('Submit').closest('button'); - - await userEvent.click(submitButton!); - - expect(container.getByText('Name is required.')).toBeVisible(); - expect(container.getByText('Severity is required.')).toBeVisible(); - expect(container.getByText('Service is required.')).toBeVisible(); - expect(container.getByText('Region is required.')).toBeVisible(); + await user.click(submitButton!); + expect(container.getAllByText('This field is required.').length).toBe(11); + container.getAllByText(errorMessage).forEach((element) => { + expect(element).toBeVisible(); + }); expect( - container.getByText('At least one resource is needed.') + container.getByText('At least one resource is required.') ).toBeVisible(); - expect(container.getByText('Metric Data Field is required.')).toBeVisible(); - expect(container.getByText('Aggregation type is required.')).toBeVisible(); - expect(container.getByText('Criteria Operator is required.')).toBeVisible(); await user.clear(input); await user.type(input, '-3'); await userEvent.click(submitButton!); expect( - await container.findByText('Threshold value cannot be negative.') + await container.findByText("The value can't be negative.") ).toBeVisible(); await user.clear(input); @@ -69,7 +71,7 @@ describe('AlertDefinition Create', () => { await userEvent.click(submitButton!); expect( - await container.findByText('Threshold value should be a number.') + await container.findByText('The value should be a number.') ).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index be961d7b308..b2a73e92590 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -1,5 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { Box, Button, Paper, TextField, Typography } from '@linode/ui'; +import { Paper, TextField, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; @@ -7,8 +7,6 @@ import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; -import { Drawer } from 'src/components/Drawer'; -import { notificationChannelFactory } from 'src/factories'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { MetricCriteriaField } from './Criteria/MetricCriteria'; @@ -18,7 +16,7 @@ import { EngineOption } from './GeneralInformation/EngineOption'; import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; import { CloudPulseMultiResourceSelect } from './GeneralInformation/ResourceMultiSelect'; import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect'; -import { AddNotificationChannel } from './NotificationChannels/AddNotificationChannel'; +import { AddChannelListing } from './NotificationChannels/AddChannelListing'; import { CreateAlertDefinitionFormSchema } from './schemas'; import { filterFormValues } from './utilities'; @@ -81,34 +79,16 @@ export const CreateAlertDefinition = () => { ), }); - const { - control, - formState, - getValues, - handleSubmit, - setError, - setValue, - } = formMethods; + const { control, formState, getValues, handleSubmit, setError } = formMethods; const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: createAlert } = useCreateAlertDefinition( getValues('serviceType')! ); - const notificationChannelWatcher = useWatch({ control, name: 'channel_ids' }); const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); - const [openAddNotification, setOpenAddNotification] = React.useState(false); const [maxScrapeInterval, setMaxScrapeInterval] = React.useState(0); - const onSubmitAddNotification = (notificationId: number) => { - setValue('channel_ids', [...notificationChannelWatcher, notificationId], { - shouldDirty: false, - shouldTouch: false, - shouldValidate: false, - }); - setOpenAddNotification(false); - }; - const onSubmit = handleSubmit(async (values) => { try { await createAlert(filterFormValues(values)); @@ -130,13 +110,6 @@ export const CreateAlertDefinition = () => { } }); - const onExitNotifications = () => { - setOpenAddNotification(false); - }; - - const onAddNotifications = () => { - setOpenAddNotification(true); - }; return ( @@ -198,15 +171,7 @@ export const CreateAlertDefinition = () => { maxScrapingInterval={maxScrapeInterval} name="trigger_conditions" /> - - - + { }} sx={{ display: 'flex', justifyContent: 'flex-end' }} /> - - - diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx index c5085cb365b..d3416941c9c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { DimensionOperatorOptions } from '../../constants'; +import { dimensionOperatorOptions } from '../../constants'; import { DimensionFilterField } from './DimensionFilterField'; import type { CreateAlertDefinitionForm } from '../types'; @@ -149,19 +149,19 @@ describe('Dimension filter field component', () => { expect( await container.findByRole('option', { - name: DimensionOperatorOptions[1].label, + name: dimensionOperatorOptions[1].label, }) ); await user.click( await container.findByRole('option', { - name: DimensionOperatorOptions[0].label, + name: dimensionOperatorOptions[0].label, }) ); expect(within(operatorContainer).getByRole('combobox')).toHaveAttribute( 'value', - DimensionOperatorOptions[0].label + dimensionOperatorOptions[0].label ); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx index b0b877c3bf0..3b8bbb363a7 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -3,7 +3,7 @@ import { Grid } from '@mui/material'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; -import { DimensionOperatorOptions } from '../../constants'; +import { dimensionOperatorOptions } from '../../constants'; import { ClearIconButton } from './ClearIconButton'; import type { CreateAlertDefinitionForm, DimensionFilterForm } from '../types'; @@ -126,7 +126,7 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { ); }} value={ - DimensionOperatorOptions.find( + dimensionOperatorOptions.find( (option) => option.value === field.value ) ?? null } @@ -134,7 +134,7 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { errorText={fieldState.error?.message} label="Operator" onBlur={field.onBlur} - options={DimensionOperatorOptions} + options={dimensionOperatorOptions} /> )} control={control} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx index 3de387b7319..5e8b7b8382a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx @@ -95,7 +95,7 @@ describe('Metric component tests', () => { expect( within(dataFieldContainer).getByRole('button', { name: - 'Represents the metric you want to receive alerts for. Choose the one that helps you evaluate performance of your service in the most efficient way.', + 'Represents the metric you want to receive alerts for. Choose the one that helps you evaluate performance of your service in the most efficient way. For multiple metrics we use the AND method by default.', }) ); const dataFieldInput = within(dataFieldContainer).getByRole('button', { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx index 6851a6e53c8..434cfc17f73 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -5,9 +5,10 @@ import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { - MetricAggregationOptions, - MetricOperatorOptions, + metricAggregationOptions, + metricOperatorOptions, } from '../../constants'; +import { getAlertBoxStyles } from '../../Utils/utils'; import { ClearIconButton } from './ClearIconButton'; import { DimensionFilters } from './DimensionFilter'; @@ -101,7 +102,7 @@ export const Metric = (props: MetricCriteriaProps) => { MetricAggregationType >[] => { return selectedMetric && selectedMetric.available_aggregate_functions - ? MetricAggregationOptions.filter((option) => + ? metricAggregationOptions.filter((option) => selectedMetric.available_aggregate_functions.includes(option.value) ) : []; @@ -111,10 +112,7 @@ export const Metric = (props: MetricCriteriaProps) => { return ( ({ - backgroundColor: - theme.name === 'light' - ? theme.tokens.color.Neutrals[5] - : theme.tokens.color.Neutrals.Black, + ...getAlertBoxStyles(theme), borderRadius: 1, display: 'flex', flexDirection: 'column', @@ -152,7 +150,7 @@ export const Metric = (props: MetricCriteriaProps) => { }} textFieldProps={{ labelTooltipText: - 'Represents the metric you want to receive alerts for. Choose the one that helps you evaluate performance of your service in the most efficient way.', + 'Represents the metric you want to receive alerts for. Choose the one that helps you evaluate performance of your service in the most efficient way. For multiple metrics we use the AND method by default.', }} value={ metricOptions.find( @@ -166,7 +164,7 @@ export const Metric = (props: MetricCriteriaProps) => { noMarginTop onBlur={field.onBlur} options={metricOptions} - placeholder="Select a Data field" + placeholder="Select a Data Field" size="medium" /> )} @@ -199,7 +197,7 @@ export const Metric = (props: MetricCriteriaProps) => { noMarginTop onBlur={field.onBlur} options={aggOptions} - placeholder="Select an Aggregation type" + placeholder="Select an Aggregation Type" sx={{ paddingTop: { sm: 1, xs: 0 } }} /> )} @@ -222,7 +220,7 @@ export const Metric = (props: MetricCriteriaProps) => { }} value={ field.value !== null - ? MetricOperatorOptions.find( + ? metricOperatorOptions.find( (option) => option.value === field.value ) : null @@ -233,8 +231,8 @@ export const Metric = (props: MetricCriteriaProps) => { label="Operator" noMarginTop onBlur={field.onBlur} - options={MetricOperatorOptions} - placeholder="Select an operator" + options={metricOperatorOptions} + placeholder="Select an Operator" sx={{ paddingTop: { sm: 1, xs: 0 } }} /> )} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx index 6c9f6ada950..f9c58068090 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx @@ -5,8 +5,8 @@ import * as React from 'react'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { - EvaluationPeriodOptions, - PollingIntervalOptions, + evaluationPeriodOptions, + pollingIntervalOptions, } from '../../constants'; import { TriggerConditions } from './TriggerConditions'; @@ -15,6 +15,7 @@ import type { CreateAlertDefinitionForm } from '../types'; const EvaluationPeriodTestId = 'evaluation-period'; const PollingIntervalTestId = 'polling-interval'; + describe('Trigger Conditions', () => { const user = userEvent.setup(); @@ -52,8 +53,7 @@ describe('Trigger Conditions', () => { const evaluationPeriodToolTip = within(evaluationPeriodContainer).getByRole( 'button', { - name: - 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', + name: 'Choose how often you intend to evaluate the alert condition.', } ); const pollingIntervalContainer = container.getByTestId( @@ -62,7 +62,8 @@ describe('Trigger Conditions', () => { const pollingIntervalToolTip = within(pollingIntervalContainer).getByRole( 'button', { - name: 'Choose how often you intend to evaulate the alert condition.', + name: + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', } ); expect(evaluationPeriodToolTip).toBeInTheDocument(); @@ -96,24 +97,24 @@ describe('Trigger Conditions', () => { expect( await container.findByRole('option', { - name: EvaluationPeriodOptions.linode[1].label, + name: evaluationPeriodOptions.linode[1].label, }) ).toBeInTheDocument(); expect( await container.findByRole('option', { - name: EvaluationPeriodOptions.linode[2].label, + name: evaluationPeriodOptions.linode[2].label, }) ); await user.click( container.getByRole('option', { - name: EvaluationPeriodOptions.linode[0].label, + name: evaluationPeriodOptions.linode[0].label, }) ); expect( within(evaluationPeriodContainer).getByRole('combobox') - ).toHaveAttribute('value', EvaluationPeriodOptions.linode[0].label); + ).toHaveAttribute('value', evaluationPeriodOptions.linode[0].label); }); it('should render the Polling Interval component with options happy path and select an option', async () => { @@ -143,24 +144,24 @@ describe('Trigger Conditions', () => { expect( await container.findByRole('option', { - name: PollingIntervalOptions.linode[1].label, + name: pollingIntervalOptions.linode[1].label, }) ).toBeInTheDocument(); expect( await container.findByRole('option', { - name: PollingIntervalOptions.linode[2].label, + name: pollingIntervalOptions.linode[2].label, }) ); await user.click( container.getByRole('option', { - name: PollingIntervalOptions.linode[0].label, + name: pollingIntervalOptions.linode[0].label, }) ); expect( within(pollingIntervalContainer).getByRole('combobox') - ).toHaveAttribute('value', PollingIntervalOptions.linode[0].label); + ).toHaveAttribute('value', pollingIntervalOptions.linode[0].label); }); it('should be able to show the options that are greater than or equal to max scraping Interval', () => { @@ -187,7 +188,7 @@ describe('Trigger Conditions', () => { user.click(evaluationPeriodInput); expect( - screen.queryByText(EvaluationPeriodOptions.linode[0].label) + screen.queryByText(evaluationPeriodOptions.linode[0].label) ).not.toBeInTheDocument(); const pollingIntervalContainer = container.getByTestId( @@ -198,7 +199,7 @@ describe('Trigger Conditions', () => { ).getByRole('button', { name: 'Open' }); user.click(pollingIntervalInput); expect( - screen.queryByText(PollingIntervalOptions.linode[0].label) + screen.queryByText(pollingIntervalOptions.linode[0].label) ).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx index 65338bf4548..5c4c5ed381e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx @@ -4,9 +4,10 @@ import * as React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { - EvaluationPeriodOptions, - PollingIntervalOptions, + evaluationPeriodOptions, + pollingIntervalOptions, } from '../../constants'; +import { getAlertBoxStyles } from '../../Utils/utils'; import type { CreateAlertDefinitionForm } from '../types'; import type { @@ -34,14 +35,14 @@ export const TriggerConditions = (props: TriggerConditionProps) => { }); const getPollingIntervalOptions = () => { const options = serviceTypeWatcher - ? PollingIntervalOptions[serviceTypeWatcher] + ? pollingIntervalOptions[serviceTypeWatcher] : []; return options.filter((item) => item.value >= maxScrapingInterval); }; const getEvaluationPeriodOptions = () => { const options = serviceTypeWatcher - ? EvaluationPeriodOptions[serviceTypeWatcher] + ? evaluationPeriodOptions[serviceTypeWatcher] : []; return options.filter((item) => item.value >= maxScrapingInterval); }; @@ -49,10 +50,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { return ( ({ - backgroundColor: - theme.name === 'light' - ? theme.tokens.color.Neutrals[5] - : theme.tokens.color.Neutrals.Black, + ...getAlertBoxStyles(theme), borderRadius: 1, marginTop: theme.spacing(2), p: 2, @@ -75,7 +73,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { }} textFieldProps={{ labelTooltipText: - 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', + 'Choose how often you intend to evaluate the alert condition.', }} value={ getEvaluationPeriodOptions().find( @@ -110,7 +108,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { }} textFieldProps={{ labelTooltipText: - 'Choose how often you intend to evaulate the alert condition.', + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', }} value={ getPollingIntervalOptions().find( diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx new file mode 100644 index 00000000000..0c8f8e5aa0f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx @@ -0,0 +1,82 @@ +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { capitalize } from 'src/utilities/capitalize'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { AddChannelListing } from './AddChannelListing'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { NotificationChannel } from '@linode/api-v4'; + +const queryMocks = vi.hoisted(() => ({ + useAllAlertNotificationChannelsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/alerts', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/alerts'); + return { + ...actual, + useAllAlertNotificationChannelsQuery: + queryMocks.useAllAlertNotificationChannelsQuery, + }; +}); + +const mockNotificationData: NotificationChannel[] = [ + notificationChannelFactory.build({ id: 0 }), +]; + +queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: mockNotificationData, + isError: false, + isLoading: false, + status: 'success', +}); + +describe('Channel Listing component', () => { + const user = userEvent.setup(); + it('should render the notification channels ', () => { + const emailAddresses = + mockNotificationData[0].channel_type === 'email' && + mockNotificationData[0].content.email + ? mockNotificationData[0].content.email.email_addresses + : []; + + const { + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + channel_ids: [mockNotificationData[0].id], + }, + }, + }); + expect(getByText('3. Notification Channels')).toBeVisible(); + expect(getByText(capitalize(mockNotificationData[0].label))).toBeVisible(); + expect(getByText(emailAddresses[0])).toBeInTheDocument(); + expect(getByText(emailAddresses[1])).toBeInTheDocument(); + }); + + it('should remove the fields', async () => { + const { + getByTestId, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + channel_ids: [mockNotificationData[0].id], + }, + }, + }); + const notificationContainer = getByTestId('notification-channel-0'); + expect(notificationContainer).toBeInTheDocument(); + + const clearButton = within(notificationContainer).getByTestId('clear-icon'); + await user.click(clearButton); + + expect(notificationContainer).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx new file mode 100644 index 00000000000..e28ff5acbcf --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx @@ -0,0 +1,172 @@ +import { Box, Button, Stack, Typography } from '@linode/ui'; +import React from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { useAllAlertNotificationChannelsQuery } from 'src/queries/cloudpulse/alerts'; +import { capitalize } from 'src/utilities/capitalize'; + +import { channelTypeOptions } from '../../constants'; +import { getAlertBoxStyles } from '../../Utils/utils'; +import { ClearIconButton } from '../Criteria/ClearIconButton'; +import { AddNotificationChannelDrawer } from './AddNotificationChannelDrawer'; +import { RenderChannelDetails } from './RenderChannelDetails'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { NotificationChannel } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface AddChannelListingProps { + /** + * FieldPathByValue for the notification channel ids + */ + name: FieldPathByValue; +} + +interface NotificationChannelsProps { + /** + * index of the NotificationChannels map + */ + id: number; + /** + * NotificationChannel object + */ + notification: NotificationChannel; +} +export const AddChannelListing = React.memo((props: AddChannelListingProps) => { + const { name } = props; + const { control, setValue } = useFormContext(); + const [openAddNotification, setOpenAddNotification] = React.useState(false); + + const notificationChannelWatcher = useWatch({ + control, + name, + }); + const { + data: notificationData, + isError: notificationChannelsError, + isLoading: notificationChannelsLoading, + } = useAllAlertNotificationChannelsQuery(); + + const notifications = React.useMemo(() => { + return ( + notificationData?.filter( + ({ id }) => !notificationChannelWatcher.includes(id) + ) ?? [] + ); + }, [notificationChannelWatcher, notificationData]); + + const selectedNotifications = React.useMemo(() => { + return ( + notificationChannelWatcher + .map((notificationId) => + notificationData?.find(({ id }) => id === notificationId) + ) + .filter((notification) => notification !== undefined) ?? [] + ); + }, [notificationChannelWatcher, notificationData]); + + const handleRemove = (index: number) => { + const newList = notificationChannelWatcher.filter((_, i) => i !== index); + setValue(name, newList); + }; + + const handleOpenDrawer = () => { + setOpenAddNotification(true); + }; + + const handleCloseDrawer = () => { + setOpenAddNotification(false); + }; + + const handleAddNotification = (notificationId: number) => { + setValue(name, [...notificationChannelWatcher, notificationId]); + handleCloseDrawer(); + }; + + const NotificationChannelCard = React.memo( + (props: NotificationChannelsProps) => { + const { id, notification } = props; + return ( + ({ + ...getAlertBoxStyles(theme), + borderRadius: 1, + overflow: 'auto', + padding: theme.spacing(2), + })} + data-testid={`notification-channel-${id}`} + key={id} + > + + + {capitalize(notification?.label ?? 'Unnamed Channel')} + + handleRemove(id)} /> + + + + Type: + + + { + channelTypeOptions.find( + (option) => option.value === notification?.channel_type + )?.label + } + + + + + To: + + + {notification && } + + + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.id === nextProps.id && + prevProps.notification.id === nextProps.notification.id + ); + } + ); + + return ( + <> + + 3. Notification Channels + + + {selectedNotifications.length > 0 && + selectedNotifications.map((notification, id) => ( + + ))} + + + + + + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx deleted file mode 100644 index 12238c3c375..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { Autocomplete, Box, Typography } from '@linode/ui'; -import React from 'react'; -import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; - -import { channelTypeOptions } from '../../constants'; -import { getAlertBoxStyles } from '../../Utils/utils'; -import { notificationChannelSchema } from '../schemas'; - -import type { NotificationChannelForm } from '../types'; -import type { ChannelType, NotificationChannel } from '@linode/api-v4'; -import type { ObjectSchema } from 'yup'; - -interface AddNotificationChannelProps { - /** - * Boolean for the Notification channels api error response - */ - isNotificationChannelsError: boolean; - /** - * Boolean for the Notification channels api loading response - */ - isNotificationChannelsLoading: boolean; - /** - * Method to exit the Drawer on cancel - * @returns void - */ - onCancel: () => void; - /** - * Method to add the notification id to the form context - * @param notificationId id of the Notification that is being submitted - * @returns void - */ - onSubmitAddNotification: (notificationId: number) => void; - /** - * Notification template data fetched from the api - */ - templateData: NotificationChannel[]; -} - -export const AddNotificationChannel = (props: AddNotificationChannelProps) => { - const { - isNotificationChannelsError, - isNotificationChannelsLoading, - onCancel, - onSubmitAddNotification, - templateData, - } = props; - - const formMethods = useForm({ - defaultValues: { - channel_type: null, - label: null, - }, - mode: 'onBlur', - resolver: yupResolver( - notificationChannelSchema as ObjectSchema - ), - }); - - const { control, handleSubmit, setValue } = formMethods; - const onSubmit = handleSubmit(() => { - onSubmitAddNotification(selectedTemplate?.id ?? 0); - }); - - const channelTypeWatcher = useWatch({ control, name: 'channel_type' }); - const channelLabelWatcher = useWatch({ control, name: 'label' }); - const selectedChannelTypeTemplate = - channelTypeWatcher && templateData - ? templateData.filter( - (template) => template.channel_type === channelTypeWatcher - ) - : null; - - const selectedTemplate = selectedChannelTypeTemplate?.find( - (template) => template.label === channelLabelWatcher - ); - - return ( - -
- ({ - ...getAlertBoxStyles(theme), - borderRadius: 1, - overflow: 'auto', - p: 2, - })} - > - ({ - color: theme.tokens.content.Text, - })} - gutterBottom - variant="h3" - > - Channel Settings - - ( - { - field.onChange( - reason === 'selectOption' ? newValue.value : null - ); - if (reason !== 'selectOption') { - setValue('label', null); - } - }} - value={ - channelTypeOptions.find( - (option) => option.value === field.value - ) ?? null - } - data-testid="channel-type" - label="Type" - onBlur={field.onBlur} - options={channelTypeOptions} - placeholder="Select a Type" - /> - )} - control={control} - name="channel_type" - /> - - ( - { - field.onChange( - reason === 'selectOption' ? selected.label : null - ); - }} - value={ - selectedChannelTypeTemplate?.find( - (option) => option.label === field.value - ) ?? null - } - data-testid="channel-label" - disabled={!selectedChannelTypeTemplate} - errorText={fieldState.error?.message} - key={channelTypeWatcher} - label="Channel" - onBlur={field.onBlur} - options={selectedChannelTypeTemplate ?? []} - placeholder="Select a Channel" - /> - )} - control={control} - name="label" - /> - - - - -
- ); -}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.test.tsx similarity index 87% rename from packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx rename to packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.test.tsx index 4544e3195eb..47d7c9b3a7a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.test.tsx @@ -6,19 +6,20 @@ import { notificationChannelFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { channelTypeOptions } from '../../constants'; -import { AddNotificationChannel } from './AddNotificationChannel'; +import { AddNotificationChannelDrawer } from './AddNotificationChannelDrawer'; const mockData = [notificationChannelFactory.build()]; -describe('AddNotificationChannel component', () => { +describe('AddNotificationChannelDrawer component', () => { const user = userEvent.setup(); it('should render the components', () => { const { getByLabelText, getByText } = renderWithTheme( - ); @@ -29,11 +30,12 @@ describe('AddNotificationChannel component', () => { it('should render the type component with happy path and able to select an option', async () => { const { findByRole, getByTestId } = renderWithTheme( - ); @@ -58,11 +60,12 @@ describe('AddNotificationChannel component', () => { }); it('should render the label component with happy path and able to select an option', async () => { const { findByRole, getByRole, getByTestId } = renderWithTheme( - ); @@ -104,11 +107,12 @@ describe('AddNotificationChannel component', () => { it('should render the error messages from the client side validation', async () => { const { getAllByText, getByRole } = renderWithTheme( - ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx new file mode 100644 index 00000000000..dc250668a43 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx @@ -0,0 +1,214 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { Autocomplete, Box, Typography } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import React from 'react'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; + +import { channelTypeOptions } from '../../constants'; +import { getAlertBoxStyles } from '../../Utils/utils'; +import { notificationChannelSchema } from '../schemas'; +import { RenderChannelDetails } from './RenderChannelDetails'; + +import type { NotificationChannelForm } from '../types'; +import type { ChannelType, NotificationChannel } from '@linode/api-v4'; +import type { ObjectSchema } from 'yup'; + +interface AddNotificationChannelDrawerProps { + /** + * Method to exit the Drawer on cancel + * @returns void + */ + handleCloseDrawer: () => void; + /** + * Boolean for the Notification channels api error response + */ + isNotificationChannelsError: boolean; + /** + * Boolean for the Notification channels api loading response + */ + isNotificationChannelsLoading: boolean; + /** + * Method to add the notification id to the form context + * @param notificationId id of the Notification that is being submitted + * @returns void + */ + onSubmitAddNotification: (notificationId: number) => void; + /** + * Boolean to determine if the Drawer is open + */ + open: boolean; + /** + * Notification template data fetched from the api + */ + templateData: NotificationChannel[]; +} + +export const AddNotificationChannelDrawer = ( + props: AddNotificationChannelDrawerProps +) => { + const { + handleCloseDrawer, + isNotificationChannelsError, + isNotificationChannelsLoading, + onSubmitAddNotification, + open, + templateData, + } = props; + + const formMethods = useForm({ + defaultValues: { + channel_type: null, + label: null, + }, + mode: 'onBlur', + resolver: yupResolver( + notificationChannelSchema as ObjectSchema + ), + }); + + const { control, handleSubmit, reset, setValue } = formMethods; + const onSubmit = handleSubmit(() => { + if (selectedTemplate) { + onSubmitAddNotification(selectedTemplate.id); + reset(); + } + }); + + const channelTypeWatcher = useWatch({ control, name: 'channel_type' }); + const channelLabelWatcher = useWatch({ control, name: 'label' }); + const selectedChannelTypeTemplate = + channelTypeWatcher && templateData + ? templateData.filter( + (template) => template.channel_type === channelTypeWatcher + ) + : null; + + const selectedTemplate = selectedChannelTypeTemplate?.find( + (template) => template.label === channelLabelWatcher + ); + + return ( + + +
+ ({ + ...getAlertBoxStyles(theme), + borderRadius: 1, + overflow: 'auto', + p: 2, + })} + > + ({ + color: theme.tokens.content.Text, + })} + gutterBottom + variant="h3" + > + Channel Settings + + ( + { + field.onChange( + reason === 'selectOption' ? newValue.value : null + ); + if (reason !== 'selectOption') { + setValue('label', null); + } + }} + value={ + channelTypeOptions.find( + (option) => option.value === field.value + ) ?? null + } + data-testid="channel-type" + label="Type" + onBlur={field.onBlur} + options={channelTypeOptions} + placeholder="Select a Type" + /> + )} + control={control} + name="channel_type" + /> + + ( + { + field.onChange( + reason === 'selectOption' ? selected.label : null + ); + }} + value={ + selectedChannelTypeTemplate?.find( + (option) => option.label === field.value + ) ?? null + } + data-testid="channel-label" + disabled={!selectedChannelTypeTemplate} + errorText={fieldState.error?.message} + key={channelTypeWatcher} + label="Channel" + onBlur={field.onBlur} + options={selectedChannelTypeTemplate ?? []} + placeholder="Select a Channel" + /> + )} + control={control} + name="label" + /> + + {selectedTemplate && selectedTemplate.channel_type === 'email' && ( + + + + To: + + + + + + + )} + + + +
+
+ ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx new file mode 100644 index 00000000000..7c3968c928a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RenderChannelDetails } from './RenderChannelDetails'; + +import type { NotificationChannel } from '@linode/api-v4'; + +const mockData: NotificationChannel = notificationChannelFactory.build(); + +describe('RenderChannelDetails component', () => { + it('should render the email channel type notification details', () => { + const emailAddresses = + mockData.channel_type === 'email' && mockData.content.email + ? mockData.content.email.email_addresses + : []; + const container = renderWithTheme( + + ); + expect(container.getByText(emailAddresses[0])).toBeVisible(); + expect(container.getByText(emailAddresses[1])).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx new file mode 100644 index 00000000000..59a163551d8 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx @@ -0,0 +1,20 @@ +import { Chip } from '@mui/material'; +import * as React from 'react'; + +import type { NotificationChannel } from '@linode/api-v4'; + +interface RenderChannelDetailProps { + /** + * Notification Channel with the data to be shown in the component + */ + template: NotificationChannel; +} +export const RenderChannelDetails = (props: RenderChannelDetailProps) => { + const { template } = props; + if (template.channel_type === 'email') { + return template.content.email.email_addresses.map((value, index) => ( + + )); + } + return null; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts index 77e667d3237..a841852c2f4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -6,14 +6,14 @@ const fieldErrorMessage = 'This field is required.'; const engineOptionValidation = string().when('service_type', { is: 'dbaas', otherwise: (schema) => schema.notRequired().nullable(), - then: (schema) => schema.required('Engine type is required.').nullable(), + then: (schema) => schema.required(fieldErrorMessage).nullable(), }); export const CreateAlertDefinitionFormSchema = createAlertDefinitionSchema.concat( object({ engineType: engineOptionValidation, - region: string().required('Region is required.'), - serviceType: string().required('Service is required.'), + region: string().required(fieldErrorMessage), + serviceType: string().required(fieldErrorMessage), }) ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx new file mode 100644 index 00000000000..c6bf740b3c6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +import { alertFactory, linodeFactory, regionFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditAlertResources } from './EditAlertResources'; + +import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; + +const linodes = linodeFactory.buildList(4); +// Mock Data +const alertDetails = alertFactory.build({ + entity_ids: ['1', '2', '3'], + service_type: 'linode', +}); +const regions = regionFactory.buildList(4).map((region, index) => ({ + ...region, + id: linodes[index].region, +})); +const cloudPulseResources: CloudPulseResources[] = linodes.map((linode) => { + return { + id: String(linode.id), + label: linode.label, + region: linode.region, + }; +}); + +// Mock Queries +const queryMocks = vi.hoisted(() => ({ + useAlertDefinitionQuery: vi.fn(), + useRegionsQuery: vi.fn(), + useResourcesQuery: vi.fn(), +})); +vi.mock('src/queries/cloudpulse/alerts', () => ({ + ...vi.importActual('src/queries/cloudpulse/alerts'), + useAlertDefinitionQuery: queryMocks.useAlertDefinitionQuery, +})); +vi.mock('src/queries/cloudpulse/resources', () => ({ + ...vi.importActual('src/queries/cloudpulse/resources'), + useResourcesQuery: queryMocks.useResourcesQuery, +})); +vi.mock('src/queries/regions/regions', () => ({ + ...vi.importActual('src/queries/regions/regions'), + useRegionsQuery: queryMocks.useRegionsQuery, +})); + +beforeAll(() => { + // Mock window.scrollTo to prevent the "Not implemented" error + window.scrollTo = vi.fn(); +}); + +// Shared Setup +beforeEach(() => { + vi.clearAllMocks(); + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: alertDetails, + isError: false, + isFetching: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: cloudPulseResources, + isError: false, + isFetching: false, + }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: regions, + isError: false, + isFetching: false, + }); +}); + +describe('EditAlertResources component tests', () => { + it('Edit alert resources happy path', async () => { + const { getByPlaceholderText, getByText } = renderWithTheme( + + ); + // validate resources sections is rendered + expect( + getByPlaceholderText('Search for a Region or Resource') + ).toBeInTheDocument(); + expect(getByPlaceholderText('Select Regions')).toBeInTheDocument(); + expect(getByText(alertDetails.label)).toBeInTheDocument(); + }); + + it('Edit alert resources alert details error and loading path', () => { + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, + isError: true, // simulate error + isFetching: false, + }); + const { getByText } = renderWithTheme(); + expect( + getByText( + 'An error occurred while loading the alerts definitions and resources. Please try again later.' + ) + ).toBeInTheDocument(); + + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, + isError: false, + isFetching: true, // simulate loading + }); + const { getByTestId } = renderWithTheme(); + expect(getByTestId('circle-progress')).toBeInTheDocument(); + }); + + it('Edit alert resources alert details empty path', () => { + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, // simulate empty + isError: false, + isFetching: false, + }); + const { getByText } = renderWithTheme(); + expect(getByText('No Data to display.')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx new file mode 100644 index 00000000000..74f117b332f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx @@ -0,0 +1,125 @@ +import { Box, CircleProgress } from '@linode/ui'; +import { useTheme } from '@mui/material'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { useAlertDefinitionQuery } from 'src/queries/cloudpulse/alerts'; + +import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; +import { AlertResources } from '../AlertsResources/AlertsResources'; +import { getAlertBoxStyles } from '../Utils/utils'; + +import type { AlertRouteParams } from '../AlertsDetail/AlertDetail'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; + +export const EditAlertResources = () => { + const { alertId, serviceType } = useParams(); + + const theme = useTheme(); + + const definitionLanding = '/monitor/alerts/definitions'; + + const { data: alertDetails, isError, isFetching } = useAlertDefinitionQuery( + Number(alertId), + serviceType + ); + const [, setSelectedResources] = React.useState([]); + + React.useEffect(() => { + setSelectedResources( + alertDetails ? alertDetails.entity_ids.map((id) => id) : [] + ); + }, [alertDetails]); + + const { newPathname, overrides } = React.useMemo(() => { + const overrides = [ + { + label: 'Definitions', + linkTo: definitionLanding, + position: 1, + }, + { + label: 'Edit', + linkTo: `${definitionLanding}/edit/${serviceType}/${alertId}`, + position: 2, + }, + ]; + + return { newPathname: '/Definitions/Edit', overrides }; + }, [serviceType, alertId]); + + if (isFetching) { + return getEditAlertMessage(, newPathname, overrides); + } + + if (isError) { + return getEditAlertMessage( + , + newPathname, + overrides + ); + } + + if (!alertDetails) { + return getEditAlertMessage( + , + newPathname, + overrides + ); + } + + const handleResourcesSelection = (resourceIds: string[]) => { + setSelectedResources(resourceIds); // keep track of the selected resources and update it on save + }; + + const { entity_ids, label, service_type } = alertDetails; + + return ( + <> + + + + + + ); +}; + +/** + * Returns a common UI structure for loading, error, or empty states. + * @param messageComponent - A React component to display (e.g., CircleProgress, ErrorState, or Placeholder). + * @param pathName - The current pathname to be provided in breadcrumb + * @param crumbOverrides - The overrides to be provided in breadcrumb + */ +const getEditAlertMessage = ( + messageComponent: React.ReactNode, + pathName: string, + crumbOverrides: CrumbOverridesProps[] +) => { + return ( + <> + + + {messageComponent} + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts index 905094b1b36..169f9daa166 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts @@ -1,6 +1,10 @@ import { regionFactory } from 'src/factories'; -import { getRegionOptions, getRegionsIdRegionMap } from './AlertResourceUtils'; +import { + getFilteredResources, + getRegionOptions, + getRegionsIdRegionMap, +} from './AlertResourceUtils'; import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; @@ -64,4 +68,113 @@ describe('getRegionOptions', () => { }); expect(result.length).toBe(2); // Should still return unique regions }); + it('should return all region objects if resourceIds is empty and isAdditionOrDeletionNeeded is true', () => { + const result = getRegionOptions({ + data, + isAdditionOrDeletionNeeded: true, + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: [], + }); + // Valid case + expect(result.length).toBe(3); + }); +}); + +describe('getFilteredResources', () => { + const regions = regionFactory.buildList(10); + const regionsIdToRegionMap = getRegionsIdRegionMap(regions); + const data: CloudPulseResources[] = [ + { id: '1', label: 'Test', region: regions[0].id }, + { id: '2', label: 'Test2', region: regions[1].id }, + { id: '3', label: 'Test3', region: regions[2].id }, + ]; + it('should return correct filtered instances on only filtered regions', () => { + const result = getFilteredResources({ + data, + filteredRegions: getRegionOptions({ + data, + regionsIdToRegionMap, + resourceIds: ['1', '2'], + }).map(({ id, label }) => `${label} (${id})`), + regionsIdToRegionMap, + resourceIds: ['1', '2'], + }); + expect(result.length).toBe(2); + expect(result[0].label).toBe(data[0].label); + expect(result[1].label).toBe(data[1].label); + }); + it('should return correct filtered instances on filtered regions and search text', () => { + const // Case with searchText + result = getFilteredResources({ + data, + filteredRegions: getRegionOptions({ + data, + regionsIdToRegionMap, + resourceIds: ['1', '2'], + }).map(({ id, label }) => `${label} (${id})`), + regionsIdToRegionMap, + resourceIds: ['1', '2'], + searchText: data[1].label, + }); + expect(result.length).toBe(1); + expect(result[0].label).toBe(data[1].label); + }); + it('should return empty result on mismatched filters', () => { + const result = getFilteredResources({ + data, + filteredRegions: getRegionOptions({ + data, + regionsIdToRegionMap, + resourceIds: ['1'], // region not associated with the resources + }).map(({ id, label }) => `${label} (${id})`), + regionsIdToRegionMap, + resourceIds: ['1', '2'], + searchText: data[1].label, + }); + expect(result.length).toBe(0); + }); + it('should return empty result on empty data', () => { + const result = getFilteredResources({ + data: [], + filteredRegions: [], + regionsIdToRegionMap, + resourceIds: ['1', '2'], + }); + expect(result.length).toBe(0); + }); + it('should return empty result if data is undefined', () => { + const result = getFilteredResources({ + data: undefined, + filteredRegions: [], + regionsIdToRegionMap, + resourceIds: ['1', '2'], + }); + expect(result.length).toBe(0); + }); + it('should return checked true for already selected instances', () => { + const // Case with searchText + result = getFilteredResources({ + data, + filteredRegions: [], + regionsIdToRegionMap, + resourceIds: ['1', '2'], + searchText: '', + selectedResources: ['1'], + }); + expect(result.length).toBe(2); + expect(result[0].checked).toBe(true); + }); + it('should return all resources in case of edit flow', () => { + const // Case with searchText + result = getFilteredResources({ + data, + filteredRegions: [], + isAdditionOrDeletionNeeded: true, + regionsIdToRegionMap, + resourceIds: [], + searchText: undefined, + selectedResources: ['1'], + }); + expect(result.length).toBe(data.length); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts index d3c2b9bc0e4..638dc349000 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts @@ -1,4 +1,5 @@ import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; +import type { AlertInstance } from '../AlertsResources/DisplayAlertResources'; import type { Region } from '@linode/api-v4'; interface FilterResourceProps { @@ -6,14 +7,33 @@ interface FilterResourceProps { * The data to be filtered */ data?: CloudPulseResources[]; + /** + * The selected regions on which the data needs to be filtered and it is in format US, Newark, NJ (us-east) + */ + filteredRegions?: string[]; + /** + * Property to integrate and edit the resources associated with alerts + */ + isAdditionOrDeletionNeeded?: boolean; /** * The map that holds the id of the region to Region object, helps in building the alert resources */ regionsIdToRegionMap: Map; + /** * The resources associated with the alerts */ resourceIds: string[]; + + /** + * The search text with which the resources needed to be filtered + */ + searchText?: string; + + /** + * This property helps to track the list of selected resources + */ + selectedResources?: string[]; } /** @@ -36,13 +56,22 @@ export const getRegionsIdRegionMap = ( export const getRegionOptions = ( filterProps: FilterResourceProps ): Region[] => { - const { data, regionsIdToRegionMap, resourceIds } = filterProps; - if (!data || !resourceIds.length || !regionsIdToRegionMap.size) { + const { + data, + isAdditionOrDeletionNeeded, + regionsIdToRegionMap, + resourceIds, + } = filterProps; + const isEmpty = + !data || + (!isAdditionOrDeletionNeeded && !resourceIds.length) || + !regionsIdToRegionMap.size; + if (isEmpty) { return []; } const uniqueRegions = new Set(); data.forEach(({ id, region }) => { - if (resourceIds.includes(String(id))) { + if (isAdditionOrDeletionNeeded || resourceIds.includes(String(id))) { const regionObject = region ? regionsIdToRegionMap.get(region) : undefined; @@ -53,3 +82,85 @@ export const getRegionOptions = ( }); return Array.from(uniqueRegions); }; + +/** + * @param filterProps Props required to filter the resources on the table + * @returns Filtered instances to be displayed on the table + */ +export const getFilteredResources = ( + filterProps: FilterResourceProps +): AlertInstance[] => { + const { + data, + filteredRegions, + isAdditionOrDeletionNeeded, + regionsIdToRegionMap, + resourceIds, + searchText, + selectedResources, + } = filterProps; + if (!data || (!isAdditionOrDeletionNeeded && resourceIds.length === 0)) { + return []; + } + return data // here we always use the base data from API for filtering as source of truth + .filter( + ({ id }) => isAdditionOrDeletionNeeded || resourceIds.includes(String(id)) + ) + .map((resource) => { + const regionObj = resource.region + ? regionsIdToRegionMap.get(resource.region) + : undefined; + return { + ...resource, + checked: selectedResources + ? selectedResources.includes(resource.id) + : false, + region: resource.region // here replace region id, formatted to Chicago, US(us-west) compatible to display in table + ? regionObj + ? `${regionObj.label} (${regionObj.id})` + : resource.region + : '', + }; + }) + .filter(({ label, region }) => { + const matchesSearchText = + !searchText || + region.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()) || + label.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()); // check with search text + + const matchesFilteredRegions = + !filteredRegions?.length || + (region.length && filteredRegions.includes(region)); // check with filtered region + + return matchesSearchText && matchesFilteredRegions; // match the search text and match the region selected + }); +}; + +/** + * This methods scrolls to the given HTML Element + * @param scrollToElement The HTML Element to which we need to scroll + */ +export const scrollToElement = (scrollToElement: HTMLDivElement | null) => { + if (scrollToElement) { + window.scrollTo({ + behavior: 'smooth', + top: scrollToElement.getBoundingClientRect().top + window.scrollY - 40, + }); + } +}; + +/** + * @param data The list of alert instances displayed in the table. + * @returns True if, all instances are selected else false. + */ +export const isAllPageSelected = (data: AlertInstance[]): boolean => { + return Boolean(data?.length) && data.every(({ checked }) => checked); +}; + +/** + * @param data The list of alert instances displayed in the table. + * @returns True if, any one of instances is selected else false. + */ +export const isSomeSelected = (data: AlertInstance[]): boolean => { + return Boolean(data?.length) && data.some(({ checked }) => checked); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts index b9a3c1b8859..e02fea17efa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts @@ -8,6 +8,7 @@ import type { Action } from 'src/components/ActionMenu/ActionMenu'; */ export const getAlertTypeToActionsList = ({ handleDetails, + handleEdit, }: ActionHandlers): Record => ({ // for now there is system and user alert types, in future more alert types can be added and action items will differ according to alert types system: [ @@ -15,6 +16,10 @@ export const getAlertTypeToActionsList = ({ onClick: handleDetails, title: 'Show Details', }, + { + onClick: handleEdit, + title: 'Edit', + }, ], user: [ { diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 00b734f4447..e139d601a05 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -1,4 +1,5 @@ -import type { ServiceTypesList } from '@linode/api-v4'; +import type { AlertDimensionsProp } from '../AlertsDetail/DisplayAlertDetailChips'; +import type { NotificationChannel, ServiceTypesList } from '@linode/api-v4'; import type { Theme } from '@mui/material'; interface AlertChipBorderProps { @@ -87,3 +88,33 @@ export const getAlertChipBorderRadius = ( } return '0'; }; + +/** + * @param value The notification channel object for which we need to display the chips + * @returns The label and the values that needs to be displayed based on channel type + */ +export const getChipLabels = ( + value: NotificationChannel +): AlertDimensionsProp => { + if (value.channel_type === 'email') { + return { + label: 'To', + values: value.content.email.email_addresses, + }; + } else if (value.channel_type === 'slack') { + return { + label: 'Slack Webhook URL', + values: [value.content.slack.slack_webhook_url], + }; + } else if (value.channel_type === 'pagerduty') { + return { + label: 'Service API Key', + values: [value.content.pagerduty.service_api_key], + }; + } else { + return { + label: 'Webhook URL', + values: [value.content.webhook.webhook_url], + }; + } +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 8d8ff5bd25d..2d5df9fb646 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -30,7 +30,7 @@ export const engineTypeOptions: Item[] = [ }, ]; -export const MetricOperatorOptions: Item[] = [ +export const metricOperatorOptions: Item[] = [ { label: '>', value: 'gt', @@ -53,7 +53,7 @@ export const MetricOperatorOptions: Item[] = [ }, ]; -export const MetricAggregationOptions: Item[] = [ +export const metricAggregationOptions: Item[] = [ { label: 'Average', value: 'avg', @@ -76,7 +76,7 @@ export const MetricAggregationOptions: Item[] = [ }, ]; -export const DimensionOperatorOptions: Item< +export const dimensionOperatorOptions: Item< string, DimensionFilterOperatorType >[] = [ @@ -98,7 +98,7 @@ export const DimensionOperatorOptions: Item< }, ]; -export const EvaluationPeriodOptions = { +export const evaluationPeriodOptions = { dbaas: [{ label: '5 min', value: 300 }], linode: [ { label: '1 min', value: 60 }, @@ -109,7 +109,7 @@ export const EvaluationPeriodOptions = { ], }; -export const PollingIntervalOptions = { +export const pollingIntervalOptions = { dbaas: [{ label: '5 min', value: 300 }], linode: [ { label: '1 min', value: 60 }, @@ -143,7 +143,6 @@ export const channelTypeOptions: Item[] = Object.entries( label, value: key as ChannelType, })); - export const metricOperatorTypeMap: Record = { eq: '=', gt: '>', @@ -167,3 +166,15 @@ export const dimensionOperatorTypeMap: Record< neq: 'not equals', startswith: 'starts with', }; +export const alertStatuses: Record = { + disabled: 'Disabled', + enabled: 'Enabled', +}; + +export const alertStatusOptions: Item< + string, + AlertStatusType +>[] = Object.entries(alertStatuses).map(([key, label]) => ({ + label, + value: key as AlertStatusType, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx index cd1b2c4912c..abd84715e5c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx @@ -8,7 +8,12 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import DatabaseBackups from './DatabaseBackups'; -describe('Database Backups (Legacy)', () => { +/** + * Skipped due to repeated flake issues that we've been unable to fix after a few attempts + * 1. https://github.com/linode/manager/pull/11130 + * 2. https://github.com/linode/manager/pull/11394 + */ +describe.skip('Database Backups (Legacy)', () => { it('should render a list of backups after loading', async () => { const mockDatabase = databaseFactory.build({ platform: 'rdbms-legacy', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx index 5801fa42f61..204acfd1925 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx @@ -16,6 +16,8 @@ export const databaseStatusMap: Record = { active: 'active', degraded: 'inactive', failed: 'error', + migrated: 'inactive', + migrating: 'other', provisioning: 'other', resizing: 'other', restoring: 'other', diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index a459645c303..0b62810b54b 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -16,6 +16,7 @@ import { DatabaseEmptyState } from 'src/features/Databases/DatabaseLanding/Datab import DatabaseLandingTable from 'src/features/Databases/DatabaseLanding/DatabaseLandingTable'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { DatabaseClusterInfoBanner } from 'src/features/GlobalNotifications/DatabaseClusterInfoBanner'; +import { DatabaseMigrationInfoBanner } from 'src/features/GlobalNotifications/DatabaseMigrationInfoBanner'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; @@ -189,6 +190,7 @@ const DatabaseLanding = () => { title="Database Clusters" /> {showTabs && !isDatabasesV2GA && } + {showTabs && isDatabasesV2GA && } {showTabs ? ( diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index 2502c09d094..6506848b73a 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -63,7 +63,10 @@ export const DatabaseRow = ({ const formattedPlan = plan && formatStorageUnits(plan.label); const actualRegion = regions?.find((r) => r.id === region); const isLinkInactive = - status === 'suspended' || status === 'suspending' || status === 'resuming'; + status === 'suspended' || + status === 'suspending' || + status === 'resuming' || + status === 'migrated'; const { isDatabasesV2GA } = useIsDatabasesEnabled(); const configuration = diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx deleted file mode 100644 index dc83809d204..00000000000 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { - castFormValuesToNumeric, - resolve, - resolveAlias, - shouldResolve, -} from './DomainRecordDrawer'; - -const exampleDomain = 'example.com'; - -describe('Domain record helper methods', () => { - describe('shouldResolve', () => { - it('should return true for the target of a CNAME', () => { - expect(shouldResolve('CNAME', 'target')).toBe(true); - }); - - it('should return false for other fields of a CNAME', () => { - expect(shouldResolve('CNAME', 'name')).toBe(false); - }); - - it('should return true for the name of an AAAA record', () => { - expect(shouldResolve('AAAA', 'name')).toBe(true); - }); - - it('should return false for other fields of an AAAA', () => { - expect(shouldResolve('AAAA', 'target')).toBe(false); - }); - - it('should return true for the name of an TXT record', () => { - expect(shouldResolve('TXT', 'name')).toBe(true); - }); - - it('should return false for other fields of an TXT', () => { - expect(shouldResolve('TXT', 'target')).toBe(false); - }); - - // @todo: test for fields we know will be ignored under all cases, once we know what those are. - }); - - describe('resolve()', () => { - it('should resolve a single @ to the Domain name', () => { - expect(resolve('mail.@', exampleDomain)).toBe('mail.example.com'); - }); - - it('should return values with no @ unchanged', () => { - expect(resolve('mail', exampleDomain)).toBe('mail'); - }); - - it('should ignore additional @s', () => { - expect(resolve('mail.@.@', exampleDomain)).toBe('mail.example.com.@'); - }); - }); - - describe('resolveAlias helper', () => { - it('should resolve aliases where shouldResolve is true', () => { - const payload = { - name: 'my-name-@', - target: 'my-target-@', - }; - const result = resolveAlias(payload, exampleDomain, 'CNAME'); - expect(result).toHaveProperty('name', payload.name); - expect(result).toHaveProperty( - 'target', - resolve(payload.target, exampleDomain) - ); - }); - }); - - describe('castFormValuesToNumeric helper', () => { - it('should convert string values to numeric for all target fields', () => { - const formValues = { apple: '1', bear: '2', cat: '3' }; - const result = castFormValuesToNumeric(formValues, ['apple', 'bear']); - expect(result).toEqual({ - apple: 1, - bear: 2, - cat: '3', - }); - }); - - it('should convert to undefined if the value is an empty string', () => { - const formValues = { apple: '' }; - const result = castFormValuesToNumeric(formValues, ['apple']); - expect(result).toEqual({ - apple: undefined, - }); - }); - }); -}); diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx index 8ed31e146ae..ba6064e829e 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx @@ -2,35 +2,26 @@ import { createDomainRecord, updateDomainRecord, } from '@linode/api-v4/lib/domains'; -import { Autocomplete, Notice, TextField } from '@linode/ui'; -import produce from 'immer'; -import { - cond, - defaultTo, - equals, - lensPath, - path, - pathOr, - pick, - set, -} from 'ramda'; +import { Notice } from '@linode/ui'; import * as React from 'react'; +import { useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; -import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import { extendedIPToString, stringToExtendedIP } from 'src/utilities/ipUtils'; -import { maybeCastToNumber } from 'src/utilities/maybeCastToNumber'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; +import { isValidCNAME, isValidDomainRecord } from '../../domainUtils'; import { - getInitialIPs, - transferHelperText as helperText, - isValidCNAME, - isValidDomainRecord, -} from '../../domainUtils'; + castFormValuesToNumeric, + defaultFieldsState, + filterDataByType, + modeMap, + noARecordsNoticeText, + resolveAlias, + typeMap, +} from './DomainRecordDrawerUtils'; +import { generateDrawerTypes } from './generateDrawerTypes'; import type { Domain, @@ -40,13 +31,12 @@ import type { UpdateDomainPayload, } from '@linode/api-v4/lib/domains'; import type { APIError } from '@linode/api-v4/lib/types'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; interface UpdateDomainDataProps extends UpdateDomainPayload { id: number; } -interface DomainRecordDrawerProps +export interface DomainRecordDrawerProps extends Partial>, Partial> { domain: string; @@ -68,7 +58,7 @@ interface DomainRecordDrawerProps interface EditableSharedFields { ttl_sec?: number; } -interface EditableRecordFields extends EditableSharedFields { +export interface EditableRecordFields extends EditableSharedFields { name?: string; port?: string; priority?: string; @@ -79,7 +69,7 @@ interface EditableRecordFields extends EditableSharedFields { weight?: string; } -interface EditableDomainFields extends EditableSharedFields { +export interface EditableDomainFields extends EditableSharedFields { axfr_ips?: string[]; description?: string; domain?: string; @@ -87,466 +77,86 @@ interface EditableDomainFields extends EditableSharedFields { refresh_sec?: number; retry_sec?: number; soa_email?: string; - ttl_sec?: number; } -interface State { - errors?: APIError[]; - fields: EditableDomainFields | EditableRecordFields; - submitting: boolean; -} - -interface AdjustedTextFieldProps { - field: keyof EditableDomainFields | keyof EditableRecordFields; - helperText?: string; - label: string; - max?: number; - min?: number; - multiline?: boolean; - placeholder?: string; - trimmed?: boolean; -} - -interface NumberFieldProps extends AdjustedTextFieldProps { - defaultValue?: number; -} - -export class DomainRecordDrawer extends React.Component< - DomainRecordDrawerProps, - State -> { - /** - * the defaultFieldState is used to pre-populate the drawer with either - * editable data or defaults. - */ - static defaultFieldsState = (props: Partial) => ({ - axfr_ips: getInitialIPs(props.axfr_ips), - description: '', - domain: props.domain, - expire_sec: props.expire_sec ?? 0, - id: props.id, - name: props.name ?? '', - port: props.port ?? '80', - priority: props.priority ?? '10', - protocol: props.protocol ?? 'tcp', - refresh_sec: props.refresh_sec ?? 0, - retry_sec: props.retry_sec ?? 0, - service: props.service ?? '', - soa_email: props.soa_email ?? '', - tag: props.tag ?? 'issue', - target: props.target ?? '', - ttl_sec: props.ttl_sec ?? 0, - weight: props.weight ?? '5', +type ErrorFields = + | '_unknown' + | 'none' + | keyof EditableDomainFields + | keyof EditableRecordFields + | undefined; + +export const DomainRecordDrawer = (props: DomainRecordDrawerProps) => { + const { mode, open, records, type } = props; + + const formContainerRef = React.useRef(null); + + const defaultValues = defaultFieldsState(props); + + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + setError, + } = useForm({ + defaultValues, + mode: 'onBlur', + values: defaultValues, }); - static errorFields = { - axfr_ips: 'domain transfers', - domain: 'domain', - expire_sec: 'expire rate', - name: 'name', - port: 'port', - priority: 'priority', - protocol: 'protocol', - refresh_sec: 'refresh rate', - retry_sec: 'retry rate', - service: 'service', - soa_email: 'SOA email address', - tag: 'tag', - target: 'target', - ttl_sec: 'ttl_sec', - type: 'type', - weight: 'weight', - }; + const types = generateDrawerTypes(props, control); - DefaultTTLField = () => ( - - ); - - DomainTransferField = () => { - const finalIPs = ( - (this.state.fields as EditableDomainFields).axfr_ips ?? [''] - ).map(stringToExtendedIP); - return ( - - ); - }; - ExpireField = () => { - const rateOptions = [ - { label: 'Default', value: 0 }, - { label: '1 week', value: 604800 }, - { label: '2 weeks', value: 1209600 }, - { label: '4 weeks', value: 2419200 }, - ]; - - const defaultRate = rateOptions.find((eachRate) => { - return ( - eachRate.value === - defaultTo( - DomainRecordDrawer.defaultFieldsState(this.props).expire_sec, - (this.state.fields as EditableDomainFields).expire_sec - ) - ); - }); - - return ( - this.setExpireSec(selected?.value)} - options={rateOptions} - value={defaultRate} - /> - ); - }; - MSSelect = ({ - field, - fn, - label, - }: { - field: keyof EditableDomainFields | keyof EditableRecordFields; - fn: (v: number) => void; - label: string; - }) => { - const MSSelectOptions = [ - { label: 'Default', value: 0 }, - { label: '30 seconds', value: 30 }, - { label: '2 minutes', value: 120 }, - { label: '5 minutes', value: 300 }, - { label: '1 hour', value: 3600 }, - { label: '2 hours', value: 7200 }, - { label: '4 hours', value: 14400 }, - { label: '8 hours', value: 28800 }, - { label: '16 hours', value: 57600 }, - { label: '1 day', value: 86400 }, - { label: '2 days', value: 172800 }, - { label: '4 days', value: 345600 }, - { label: '1 week', value: 604800 }, - { label: '2 weeks', value: 1209600 }, - { label: '4 weeks', value: 2419200 }, - ]; - - const defaultOption = MSSelectOptions.find((eachOption) => { - return ( - eachOption.value === - defaultTo( - DomainRecordDrawer.defaultFieldsState(this.props)[field], - (this.state.fields as EditableDomainFields & EditableRecordFields)[ - field - ] - ) - ); - }); - - return ( - fn(selected.value)} - options={MSSelectOptions} - value={defaultOption} - /> - ); - }; - NameOrTargetField = ({ - field, - label, - multiline, - }: { - field: 'name' | 'target'; - label: string; - multiline?: boolean; - }) => { - const { domain, type } = this.props; - const value = (this.state.fields as EditableDomainFields & - EditableRecordFields)[field]; - const hasAliasToResolve = - value && value.indexOf('@') >= 0 && shouldResolve(type, field); - return ( - - ); - }; - NumberField = ({ field, label, ...rest }: NumberFieldProps) => { - return ( - ) => - this.updateField(field)(e.target.value) - } - onChange={(e: React.ChangeEvent) => - this.updateField(field)(e.target.value) - } - value={ - (this.state.fields as EditableDomainFields & EditableRecordFields)[ - field - ] as number - } - data-qa-target={label} - label={label} - type="number" - {...rest} - /> - ); - }; - PortField = () => ; + const { fields } = types[type]; + const isCreating = mode === 'create'; + const isDomain = type === 'master' || type === 'slave'; - PriorityField = (props: { label: string; max: number; min: number }) => ( - + // If there are no A/AAAA records and a user tries to add an NS record, + // they'll see a warning message asking them to add an A/AAAA record. + const hasARecords = records.find((thisRecord) => + ['A', 'AAAA'].includes(thisRecord.type) ); - ProtocolField = () => { - const protocolOptions = [ - { label: 'tcp', value: 'tcp' }, - { label: 'udp', value: 'udp' }, - { label: 'xmpp', value: 'xmpp' }, - { label: 'tls', value: 'tls' }, - { label: 'smtp', value: 'smtp' }, - ]; - - const defaultProtocol = protocolOptions.find((eachProtocol) => { - return ( - eachProtocol.value === - defaultTo( - DomainRecordDrawer.defaultFieldsState(this.props).protocol, - (this.state.fields as EditableRecordFields).protocol - ) - ); - }); - - return ( - this.setProtocol(selected.value)} - options={protocolOptions} - value={defaultProtocol} - /> - ); - }; - - RefreshRateField = () => ( - - ); - - RetryRateField = () => ( - - ); + const otherErrors = [ + errors?.root?._unknown, + errors?.root?.none, + errors?.root?.root, + ]; - ServiceField = () => ; - - TTLField = () => ( - - ); - - TagField = () => { - const tagOptions = [ - { label: 'issue', value: 'issue' }, - { label: 'issuewild', value: 'issuewild' }, - { label: 'iodef', value: 'iodef' }, - ]; - - const defaultTag = tagOptions.find((eachTag) => { - return ( - eachTag.value === - defaultTo( - DomainRecordDrawer.defaultFieldsState(this.props).tag, - (this.state.fields as EditableRecordFields).tag - ) - ); - }); - return ( - this.setTag(selected.value)} - options={tagOptions} - value={defaultTag} - /> - ); + const handleRecordSubmissionSuccess = () => { + props.updateRecords(); + handleClose(); }; - TextField = ({ - field, - helperText, - label, - multiline, - placeholder, - trimmed, - }: AdjustedTextFieldProps) => ( - ) => - this.updateField(field)(e.target.value) - } - onChange={(e: React.ChangeEvent) => - this.updateField(field)(e.target.value) - } - value={defaultTo( - DomainRecordDrawer.defaultFieldsState(this.props)[field] as - | number - | string, - (this.state.fields as EditableDomainFields & EditableRecordFields)[ - field - ] as number | string - )} - data-qa-target={label} - helperText={helperText} - label={label} - multiline={multiline} - placeholder={placeholder} - trimmed={trimmed} - /> - ); - - WeightField = () => ; - - filterDataByType = ( - fields: EditableDomainFields | EditableRecordFields, - t: DomainType | RecordType - ): Partial => - cond([ - [ - () => equals('master', t), - () => - pick( - [ - 'domain', - 'soa_email', - 'refresh_sec', - 'retry_sec', - 'expire_sec', - 'ttl_sec', - 'axfr_ips', - ], - fields - ), - ], - // [ - // () => equals('slave', t), - // () => pick([], fields), - // ], - [() => equals('A', t), () => pick(['name', 'target', 'ttl_sec'], fields)], - [ - () => equals('AAAA', t), - () => pick(['name', 'target', 'ttl_sec'], fields), - ], - [ - () => equals('CAA', t), - () => pick(['name', 'tag', 'target', 'ttl_sec'], fields), - ], - [ - () => equals('CNAME', t), - () => pick(['name', 'target', 'ttl_sec'], fields), - ], - [ - () => equals('MX', t), - () => pick(['target', 'priority', 'ttl_sec', 'name'], fields), - ], - [ - () => equals('NS', t), - () => pick(['target', 'name', 'ttl_sec'], fields), - ], - [ - () => equals('SRV', t), - () => - pick( - [ - 'service', - 'protocol', - 'priority', - 'port', - 'weight', - 'target', - 'ttl_sec', - ], - fields - ), - ], - [ - () => equals('TXT', t), - () => pick(['name', 'target', 'ttl_sec'], fields), - ], - ])(); - - handleRecordSubmissionSuccess = () => { - this.props.updateRecords(); - this.onClose(); - }; - - handleSubmissionErrors = (errorResponse: any) => { + const handleSubmissionErrors = (errorResponse: APIError[]) => { const errors = getAPIErrorOrDefault(errorResponse); - this.setState({ errors, submitting: false }, () => { - scrollErrorIntoView(); - }); - }; - handleTransferUpdate = (transferIPs: ExtendedIP[]) => { - const axfr_ips = - transferIPs.length > 0 ? transferIPs.map(extendedIPToString) : ['']; - this.updateField('axfr_ips')(axfr_ips); - }; + for (const error of errors) { + const errorField = error.field as ErrorFields; + + if (errorField === '_unknown' || errorField === 'none') { + setError(`root.${errorField}`, { + message: error.reason, + }); + } else if (errorField) { + setError(errorField, { + message: error.reason, + }); + } else { + setError('root.root', { + message: error.reason, + }); + } + } - onClose = () => { - this.setState({ - errors: undefined, - fields: DomainRecordDrawer.defaultFieldsState({}), - submitting: false, - }); - this.props.onClose(); + scrollErrorIntoViewV2(formContainerRef); }; - onDomainEdit = () => { - const { domainId, type, updateDomain } = this.props; - this.setState({ errors: undefined, submitting: true }); + const onDomainEdit = async (formData: EditableDomainFields) => { + const { domainId, type, updateDomain } = props; const data = { - ...this.filterDataByType(this.state.fields, type), + ...filterDataByType(formData, type), } as Partial; if (data.axfr_ips) { @@ -560,25 +170,24 @@ export class DomainRecordDrawer extends React.Component< .map((ip) => ip.trim()); } - updateDomain({ id: domainId, ...data, status: 'active' }) + await updateDomain({ id: domainId, ...data, status: 'active' }) .then(() => { - this.onClose(); + handleClose(); }) - .catch(this.handleSubmissionErrors); + .catch(handleSubmissionErrors); }; - onRecordCreate = () => { - const { domain, records, type } = this.props; + const onRecordCreate = async (formData: EditableRecordFields) => { + const { domain, records, type } = props; /** Appease TS ensuring we won't use it during Record create. */ if (type === 'master' || type === 'slave') { return; } - this.setState({ errors: undefined, submitting: true }); const _data = { type, - ...this.filterDataByType(this.state.fields, type), + ...filterDataByType(formData, type), }; // Expand @ to the Domain in appropriate fields @@ -592,7 +201,7 @@ export class DomainRecordDrawer extends React.Component< * This should be done on the API side, but several breaking * configurations will currently succeed on their end. */ - const _domain = pathOr('', ['name'], data); + const _domain = (data?.name ?? '') as string; const invalidCNAME = data.type === 'CNAME' && !isValidCNAME(_domain, records); @@ -601,231 +210,64 @@ export class DomainRecordDrawer extends React.Component< field: 'name', reason: 'Record conflict - CNAMES must be unique', }; - this.handleSubmissionErrors([error]); + handleSubmissionErrors([error]); return; } - createDomainRecord(this.props.domainId, data) - .then(this.handleRecordSubmissionSuccess) - .catch(this.handleSubmissionErrors); + await createDomainRecord(props.domainId, data) + .then(handleRecordSubmissionSuccess) + .catch(handleSubmissionErrors); }; - onRecordEdit = () => { - const { domain, domainId, id, type } = this.props; - const fields = this.state.fields as EditableRecordFields; + const onRecordEdit = async (formData: EditableRecordFields) => { + const { domain, domainId, id, type } = props; + const fields = formData; /** Appease TS ensuring we won't use it during Record create. */ if (type === 'master' || type === 'slave' || !id) { return; } - this.setState({ errors: undefined, submitting: true }); - const _data = { - ...this.filterDataByType(fields, type), + ...filterDataByType(fields, type), }; // Expand @ to the Domain in appropriate fields let data = resolveAlias(_data, domain, type); // Convert string values to numeric, replacing '' with undefined data = castFormValuesToNumeric(data); - updateDomainRecord(domainId, id, data) - .then(this.handleRecordSubmissionSuccess) - .catch(this.handleSubmissionErrors); + await updateDomainRecord(domainId, id, data) + .then(handleRecordSubmissionSuccess) + .catch(handleSubmissionErrors); }; - updateField = ( - key: keyof EditableDomainFields | keyof EditableRecordFields - ) => (value: any) => this.setState(set(lensPath(['fields', key]), value)); - - componentDidUpdate(prevProps: DomainRecordDrawerProps) { - if (this.props.open && !prevProps.open) { - // Drawer is opening, set the fields according to props - this.setState({ - fields: DomainRecordDrawer.defaultFieldsState(this.props), - }); - } - } - - // eslint-disable-next-line perfectionist/sort-classes - setExpireSec = this.updateField('expire_sec'); - - setProtocol = this.updateField('protocol'); - - setRefreshSec = this.updateField('refresh_sec'); - - setRetrySec = this.updateField('retry_sec'); - - setTTLSec = this.updateField('ttl_sec'); - - setTag = this.updateField('tag'); - - state: State = { - fields: DomainRecordDrawer.defaultFieldsState(this.props), - submitting: false, + const handleClose = () => { + reset(); + props.onClose(); }; - types = { - A: { - fields: [], - }, - AAAA: { - fields: [ - (idx: number) => ( - - ), - (idx: number) => ( - - ), - (idx: number) => , - ], - }, - CAA: { - fields: [ - (idx: number) => ( - - ), - (idx: number) => , - (idx: number) => ( - - ), - (idx: number) => , - ], - }, - CNAME: { - fields: [ - (idx: number) => ( - - ), - (idx: number) => ( - - ), - (idx: number) => , - , - ], - }, - MX: { - fields: [ - (idx: number) => ( - - ), - , - (idx: number) => ( - - ), - (idx: number) => , - (idx: number) => ( - - ), - ], - }, - NS: { - fields: [ - (idx: number) => ( - - ), - (idx: number) => ( - - ), - (idx: number) => , - ], - }, - PTR: { - fields: [], - }, - SRV: { - fields: [ - (idx: number) => , - (idx: number) => , - (idx: number) => ( - - ), - (idx: number) => , - (idx: number) => , - (idx: number) => ( - - ), - (idx: number) => , - ], - }, - TXT: { - fields: [ - (idx: number) => ( - - ), - (idx: number) => ( - - ), - (idx: number) => , - ], - }, - master: { - fields: [ - (idx: number) => ( - - ), - (idx: number) => ( - - ), - (idx: number) => , - (idx: number) => , - (idx: number) => , - (idx: number) => , - (idx: number) => , - ], - }, - slave: { - fields: [], - }, - }; + const onSubmit = handleSubmit(async (data) => { + if (isDomain) { + await onDomainEdit(data); + } else if (isCreating) { + await onRecordCreate(data); + } else { + await onRecordEdit(data); + } + }); - render() { - const { submitting } = this.state; - const { mode, open, records, type } = this.props; - const { fields } = this.types[type]; - const isCreating = mode === 'create'; - const isDomain = type === 'master' || type === 'slave'; - - const hasARecords = records.find((thisRecord) => - ['A', 'AAAA'].includes(thisRecord.type) - ); // If there are no A/AAAA records and a user tries to add an NS record, they'll see a warning message asking them to add an A/AAAA record. - - const noARecordsNoticeText = - 'Please create an A/AAAA record for this domain to avoid a Zone File invalidation.'; - - const otherErrors = [ - getAPIErrorFor({}, this.state.errors)('_unknown'), - getAPIErrorFor({}, this.state.errors)('none'), - ].filter(Boolean); - - return ( - - {otherErrors.length > 0 && - otherErrors.map((err, index) => { - return ; - })} + return ( + +
+ {otherErrors.map((error, idx) => + error ? ( + + ) : null + )} {!hasARecords && type === 'NS' && ( )} - {fields.map((field: any, idx: number) => field(idx))} - + {fields.map((field, idx) => + field && typeof field === 'function' ? field(idx) : null + )} - - ); - } -} - -const modeMap = { - create: 'Create', - edit: 'Edit', -}; - -const typeMap = { - A: 'A', - AAAA: 'A/AAAA', - CAA: 'CAA', - CNAME: 'CNAME', - MX: 'MX', - NS: 'NS', - PTR: 'PTR', - SRV: 'SRV', - TXT: 'TXT', - master: 'SOA', - slave: 'SOA', -}; - -export const shouldResolve = (type: string, field: string) => { - switch (type) { - case 'AAAA': - return field === 'name'; - case 'SRV': - return field === 'target'; - case 'CNAME': - return field === 'target'; - case 'TXT': - return field === 'name'; - default: - return false; - } -}; - -export const resolve = (value: string, domain: string) => - value.replace(/\@/, domain); - -export const resolveAlias = ( - data: Record, - domain: string, - type: string -) => { - // Replace a single @ with a reference to the Domain - const clone = { ...data }; - for (const [key, value] of Object.entries(clone)) { - if (shouldResolve(type, key) && typeof value === 'string') { - clone[key] = resolve(value, domain); - } - } - return clone; -}; - -const numericFields = ['port', 'weight', 'priority']; -export const castFormValuesToNumeric = ( - data: Record, - fieldNames: string[] = numericFields -) => { - return produce(data, (draft) => { - fieldNames.forEach((thisField) => { - draft[thisField] = maybeCastToNumber(draft[thisField]); - }); - }); + +
+ ); }; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerFields.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerFields.tsx new file mode 100644 index 00000000000..adedcd52eea --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerFields.tsx @@ -0,0 +1,326 @@ +import { TextField as _TextField, Autocomplete } from '@linode/ui'; +import * as React from 'react'; + +import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +import { extendedIPToString, stringToExtendedIP } from 'src/utilities/ipUtils'; + +import { transferHelperText as helperText } from '../../domainUtils'; +import { resolve, shouldResolve } from './DomainRecordDrawerUtils'; + +import type { + DomainRecordDrawerProps, + EditableDomainFields, +} from './DomainRecordDrawer'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; + +interface AdjustedTextFieldProps { + errorText?: string; + helperText?: string; + label: string; + max?: number; + min?: number; + multiline?: boolean; + onBlur: (e: React.FocusEvent) => void; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + trimmed?: boolean; + value: null | number | string; +} + +interface NumberFieldProps extends AdjustedTextFieldProps { + defaultValue?: number; +} + +export const TextField = ({ label, ...rest }: AdjustedTextFieldProps) => ( + <_TextField data-qa-target={label} label={label} {...rest} /> +); + +export const NameOrTargetField = ({ + domain, + errorText, + field, + label, + multiline, + onBlur, + onChange, + type, + value, +}: { + domain: DomainRecordDrawerProps['domain']; + errorText?: string; + field: 'name' | 'target'; + label: string; + multiline?: boolean; + onBlur: (e: React.FocusEvent) => void; + onChange: (e: React.ChangeEvent) => void; + type: DomainRecordDrawerProps['type']; + value: string; +}) => { + const hasAliasToResolve = + value && value.indexOf('@') >= 0 && shouldResolve(type, field); + + return ( + + ); +}; + +export const ServiceField = (props: { + errorText?: string; + onBlur: (e: React.FocusEvent) => void; + onChange: (e: React.ChangeEvent) => void; + value: string; +}) => ; + +export const DomainTransferField = ({ + errorText, + onChange, + value, +}: { + errorText?: string; + onChange: (ips: string[]) => void; + value: EditableDomainFields['axfr_ips']; +}) => { + const finalIPs = (value ?? ['']).map(stringToExtendedIP); + + const handleTransferUpdate = (transferIPs: ExtendedIP[]) => { + const axfrIps = + transferIPs.length > 0 ? transferIPs.map(extendedIPToString) : ['']; + onChange(axfrIps); + }; + + return ( + + ); +}; + +const MSSelect = ({ + label, + onChange, + value, +}: { + label: string; + onChange: (value: number) => void; + value: number; +}) => { + const MSSelectOptions = [ + { label: 'Default', value: 0 }, + { label: '30 seconds', value: 30 }, + { label: '2 minutes', value: 120 }, + { label: '5 minutes', value: 300 }, + { label: '1 hour', value: 3600 }, + { label: '2 hours', value: 7200 }, + { label: '4 hours', value: 14400 }, + { label: '8 hours', value: 28800 }, + { label: '16 hours', value: 57600 }, + { label: '1 day', value: 86400 }, + { label: '2 days', value: 172800 }, + { label: '4 days', value: 345600 }, + { label: '1 week', value: 604800 }, + { label: '2 weeks', value: 1209600 }, + { label: '4 weeks', value: 2419200 }, + ]; + + const defaultOption = MSSelectOptions.find((eachOption) => { + return eachOption.value === value; + }); + + return ( + onChange(selected.value)} + options={MSSelectOptions} + value={defaultOption} + /> + ); +}; + +export const RefreshRateField = (props: { + onChange: (value: number) => void; + value: number; +}) => ; + +export const RetryRateField = (props: { + onChange: (value: number) => void; + value: number; +}) => ; + +export const DefaultTTLField = (props: { + onChange: (value: number) => void; + value: number; +}) => ; + +export const TTLField = (props: { + onChange: (value: number) => void; + value: number; +}) => ; + +export const ExpireField = ({ + onChange, + value, +}: { + onChange: (value: number) => void; + value: number; +}) => { + const rateOptions = [ + { label: 'Default', value: 0 }, + { label: '1 week', value: 604800 }, + { label: '2 weeks', value: 1209600 }, + { label: '4 weeks', value: 2419200 }, + ]; + + const defaultRate = rateOptions.find((eachRate) => { + return eachRate.value === value; + }); + + return ( + onChange(selected.value)} + options={rateOptions} + value={defaultRate} + /> + ); +}; + +export const ProtocolField = ({ + onChange, + value, +}: { + onChange: (value: string) => void; + value: string; +}) => { + const protocolOptions = [ + { label: 'tcp', value: 'tcp' }, + { label: 'udp', value: 'udp' }, + { label: 'xmpp', value: 'xmpp' }, + { label: 'tls', value: 'tls' }, + { label: 'smtp', value: 'smtp' }, + ]; + + const defaultProtocol = protocolOptions.find((eachProtocol) => { + return eachProtocol.value === value; + }); + + return ( + onChange(selected.value)} + options={protocolOptions} + value={defaultProtocol} + /> + ); +}; + +export const TagField = ({ + onChange, + value, +}: { + onChange: (value: string) => void; + value: string; +}) => { + const tagOptions = [ + { label: 'issue', value: 'issue' }, + { label: 'issuewild', value: 'issuewild' }, + { label: 'iodef', value: 'iodef' }, + ]; + + const defaultTag = tagOptions.find((eachTag) => { + return eachTag.value === value; + }); + + return ( + onChange(selected.value)} + options={tagOptions} + value={defaultTag} + /> + ); +}; + +const NumberField = ({ + errorText, + label, + onBlur, + onChange, + value, + ...rest +}: NumberFieldProps) => { + return ( + <_TextField + data-qa-target={label} + errorText={errorText} + label={label} + onBlur={onBlur} + onChange={onChange} + type="number" + value={value} + {...rest} + /> + ); +}; + +export const PortField = (props: { + errorText?: string; + onBlur: (e: React.FocusEvent) => void; + onChange: (e: React.ChangeEvent) => void; + value: number | string; +}) => ; + +export const PriorityField = (props: { + errorText?: string; + label: string; + max: number; + min: number; + onBlur: (e: React.FocusEvent) => void; + onChange: (e: React.ChangeEvent) => void; + value: number | string; +}) => ; + +export const WeightField = (props: { + errorText?: string; + onBlur: (e: React.FocusEvent) => void; + onChange: (e: React.ChangeEvent) => void; + value: number | string; +}) => ; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.test.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.test.tsx new file mode 100644 index 00000000000..94441393cac --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.test.tsx @@ -0,0 +1,195 @@ +import { + castFormValuesToNumeric, + filterDataByType, + resolve, + resolveAlias, + shouldResolve, +} from './DomainRecordDrawerUtils'; + +import type { + EditableDomainFields, + EditableRecordFields, +} from './DomainRecordDrawer'; +import type { DomainType, RecordType } from '@linode/api-v4/lib/domains'; + +const exampleDomain = 'example.com'; + +describe('Domain record helper methods', () => { + describe('shouldResolve', () => { + it('should return true for the target of a CNAME', () => { + expect(shouldResolve('CNAME', 'target')).toBe(true); + }); + + it('should return false for other fields of a CNAME', () => { + expect(shouldResolve('CNAME', 'name')).toBe(false); + }); + + it('should return true for the name of an AAAA record', () => { + expect(shouldResolve('AAAA', 'name')).toBe(true); + }); + + it('should return false for other fields of an AAAA', () => { + expect(shouldResolve('AAAA', 'target')).toBe(false); + }); + + it('should return true for the name of an TXT record', () => { + expect(shouldResolve('TXT', 'name')).toBe(true); + }); + + it('should return false for other fields of an TXT', () => { + expect(shouldResolve('TXT', 'target')).toBe(false); + }); + + // @todo: test for fields we know will be ignored under all cases, once we know what those are. + }); + + describe('resolve()', () => { + it('should resolve a single @ to the Domain name', () => { + expect(resolve('mail.@', exampleDomain)).toBe('mail.example.com'); + }); + + it('should return values with no @ unchanged', () => { + expect(resolve('mail', exampleDomain)).toBe('mail'); + }); + + it('should ignore additional @s', () => { + expect(resolve('mail.@.@', exampleDomain)).toBe('mail.example.com.@'); + }); + }); + + describe('resolveAlias helper', () => { + it('should resolve aliases where shouldResolve is true', () => { + const payload = { + name: 'my-name-@', + target: 'my-target-@', + }; + const result = resolveAlias(payload, exampleDomain, 'CNAME'); + expect(result).toHaveProperty('name', payload.name); + expect(result).toHaveProperty( + 'target', + resolve(payload.target, exampleDomain) + ); + }); + }); + + describe('castFormValuesToNumeric helper', () => { + it('should convert string values to numeric for all target fields', () => { + const formValues = { apple: '1', bear: '2', cat: '3' }; + const result = castFormValuesToNumeric(formValues, ['apple', 'bear']); + expect(result).toEqual({ + apple: 1, + bear: 2, + cat: '3', + }); + }); + + it('should convert to undefined if the value is an empty string', () => { + const formValues = { apple: '' }; + const result = castFormValuesToNumeric(formValues, ['apple']); + expect(result).toEqual({ + apple: undefined, + }); + }); + }); + + describe('filterDataByType', () => { + const mockDomainFields: EditableDomainFields = { + axfr_ips: ['192.168.0.1'], + domain: exampleDomain, + expire_sec: 3600, + refresh_sec: 7200, + retry_sec: 300, + soa_email: 'soa@example.com', + ttl_sec: 86400, + }; + + const mockRecordFields: EditableRecordFields = { + name: 'www', + port: '80', + priority: '10', + protocol: 'tcp', + service: '_http', + tag: 'tag-example', + target: exampleDomain, + ttl_sec: 3600, + weight: '5', + }; + + it('should return correct master data for domain type "master"', () => { + const result = filterDataByType(mockDomainFields, 'master'); + + expect(result).toEqual({ + axfr_ips: ['192.168.0.1'], + domain: exampleDomain, + expire_sec: 3600, + refresh_sec: 7200, + retry_sec: 300, + soa_email: 'soa@example.com', + ttl_sec: 86400, + }); + }); + + it('should return correct record data for "A", "AAAA", "CNAME", "NS", "TXT" types', () => { + const recordTypes: RecordType[] = ['A', 'AAAA', 'CNAME', 'NS', 'TXT']; + + recordTypes.forEach((type) => { + const result = filterDataByType(mockRecordFields, type); + + expect(result).toEqual({ + name: 'www', + target: exampleDomain, + ttl_sec: 3600, + }); + }); + }); + + it('should return correct CAA record data for type "CAA"', () => { + const result = filterDataByType(mockRecordFields, 'CAA'); + + expect(result).toEqual({ + name: 'www', + tag: 'tag-example', + target: exampleDomain, + ttl_sec: 3600, + }); + }); + + it('should return correct MX record data for type "MX"', () => { + const result = filterDataByType(mockRecordFields, 'MX'); + + expect(result).toEqual({ + name: 'www', + priority: '10', + target: exampleDomain, + ttl_sec: 3600, + }); + }); + + it('should return correct SRV record data for type "SRV"', () => { + const result = filterDataByType(mockRecordFields, 'SRV'); + + expect(result).toEqual({ + port: '80', + priority: '10', + protocol: 'tcp', + service: '_http', + target: exampleDomain, + ttl_sec: 3600, + weight: '5', + }); + }); + + it('should return an empty object for PTR, slave types (default cases)', () => { + const types: (DomainType | RecordType)[] = ['slave', 'PTR']; + + types.forEach((type) => { + const mockFields = + type === 'slave' ? mockDomainFields : mockRecordFields; + + const result = filterDataByType(mockFields, type); + + expect(result).toEqual({}); + }); + }); + }); +}); diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.tsx new file mode 100644 index 00000000000..42b2c0e6aff --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawerUtils.tsx @@ -0,0 +1,214 @@ +import produce from 'immer'; + +import { maybeCastToNumber } from 'src/utilities/maybeCastToNumber'; + +import { getInitialIPs } from '../../domainUtils'; + +import type { + DomainRecordDrawerProps, + EditableDomainFields, + EditableRecordFields, +} from './DomainRecordDrawer'; +import type { DomainType, RecordType } from '@linode/api-v4/lib/domains'; + +type ValuesOfEditableData = Partial< + EditableDomainFields & EditableRecordFields +>[keyof Partial]; + +export const noARecordsNoticeText = + 'Please create an A/AAAA record for this domain to avoid a Zone File invalidation.'; + +export const modeMap = { + create: 'Create', + edit: 'Edit', +}; + +export const typeMap = { + A: 'A', + AAAA: 'A/AAAA', + CAA: 'CAA', + CNAME: 'CNAME', + MX: 'MX', + NS: 'NS', + PTR: 'PTR', + SRV: 'SRV', + TXT: 'TXT', + master: 'SOA', + slave: 'SOA', +}; + +export const shouldResolve = (type: string, field: string) => { + switch (type) { + case 'AAAA': + return field === 'name'; + case 'SRV': + return field === 'target'; + case 'CNAME': + return field === 'target'; + case 'TXT': + return field === 'name'; + default: + return false; + } +}; + +export const resolve = (value: string, domain: string) => + value.replace(/\@/, domain); + +export const resolveAlias = ( + data: Record, + domain: string, + type: string +) => { + // Replace a single @ with a reference to the Domain + const clone = { ...data }; + for (const [key, value] of Object.entries(clone)) { + if (shouldResolve(type, key) && typeof value === 'string') { + clone[key] = resolve(value, domain); + } + } + return clone; +}; + +const numericFields = ['port', 'weight', 'priority']; +export const castFormValuesToNumeric = ( + data: Record, + fieldNames: string[] = numericFields +) => { + return produce(data, (draft) => { + fieldNames.forEach((thisField) => { + draft[thisField] = maybeCastToNumber(draft[thisField] as number | string); + }); + }); +}; + +/** + * the defaultFieldState is used to pre-populate the drawer with either + * editable data or defaults. + */ +export const defaultFieldsState = ( + props: Partial +) => ({ + axfr_ips: getInitialIPs(props.axfr_ips), + description: '', + domain: props.domain, + expire_sec: props.expire_sec ?? 0, + id: props.id, + name: props.name ?? '', + port: props.port ?? '80', + priority: props.priority ?? '10', + protocol: props.protocol ?? 'tcp', + refresh_sec: props.refresh_sec ?? 0, + retry_sec: props.retry_sec ?? 0, + service: props.service ?? '', + soa_email: props.soa_email ?? '', + tag: props.tag ?? 'issue', + target: props.target ?? '', + ttl_sec: props.ttl_sec ?? 0, + weight: props.weight ?? '5', +}); + +const getMasterData = ( + fields: EditableDomainFields +): Pick< + EditableDomainFields, + | 'axfr_ips' + | 'domain' + | 'expire_sec' + | 'refresh_sec' + | 'retry_sec' + | 'soa_email' + | 'ttl_sec' +> => { + return { + axfr_ips: fields.axfr_ips, + domain: fields.domain, + expire_sec: fields.expire_sec, + refresh_sec: fields.refresh_sec, + retry_sec: fields.retry_sec, + soa_email: fields.soa_email, + ttl_sec: fields.ttl_sec, + }; +}; + +/** + * Get data for `A`, `AAAA`, `CNAME`, `NS`, `TXT` records + * @param fields - (unfiltered) Domain Record form data fields + */ +const getSharedRecordData = ( + fields: EditableRecordFields +): Pick => { + return { + name: fields.name, + target: fields.target, + ttl_sec: fields.ttl_sec, + }; +}; + +const getCAARecordData = ( + fields: EditableRecordFields +): Pick => { + return { + name: fields.name, + tag: fields.tag, + target: fields.target, + ttl_sec: fields.ttl_sec, + }; +}; + +const getMXRecordData = ( + fields: EditableRecordFields +): Pick => { + return { + name: fields.name, + priority: fields.priority, + target: fields.target, + ttl_sec: fields.ttl_sec, + }; +}; + +const getSRVRecordData = ( + fields: EditableRecordFields +): Pick< + EditableRecordFields, + 'port' | 'priority' | 'protocol' | 'service' | 'target' | 'ttl_sec' | 'weight' +> => { + return { + port: fields.port, + priority: fields.priority, + protocol: fields.protocol, + service: fields.service, + target: fields.target, + ttl_sec: fields.ttl_sec, + weight: fields.weight, + }; +}; + +export const filterDataByType = ( + fields: EditableDomainFields | EditableRecordFields, + type: DomainType | RecordType +): Partial => { + switch (type) { + case 'master': + return getMasterData(fields); + + case 'A': + case 'AAAA': + case 'CNAME': + case 'NS': + case 'TXT': + return getSharedRecordData(fields); + + case 'CAA': + return getCAARecordData(fields); + + case 'MX': + return getMXRecordData(fields); + + case 'SRV': + return getSRVRecordData(fields); + + default: + return {}; + } +}; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateDrawerTypes.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateDrawerTypes.tsx new file mode 100644 index 00000000000..a0cf5cb0771 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateDrawerTypes.tsx @@ -0,0 +1,658 @@ +import * as React from 'react'; +import { Controller } from 'react-hook-form'; + +import { + DefaultTTLField, + DomainTransferField, + ExpireField, + NameOrTargetField, + PortField, + PriorityField, + ProtocolField, + RefreshRateField, + RetryRateField, + ServiceField, + TTLField, + TagField, + TextField, + WeightField, +} from './DomainRecordDrawerFields'; +import { defaultFieldsState } from './DomainRecordDrawerUtils'; + +import type { + DomainRecordDrawerProps, + EditableDomainFields, + EditableRecordFields, +} from './DomainRecordDrawer'; +import type { Control } from 'react-hook-form'; + +type FieldRenderFunction = (idx: number) => JSX.Element; + +interface RecordTypeFields { + fields: FieldRenderFunction[]; +} + +interface DrawerTypes { + A: RecordTypeFields; + AAAA: RecordTypeFields; + CAA: RecordTypeFields; + CNAME: RecordTypeFields; + MX: RecordTypeFields; + NS: RecordTypeFields; + PTR: RecordTypeFields; + SRV: RecordTypeFields; + TXT: RecordTypeFields; + master: RecordTypeFields; + slave: RecordTypeFields; +} + +export const generateDrawerTypes = ( + props: Pick, + control: Control +): DrawerTypes => { + return { + A: { + fields: [], + }, + AAAA: { + fields: [ + (idx: number) => ( + ( + + )} + control={control} + key={`aaaa-name-${idx}`} + name="name" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`aaaa-target-${idx}`} + name="target" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`aaaa-ttl-sec-${idx}`} + name="ttl_sec" + /> + ), + ], + }, + CAA: { + fields: [ + (idx: number) => ( + ( + + )} + control={control} + key={`caa-name-${idx}`} + name="name" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`caa-tag-${idx}`} + name="tag" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`caa-target-${idx}`} + name="target" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`caa-ttl-sec-${idx}`} + name="ttl_sec" + /> + ), + ], + }, + CNAME: { + fields: [ + (idx: number) => ( + ( + + )} + control={control} + key={`cname-name-${idx}`} + name="name" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`cname-target-${idx}`} + name="target" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`cname-ttl-sec-${idx}`} + name="ttl_sec" + /> + ), + ], + }, + MX: { + fields: [ + (idx: number) => ( + ( + + )} + control={control} + key={`mx-target-${idx}`} + name="target" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`mx-priority-${idx}`} + name="priority" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`mx-ttl-sec-${idx}`} + name="ttl_sec" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`mx-name-${idx}`} + name="name" + /> + ), + ], + }, + NS: { + fields: [ + (idx: number) => ( + ( + + )} + control={control} + key={`ns-target-${idx}`} + name="target" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`ns-name-${idx}`} + name="name" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`ns-ttl-sec-${idx}`} + name="ttl_sec" + /> + ), + ], + }, + PTR: { + fields: [], + }, + SRV: { + fields: [ + (idx: number) => ( + ( + + )} + control={control} + key={`srv-service-${idx}`} + name="service" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`srv-protocol-${idx}`} + name="protocol" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`srv-priority-${idx}`} + name="priority" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`srv-weight-${idx}`} + name="weight" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`srv-port-${idx}`} + name="port" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`srv-target-${idx}`} + name="target" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`srv-ttl-sec-${idx}`} + name="ttl_sec" + /> + ), + ], + }, + TXT: { + fields: [ + (idx: number) => ( + ( + + )} + control={control} + key={`txt-name-${idx}`} + name="name" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`txt-target-${idx}`} + name="target" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`txt-ttl-sec-${idx}`} + name="ttl_sec" + /> + ), + ], + }, + master: { + fields: [ + (idx: number) => ( + ( + + )} + control={control} + key={`domain-${idx}`} + name="domain" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`soa-email-${idx}`} + name="soa_email" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`axfr-ips-${idx}`} + name="axfr_ips" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`ttl-sec-${idx}`} + name="ttl_sec" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`refresh-sec-${idx}`} + name="refresh_sec" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`retry-sec-${idx}`} + name="retry_sec" + /> + ), + (idx: number) => ( + ( + + )} + control={control} + key={`expire-sec-${idx}`} + name="expire_sec" + /> + ), + ], + }, + slave: { + fields: [], + }, + }; +}; diff --git a/packages/manager/src/features/Events/factories/database.tsx b/packages/manager/src/features/Events/factories/database.tsx index b34114cc9e8..94410f0e512 100644 --- a/packages/manager/src/features/Events/factories/database.tsx +++ b/packages/manager/src/features/Events/factories/database.tsx @@ -106,6 +106,20 @@ export const database: PartialEventMap<'database'> = { ), }, + database_migrate: { + finished: (e) => ( + <> + Database migration{' '} + completed. + + ), + started: (e) => ( + <> + Database migration{' '} + in progress. + + ), + }, database_resize: { failed: (e) => ( <> diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx index 41fcbf8a6ba..c9db016c1e5 100644 --- a/packages/manager/src/features/Events/factories/firewall.tsx +++ b/packages/manager/src/features/Events/factories/firewall.tsx @@ -1,18 +1,12 @@ import * as React from 'react'; +import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding'; + import { EventLink } from '../EventLink'; import type { PartialEventMap } from '../types'; import type { FirewallDeviceEntityType } from '@linode/api-v4'; -const secondaryFirewallEntityNameMap: Record< - FirewallDeviceEntityType, - string -> = { - linode: 'Linode', - nodebalancer: 'NodeBalancer', -}; - export const firewall: PartialEventMap<'firewall'> = { firewall_apply: { notification: (e) => ( @@ -41,9 +35,7 @@ export const firewall: PartialEventMap<'firewall'> = { notification: (e) => { if (e.secondary_entity?.type) { const secondaryEntityName = - secondaryFirewallEntityNameMap[ - e.secondary_entity.type as FirewallDeviceEntityType - ]; + formattedTypes[e.secondary_entity.type as FirewallDeviceEntityType]; return ( <> {secondaryEntityName} {' '} @@ -64,9 +56,7 @@ export const firewall: PartialEventMap<'firewall'> = { notification: (e) => { if (e.secondary_entity?.type) { const secondaryEntityName = - secondaryFirewallEntityNameMap[ - e.secondary_entity.type as FirewallDeviceEntityType - ]; + formattedTypes[e.secondary_entity.type as FirewallDeviceEntityType]; return ( <> {secondaryEntityName} {' '} diff --git a/packages/manager/src/features/Events/factories/index.ts b/packages/manager/src/features/Events/factories/index.ts index 77b7a8d85f6..d58251b8d66 100644 --- a/packages/manager/src/features/Events/factories/index.ts +++ b/packages/manager/src/features/Events/factories/index.ts @@ -10,6 +10,7 @@ export * from './entity'; export * from './firewall'; export * from './host'; export * from './image'; +export * from './interface'; export * from './ipaddress'; export * from './ipv6pool'; export * from './lassie'; diff --git a/packages/manager/src/features/Events/factories/interface.tsx b/packages/manager/src/features/Events/factories/interface.tsx new file mode 100644 index 00000000000..79a7dd69871 --- /dev/null +++ b/packages/manager/src/features/Events/factories/interface.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const linodeInterface: PartialEventMap<'interface'> = { + interface_create: { + failed: (e) => ( + <> + Linode Interface {e.entity!.id} could not be{' '} + created. + + ), + finished: (e) => ( + <> + Linode Interface has been{' '} + created for Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Linode Interface {e.entity!.id} is scheduled for{' '} + creation. + + ), + started: (e) => ( + <> + Linode Interface {e.entity!.id} is being created. + + ), + }, + interface_delete: { + failed: (e) => ( + <> + Linode Interface {e.entity!.id} could not be{' '} + deleted. + + ), + finished: (e) => ( + <> + Linode Interface has been{' '} + deleted from Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Linode Interface {e.entity!.id} is scheduled for{' '} + deletion. + + ), + started: (e) => ( + <> + Linode Interface {e.entity!.id} is being deleted. + + ), + }, + interface_update: { + failed: (e) => ( + <> + Linode Interface {e.entity!.id} could not be{' '} + updated. + + ), + finished: (e) => ( + <> + Linode Interface has been{' '} + updated from Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Linode Interface {e.entity!.id} is scheduled for{' '} + updating. + + ), + started: (e) => ( + <> + Linode Interface {e.entity!.label} is being updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index faf5bf4a53c..96e80b72cf7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -46,7 +46,7 @@ export const AddLinodeDrawer = (props: Props) => { const { isPending: addDeviceIsLoading, mutateAsync: addDevice, - } = useAddFirewallDeviceMutation(Number(id)); + } = useAddFirewallDeviceMutation(); const [selectedLinodes, setSelectedLinodes] = React.useState([]); @@ -60,7 +60,7 @@ export const AddLinodeDrawer = (props: Props) => { const results = await Promise.allSettled( selectedLinodes.map((linode) => - addDevice({ id: linode.id, type: 'linode' }) + addDevice({ firewallId: Number(id), id: linode.id, type: 'linode' }) ) ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index 28ea6434f45..d309eb24185 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -44,7 +44,7 @@ export const AddNodebalancerDrawer = (props: Props) => { const { isPending: addDeviceIsLoading, mutateAsync: addDevice, - } = useAddFirewallDeviceMutation(Number(id)); + } = useAddFirewallDeviceMutation(); const [selectedNodebalancers, setSelectedNodebalancers] = React.useState< NodeBalancer[] @@ -60,7 +60,11 @@ export const AddNodebalancerDrawer = (props: Props) => { const results = await Promise.allSettled( selectedNodebalancers.map((nodebalancer) => - addDevice({ id: nodebalancer.id, type: 'nodebalancer' }) + addDevice({ + firewallId: Number(id), + id: nodebalancer.id, + type: 'nodebalancer', + }) ) ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx index 202336a8e08..7b4dd00f56c 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx @@ -22,7 +22,8 @@ export interface FirewallDeviceLandingProps { type: FirewallDeviceEntityType; } -export const formattedTypes = { +export const formattedTypes: Record = { + interface: 'Interface', // @TODO Linode Interface: double check this when working on UI tickets linode: 'Linode', nodebalancer: 'NodeBalancer', }; diff --git a/packages/manager/src/features/GlobalNotifications/DatabaseMigrationInfoBanner.tsx b/packages/manager/src/features/GlobalNotifications/DatabaseMigrationInfoBanner.tsx new file mode 100644 index 00000000000..5807ddbbe45 --- /dev/null +++ b/packages/manager/src/features/GlobalNotifications/DatabaseMigrationInfoBanner.tsx @@ -0,0 +1,20 @@ +import { Notice, Typography } from '@linode/ui'; +import React from 'react'; + +import { SupportLink } from 'src/components/SupportLink'; + +export const DatabaseMigrationInfoBanner = () => { + return ( + + theme.font.bold} lineHeight="20px"> + Legacy clusters decommission + + + Legacy database clusters will only be available until the end of 2025. + At that time, we’ll migrate your clusters to the new solution. For + questions regarding the new database clusters or the migration,{' '} + . + + + ); +}; diff --git a/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx b/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx index b49c1c042fc..acfdf3984c3 100644 --- a/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx +++ b/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx @@ -1,6 +1,5 @@ import { Autocomplete, InputAdornment, Notice } from '@linode/ui'; import Search from '@mui/icons-material/Search'; -import { pathOr } from 'ramda'; import * as React from 'react'; import { withRouter } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; @@ -66,7 +65,8 @@ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { return; } - const href = pathOr('', ['data', 'href'], selected); + const href = + selected?.data && 'href' in selected.data ? selected.data.href : ''; if (href) { // If an href exists for the selected option, redirect directly to that link. window.open(href, '_blank', 'noopener'); diff --git a/packages/manager/src/features/Help/SearchHOC.tsx b/packages/manager/src/features/Help/SearchHOC.tsx index 996bab7c094..b93b69e945c 100644 --- a/packages/manager/src/features/Help/SearchHOC.tsx +++ b/packages/manager/src/features/Help/SearchHOC.tsx @@ -1,5 +1,5 @@ +/* eslint-disable react-refresh/only-export-components */ import Algolia from 'algoliasearch'; -import { pathOr } from 'ramda'; import * as React from 'react'; import { @@ -118,23 +118,7 @@ export default (options: SearchOptions) => ( ) => { const { highlight, hitsPerPage } = options; class WrappedComponent extends React.PureComponent<{}, AlgoliaState> { - componentDidMount() { - this.mounted = true; - this.initializeSearchIndices(); - } - componentWillUnmount() { - this.mounted = false; - } - - render() { - return React.createElement(Component, { - ...this.props, - ...this.state, - }); - } - client: SearchClient; - initializeSearchIndices = () => { try { const client = Algolia(ALGOLIA_APPLICATION_ID, ALGOLIA_SEARCH_KEY); @@ -210,8 +194,14 @@ export default (options: SearchOptions) => ( } /* If err is undefined, the shape of content is guaranteed, but better to be safe: */ - const docs = pathOr([], ['results', 0, 'hits'], content); - const community = pathOr([], ['results', 1, 'hits'], content); + const docs: SearchHit[] = + (Array.isArray(content.results) && + (content.results?.[0] as { hits: SearchHit[] })?.hits) || + []; + const community: SearchHit[] = + (Array.isArray(content.results) && + (content.results?.[1] as { hits: SearchHit[] })?.hits) || + []; const docsResults = convertDocsToItems(highlight, docs); const commResults = convertCommunityToItems(highlight, community); this.setState({ @@ -226,6 +216,22 @@ export default (options: SearchOptions) => ( searchError: undefined, searchResults: [[], []], }; + + componentDidMount() { + this.mounted = true; + this.initializeSearchIndices(); + } + + componentWillUnmount() { + this.mounted = false; + } + + render() { + return React.createElement(Component, { + ...this.props, + ...this.state, + }); + } } return WrappedComponent; diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx new file mode 100644 index 00000000000..63bd6055247 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { accountPermissionsFactory } from 'src/factories/accountPermissions'; +import { accountResourcesFactory } from 'src/factories/accountResources'; +import { userPermissionsFactory } from 'src/factories/userPermissions'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AssignedRolesTable } from './AssignedRolesTable'; + +const queryMocks = vi.hoisted(() => ({ + useAccountPermissions: vi.fn().mockReturnValue({}), + useAccountResources: vi.fn().mockReturnValue({}), + useAccountUserPermissions: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/iam/iam', async () => { + const actual = await vi.importActual('src/queries/iam/iam'); + return { + ...actual, + useAccountPermissions: queryMocks.useAccountPermissions, + useAccountUserPermissions: queryMocks.useAccountUserPermissions, + }; +}); + +vi.mock('src/queries/resources/resources', async () => { + const actual = await vi.importActual('src/queries/resources/resources'); + return { + ...actual, + useAccountResources: queryMocks.useAccountResources, + }; +}); + +describe('AssignedRolesTable', () => { + it('should display no roles text if there are no roles assigned to user', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: {}, + }); + + const { getByText } = renderWithTheme(); + + getByText('No Roles are assigned.'); + }); + + it('should display roles and menu when data is available', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: userPermissionsFactory.build(), + }); + + queryMocks.useAccountPermissions.mockReturnValue({ + data: accountPermissionsFactory.build(), + }); + + queryMocks.useAccountResources.mockReturnValue({ + data: accountResourcesFactory.build(), + }); + + const { getAllByLabelText, getAllByText, getByText } = renderWithTheme( + + ); + + expect(getByText('account_linode_admin')).toBeInTheDocument(); + expect(getAllByText('All linodes')[0]).toBeInTheDocument(); + + const actionMenuButton = getAllByLabelText('action menu')[0]; + expect(actionMenuButton).toBeInTheDocument(); + + fireEvent.click(actionMenuButton); + expect(getByText('Change Role')).toBeInTheDocument(); + expect(getByText('Unassign Role')).toBeInTheDocument(); + }); + + it('should display empty state when no roles match filters', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: userPermissionsFactory.build(), + }); + + queryMocks.useAccountPermissions.mockReturnValue({ + data: accountPermissionsFactory.build(), + }); + + queryMocks.useAccountResources.mockReturnValue({ + data: accountResourcesFactory.build(), + }); + + const { getByPlaceholderText, getByText } = renderWithTheme( + + ); + + const searchInput = getByPlaceholderText('Search'); + fireEvent.change(searchInput, { target: { value: 'NonExistentRole' } }); + + await waitFor(() => { + expect(getByText('No Roles are assigned.')).toBeInTheDocument(); + }); + }); + + it('should filter roles based on search query', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: userPermissionsFactory.build(), + }); + + queryMocks.useAccountPermissions.mockReturnValue({ + data: accountPermissionsFactory.build(), + }); + + queryMocks.useAccountResources.mockReturnValue({ + data: accountResourcesFactory.build(), + }); + + const { getByPlaceholderText, queryByText } = renderWithTheme( + + ); + + const searchInput = getByPlaceholderText('Search'); + fireEvent.change(searchInput, { + target: { value: 'account_linode_admin' }, + }); + + await waitFor(() => { + expect(queryByText('account_linode_admin')).toBeInTheDocument(); + }); + }); + + it('should filter roles based on selected resource type', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: userPermissionsFactory.build(), + }); + + queryMocks.useAccountPermissions.mockReturnValue({ + data: accountPermissionsFactory.build(), + }); + + queryMocks.useAccountResources.mockReturnValue({ + data: accountResourcesFactory.build(), + }); + + const { getByPlaceholderText, queryByText } = renderWithTheme( + + ); + + const autocomplete = getByPlaceholderText('All Assigned Roles'); + fireEvent.change(autocomplete, { target: { value: 'Firewall Roles' } }); + + await waitFor(() => { + expect(queryByText('firewall_creator')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx new file mode 100644 index 00000000000..486320875dc --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -0,0 +1,385 @@ +import { Autocomplete, Chip, CircleProgress, Typography } from '@linode/ui'; +import { Grid, styled } from '@mui/material'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { CollapsibleTable } from 'src/components/CollapsibleTable/CollapsibleTable'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { + useAccountPermissions, + useAccountUserPermissions, +} from 'src/queries/iam/iam'; +import { useAccountResources } from 'src/queries/resources/resources'; +import { capitalize } from 'src/utilities/capitalize'; + +import { getFilteredRoles } from '../utilities'; + +import type { ExtendedRoleMap, RoleMap } from '../utilities'; +import type { + AccountAccessType, + IamAccess, + IamAccountPermissions, + IamAccountResource, + IamUserPermissions, + ResourceType, + RoleType, + Roles, +} from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; +import type { TableItem } from 'src/components/CollapsibleTable/CollapsibleTable'; + +interface ResourcesType { + label: string; + rawValue: ResourceType; + value?: string; +} + +interface AllResources { + resource: IamAccess; + type: 'account' | 'resource'; +} + +interface CombinedRoles { + id: null | number[]; + name: AccountAccessType | RoleType; +} + +export const AssignedRolesTable = () => { + const { username } = useParams<{ username: string }>(); + + const { + data: accountPermissions, + isLoading: accountPermissionsLoading, + } = useAccountPermissions(); + const { + data: resources, + isLoading: resourcesLoading, + } = useAccountResources(); + const { + data: assignedRoles, + isLoading: assignedRolesLoading, + } = useAccountUserPermissions(username ?? ''); + + const { resourceTypes, roles } = React.useMemo(() => { + if (!assignedRoles || !accountPermissions) { + return { resourceTypes: [], roles: [] }; + } + + const userRoles = combineRoles(assignedRoles); + let roles = mapRolesToPermissions(accountPermissions, userRoles); + const resourceTypes = getResourceTypes(roles); + + if (resources) { + roles = addResourceNamesToRoles(roles, resources); + } + + return { resourceTypes, roles }; + }, [assignedRoles, accountPermissions, resources]); + + const [query, setQuery] = React.useState(''); + + const [resourceType, setResourceType] = React.useState( + null + ); + + const memoizedTableItems: TableItem[] = React.useMemo(() => { + const filteredRoles = getFilteredRoles({ + query, + resourceType: resourceType?.rawValue, + roles, + }); + + return filteredRoles.map((role: ExtendedRoleMap) => { + const resources = role.resource_names?.map((name: string) => ( + + )); + + const accountMenu: Action[] = [ + { + onClick: () => { + // mock + }, + title: 'Change Role', + }, + { + onClick: () => { + // mock + }, + title: 'Unassign Role', + }, + ]; + + const entitiesMenu: Action[] = [ + { + onClick: () => { + // mock + }, + title: 'View Entities', + }, + { + onClick: () => { + // mock + }, + title: 'Update List of Entities', + }, + { + onClick: () => { + // mock + }, + title: 'Change Role', + }, + { + onClick: () => { + // mock + }, + title: 'Unassign Role', + }, + ]; + + const actions = role.access === 'account' ? accountMenu : entitiesMenu; + + const OuterTableCells = ( + <> + {role.access === 'account' ? ( + + + {role.resource_type === 'account' + ? 'All entities' + : `All ${role.resource_type}s`} + + + ) : ( + {resources} + )} + + + + + ); + + const InnerTable = ( + ({ + background: theme.color.grey5, + paddingBottom: 1.5, + paddingLeft: 4.5, + paddingRight: 4.5, + paddingTop: 1.5, + })} + > + Description: + {role.description} + + ); + + return { + InnerTable, + OuterTableCells, + id: role.id, + label: role.name, + }; + }); + }, [roles, query, resourceType]); + + if (accountPermissionsLoading || resourcesLoading || assignedRolesLoading) { + return ; + } + + return ( + + + + setResourceType(selected ?? null)} + options={resourceTypes} + placeholder="All Assigned Roles" + value={resourceType} + /> + + + } + TableItems={memoizedTableItems} + TableRowHead={RoleTableRowHead} + /> + + ); +}; + +const RoleTableRowHead = ( + + Role + Entities + + +); + +/** + * Group account_access and resource_access roles of the user + * + */ +const combineRoles = (data: IamUserPermissions): CombinedRoles[] => { + const combinedRoles: CombinedRoles[] = []; + const roleMap: Map = new Map(); + + // Add account access roles with resource_id set to null + data.account_access.forEach((role: AccountAccessType) => { + if (!roleMap.has(role)) { + roleMap.set(role, null); + } + }); + + // Add resource access roles with their respective resource_id + data.resource_access.forEach( + (resource: { resource_id: number; roles: RoleType[] }) => { + resource.roles?.forEach((role: RoleType) => { + if (roleMap.has(role)) { + const existingResourceIds = roleMap.get(role); + if (existingResourceIds && existingResourceIds !== null) { + roleMap.set(role, [...existingResourceIds, resource.resource_id]); + } + } else { + roleMap.set(role, [resource.resource_id]); + } + }); + } + ); + + // Convert the Map into the final combinedRoles array + roleMap.forEach((id, name) => { + combinedRoles.push({ id, name }); + }); + + return combinedRoles; +}; + +/** + * Add descriptions, permissions, type to roles + */ +const mapRolesToPermissions = ( + accountPermissions: IamAccountPermissions, + userRoles: { + id: null | number[]; + name: AccountAccessType | RoleType; + }[] +): RoleMap[] => { + const roleMap = new Map(); + + // Flatten resources and map roles for quick lookup + const allResources11: AllResources[] = [ + ...accountPermissions.account_access.map((resource) => ({ + resource, + type: 'account' as const, + })), + ...accountPermissions.resource_access.map((resource) => ({ + resource, + type: 'resource' as const, + })), + ]; + + const roleLookup = new Map(); + allResources11.forEach(({ resource, type }) => { + resource.roles.forEach((role: Roles) => { + roleLookup.set(role.name, { resource, type }); + }); + }); + + // Map userRoles to permissions + userRoles.forEach(({ id, name }) => { + const match = roleLookup.get(name); + if (match) { + const { resource, type } = match; + const role = resource.roles.find((role: Roles) => role.name === name)!; + roleMap.set(name, { + access: type, + description: role.description, + id: name, + name, + permissions: role.permissions, + resource_ids: id, + resource_type: resource.resource_type, + }); + } + }); + + return Array.from(roleMap.values()); +}; + +const addResourceNamesToRoles = ( + roles: ExtendedRoleMap[], + resources: IamAccountResource +): ExtendedRoleMap[] => { + const resourcesArray: IamAccountResource[] = Object.values(resources); + + return roles.map((role) => { + // Find the resource group by resource_type + const resourceGroup = resourcesArray.find( + (res) => res.resource_type === role.resource_type + ); + + if (resourceGroup && role.resource_ids) { + // Map resource_ids to their names + const resourceNames = role.resource_ids + .map( + (id) => + resourceGroup.resources.find((resource) => resource.id === id)?.name + ) + .filter((name): name is string => name !== undefined); // Remove undefined values + + return { ...role, resource_names: resourceNames }; + } + + // If no matching resource_type, return the role unchanged + return { ...role, resource_names: [] }; + }); +}; + +const getResourceTypes = (data: RoleMap[]): ResourcesType[] => { + const resourceTypes = Array.from( + new Set(data.map((el: RoleMap) => el.resource_type)) + ); + + return resourceTypes.map((resource: ResourceType) => ({ + label: capitalize(resource) + ` Roles`, + rawValue: resource, + value: capitalize(resource) + ` Roles`, + })); +}; + +export const StyledTypography = styled(Typography, { + label: 'StyledTypography', +})(({ theme }) => ({ + color: + theme.name === 'light' + ? theme.tokens.color.Neutrals[90] + : theme.tokens.color.Neutrals.Black, + fontFamily: theme.font.bold, + marginBottom: 0, +})); diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts new file mode 100644 index 00000000000..02ad01cbe27 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts @@ -0,0 +1,68 @@ +import { Box, Typography } from '@linode/ui'; +import { Grid } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export const sxTooltipIcon = { + marginLeft: 1, + padding: 0, +}; + +export const StyledTypography = styled(Typography, { + label: 'StyledTypography', +})(({ theme }) => ({ + fontFamily: theme.font.bold, + marginBottom: 0, + paddingLeft: theme.spacing(0.5), +})); + +export const StyledGrid = styled(Grid, { label: 'StyledGrid' })(() => ({ + alignItems: 'center', + marginBottom: 0, +})); + +export const StyledPermissionItem = styled(Typography, { + label: 'StyledPermissionItem', +})(({ theme }) => ({ + borderRight: `1px solid ${theme.tokens.border.Normal}`, + display: 'inline-block', + padding: `0px ${theme.spacing(0.75)} ${theme.spacing(0.25)}`, +})); + +export const StyledContainer = styled('div', { + label: 'StyledContainer', +})(() => ({ + position: 'relative', +})); + +export const StyledClampedContent = styled('div', { + label: 'StyledClampedContent', +})<{ showAll?: boolean }>(({ showAll }) => ({ + WebkitBoxOrient: 'vertical', + WebkitLineClamp: showAll ? 'unset' : 2, + display: '-webkit-box', + overflow: 'hidden', +})); + +export const StyledBox = styled(Box, { + label: 'StyledBox', +})(({ theme }) => ({ + backgroundColor: + theme.name === 'light' + ? theme.tokens.color.Neutrals.White + : theme.tokens.color.Neutrals[90], + bottom: 0, + display: 'flex', + justifyContent: 'space-between', + position: 'absolute', + right: 0, +})); + +export const StyledSpan = styled(Typography, { label: 'StyledSpan' })( + ({ theme }) => ({ + borderRight: `1px solid ${theme.tokens.border.Normal}`, + bottom: 0, + marginRight: theme.spacing(0.5), + paddingLeft: theme.spacing(0.5), + paddingRight: theme.spacing(0.5), + }) +); diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.test.tsx b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.test.tsx new file mode 100644 index 00000000000..6b3068f99e7 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Permissions } from './Permissions'; + +import type { PermissionType } from '@linode/api-v4/lib/iam/types'; + +const mockPermissions: PermissionType[] = ['cancel_account']; + +const mockPermissionsLong: PermissionType[] = [ + 'list_payments', + 'list_invoices', + 'list_payment_methods', + 'view_invoice', + 'list_invoice_items', + 'view_payment_method', + 'view_payment', +]; + +describe('Permissions', () => { + it('renders the correct number of permission chips', () => { + const { getAllByTestId, getByText } = renderWithTheme( + + ); + + const chips = getAllByTestId('permission'); + expect(chips).toHaveLength(1); + + expect(getByText('cancel_account')).toBeInTheDocument(); + }); + + it('renders the title', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Permissions')).toBeInTheDocument(); + }); + + it('renders the correct number of permission chips', () => { + const { getAllByTestId } = renderWithTheme( + + ); + + const chips = getAllByTestId('permission'); + expect(chips).toHaveLength(mockPermissionsLong.length); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx new file mode 100644 index 00000000000..ceb21fffd77 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx @@ -0,0 +1,116 @@ +import { StyledLinkButton, TooltipIcon } from '@linode/ui'; +import { debounce } from '@mui/material'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; + +import { + StyledBox, + StyledClampedContent, + StyledContainer, + StyledGrid, + StyledPermissionItem, + StyledSpan, + StyledTypography, + sxTooltipIcon, +} from './Permissions.style'; + +import type { PermissionType } from '@linode/api-v4/lib/iam/types'; + +type Props = { + permissions: PermissionType[]; +}; + +export const Permissions = ({ permissions }: Props) => { + const [showAll, setShowAll] = React.useState(false); + const [numHiddenItems, setNumHiddenItems] = React.useState(0); + + const containerRef = React.useRef(null); + + const itemRefs = React.useRef<(HTMLSpanElement | null)[]>([]); + + const calculateHiddenItems = React.useCallback(() => { + if (showAll || !containerRef.current) { + setNumHiddenItems(0); + return; + } + + if (!itemRefs.current) { + return; + } + + const containerBottom = containerRef.current.getBoundingClientRect().bottom; + + const itemsArray = Array.from(itemRefs.current); + + const firstHiddenIndex = itemsArray.findIndex( + (item: HTMLParagraphElement) => { + const rect = item.getBoundingClientRect(); + return rect.top >= containerBottom; + } + ); + + const numHiddenItems = firstHiddenIndex + ? itemsArray.length - firstHiddenIndex + : 0; + + setNumHiddenItems(numHiddenItems); + }, [showAll, permissions]); + + const handleResize = React.useMemo( + () => debounce(() => calculateHiddenItems(), 100), + [calculateHiddenItems] + ); + + React.useEffect(() => { + // Ensure calculateHiddenItems runs after layout stabilization on initial render + const rafId = requestAnimationFrame(() => calculateHiddenItems()); + + window.addEventListener('resize', handleResize); + + return () => { + cancelAnimationFrame(rafId); + window.removeEventListener('resize', handleResize); + }; + }, [calculateHiddenItems, handleResize]); + + return ( + + + Permissions + + + + + + + {numHiddenItems > 0 && +{numHiddenItems} } + setShowAll(!showAll)}> + {showAll ? 'Hide' : ` Expand`} + + + + {permissions.map((permission: PermissionType, index: number) => ( + + (itemRefs.current[index] = el) + } + data-testid="permission" + key={permission} + > + {permission} + + ))} + + + + ); +}; diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index fef7ff9da58..b687a7d5082 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -1,5 +1,12 @@ import { useFlags } from 'src/hooks/useFlags'; +import type { + AccountAccessType, + PermissionType, + ResourceTypePermissions, + RoleType, +} from '@linode/api-v4'; + /** * Hook to determine if the IAM feature should be visible to the user. * Based on the user's account capability and the feature flag. @@ -30,3 +37,86 @@ export const placeholderMap: Record = { volume: 'Select Volumes', vpc: 'Select VPCs', }; + +interface FilteredRolesOptions { + query: string; + resourceType?: string; + roles: RoleMap[]; +} + +export interface RoleMap { + access: 'account' | 'resource'; + description: string; + id: AccountAccessType | RoleType; + name: AccountAccessType | RoleType; + permissions: PermissionType[]; + resource_ids: null | number[]; + resource_type: ResourceTypePermissions; +} +export interface ExtendedRoleMap extends RoleMap { + resource_names?: string[]; +} + +export const getFilteredRoles = (options: FilteredRolesOptions) => { + const { query, resourceType, roles } = options; + + return roles.filter((role: ExtendedRoleMap) => { + if (query && resourceType) { + return ( + getDoesRolesMatchQuery(query, role) && + getDoesRolesMatchType(resourceType, role) + ); + } + + if (query) { + return getDoesRolesMatchQuery(query, role); + } + + if (resourceType) { + return getDoesRolesMatchType(resourceType, role); + } + + return true; + }); +}; + +/** + * Checks if the given Role has a type + * + * @param resourceType The type to check for + * @param role The role to compare against + * @returns true if the given role has the given type + */ +const getDoesRolesMatchType = (resourceType: string, role: ExtendedRoleMap) => { + return role.resource_type === resourceType; +}; + +/** + * Compares a Role details to a given text search query + * + * @param query the current search query + * @param role the Role to compare aginst + * @returns true if the Role matches the given query + */ +const getDoesRolesMatchQuery = (query: string, role: ExtendedRoleMap) => { + const queryWords = query + .replace(/[,.-]/g, '') + .trim() + .toLocaleLowerCase() + .split(' '); + const resourceNames = role.resource_names || []; + + const searchableFields = [ + String(role.id), + role.resource_type, + role.name, + role.access, + role.description, + ...resourceNames, + ...role.permissions, + ]; + + return searchableFields.some((field) => + queryWords.some((queryWord) => field.toLowerCase().includes(queryWord)) + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 4a376173a90..f902aacb30e 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -94,7 +94,7 @@ export const UserDetailsLanding = () => { - + diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx index 15d16731851..2ef26742a0e 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx @@ -4,20 +4,42 @@ import React from 'react'; import { useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { useAccountUserPermissions } from 'src/queries/iam/iam'; import { NO_ASSIGNED_ROLES_TEXT } from '../../Shared/constants'; -import { Entities } from '../../Shared/Entities/Entities'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; +import { Permissions } from '../../Shared/Permissions/Permissions'; -import type { IamUserPermissions } from '@linode/api-v4'; +import type { PermissionType } from '@linode/api-v4'; -interface Props { - assignedRoles?: IamUserPermissions; -} +// just for demonstaring the Permissions component. +// it will be gone with the AssignedPermissions Component in the next PR +const mockPermissionsLong: PermissionType[] = [ + 'create_nodebalancer', + 'list_nodebalancers', + 'view_nodebalancer', + 'list_nodebalancer_firewalls', + 'view_nodebalancer_statistics', + 'list_nodebalancer_configs', + 'view_nodebalancer_config', + 'list_nodebalancer_config_nodes', + 'view_nodebalancer_config_node', + 'update_nodebalancer', + 'add_nodebalancer_config', + 'update_nodebalancer_config', + 'rebuild_nodebalancer_config', + 'add_nodebalancer_config_node', + 'update_nodebalancer_config_node', + 'delete_nodebalancer', + 'delete_nodebalancer_config', + 'delete_nodebalancer_config_node', +]; -export const UserRoles = ({ assignedRoles }: Props) => { +export const UserRoles = () => { const { username } = useParams<{ username: string }>(); + const { data: assignedRoles } = useAccountUserPermissions(username ?? ''); + const handleClick = () => { // mock for UIE-8140: RBAC-4: User Roles - Assign New Role }; @@ -42,11 +64,18 @@ export const UserRoles = ({ assignedRoles }: Props) => { {hasAssignedRoles ? (

UIE-8138 - assigned roles table

- {/* just for showing the Entities componnet, it will be gone wuth the AssignedPermissions component*/} - - - + {/* just for showing the Permissions componnet, it will be gone with the AssignedPermissions component*/} + +
+ +
) : ( diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx index de646aa97e2..5c67f4c31c0 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -10,13 +10,27 @@ import { } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { CreateImageTab } from './CreateImageTab'; +const queryMocks = vi.hoisted(() => ({ + useSearch: vi.fn().mockReturnValue({ query: undefined }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useSearch: queryMocks.useSearch, + }; +}); + describe('CreateImageTab', () => { - it('should render fields, titles, and buttons in their default state', () => { - const { getByLabelText, getByText } = renderWithTheme(); + it('should render fields, titles, and buttons in their default state', async () => { + const { getByLabelText, getByText } = await renderWithThemeAndRouter( + + ); expect(getByText('Select Linode & Disk')).toBeVisible(); @@ -54,14 +68,15 @@ describe('CreateImageTab', () => { }) ); - const { getByLabelText } = renderWithTheme(, { - MemoryRouter: { - initialEntries: [ - `/images/create/disk?selectedLinode=${linode.id}&selectedDisk=${disk.id}`, - ], - }, + queryMocks.useSearch.mockReturnValue({ + selectedDisk: disk.id, + selectedLinode: linode.id, }); + const { getByLabelText } = await renderWithThemeAndRouter( + + ); + await waitFor(() => { expect(getByLabelText('Linode')).toHaveValue(linode.label); expect(getByLabelText('Disk')).toHaveValue(disk.label); @@ -69,7 +84,11 @@ describe('CreateImageTab', () => { }); it('should render client side validation errors', async () => { - const { getByText } = renderWithTheme(); + queryMocks.useSearch.mockReturnValue({ + selectedDisk: undefined, + selectedLinode: undefined, + }); + const { getByText } = await renderWithThemeAndRouter(); const submitButton = getByText('Create Image').closest('button'); @@ -100,7 +119,7 @@ describe('CreateImageTab', () => { getByLabelText, getByText, queryByText, - } = renderWithTheme(); + } = await renderWithThemeAndRouter(); const linodeSelect = getByLabelText('Linode'); @@ -130,40 +149,7 @@ describe('CreateImageTab', () => { await findByText('Image scheduled for creation.'); }); - it('should render a notice if the user selects a Linode in a distributed compute region', async () => { - const region = regionFactory.build({ site_type: 'distributed' }); - const linode = linodeFactory.build({ region: region.id }); - - server.use( - http.get('*/v4/linode/instances', () => { - return HttpResponse.json(makeResourcePage([linode])); - }), - http.get('*/v4/linode/instances/:id', () => { - return HttpResponse.json(linode); - }), - http.get('*/v4/regions', () => { - return HttpResponse.json(makeResourcePage([region])); - }) - ); - - const { findByText, getByLabelText } = renderWithTheme(); - - const linodeSelect = getByLabelText('Linode'); - - await userEvent.click(linodeSelect); - - const linodeOption = await findByText(linode.label); - - await userEvent.click(linodeOption); - - // Verify distributed compute region notice renders - await findByText( - "This Linode is in a distributed compute region. These regions can't store images.", - { exact: false } - ); - }); - - it('should render a notice if the user selects a Linode in a region that does not support image storage and Image Service Gen 2 GA is enabled', async () => { + it('should render a notice if the user selects a Linode in a region that does not support image storage', async () => { const region = regionFactory.build({ capabilities: [] }); const linode = linodeFactory.build({ region: region.id }); @@ -179,9 +165,9 @@ describe('CreateImageTab', () => { }) ); - const { findByText, getByLabelText } = renderWithTheme(, { - flags: { imageServiceGen2: true, imageServiceGen2Ga: true }, - }); + const { findByText, getByLabelText } = await renderWithThemeAndRouter( + + ); const linodeSelect = getByLabelText('Linode'); @@ -218,9 +204,11 @@ describe('CreateImageTab', () => { }) ); - const { findByText, getByLabelText, queryByText } = renderWithTheme( - - ); + const { + findByText, + getByLabelText, + queryByText, + } = await renderWithThemeAndRouter(); const linodeSelect = getByLabelText('Linode'); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index cc7aa2ecff9..ff3d7726214 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -12,10 +12,10 @@ import { Typography, } from '@linode/ui'; import { createImageSchema } from '@linode/validation'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { useHistory, useLocation } from 'react-router-dom'; import { Link } from 'src/components/Link'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; @@ -29,21 +29,17 @@ import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useGrants } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import type { CreateImagePayload } from '@linode/api-v4'; -import type { LinodeConfigAndDiskQueryParams } from 'src/features/Linodes/types'; export const CreateImageTab = () => { - const location = useLocation(); - - const queryParams = React.useMemo( - () => - getQueryParamsFromQueryString( - location.search - ), - [location.search] - ); + const { + selectedDisk: selectedDiskFromSearch, + selectedLinode: selectedLinodeFromSearch, + } = useSearch({ + strict: false, + }); + const navigate = useNavigate(); const { control, @@ -55,7 +51,7 @@ export const CreateImageTab = () => { watch, } = useForm({ defaultValues: { - disk_id: +queryParams.selectedDisk, + disk_id: selectedDiskFromSearch ? +selectedDiskFromSearch : undefined, }, mode: 'onBlur', resolver: yupResolver(createImageSchema), @@ -64,7 +60,6 @@ export const CreateImageTab = () => { const flags = useFlags(); const { enqueueSnackbar } = useSnackbar(); - const { push } = useHistory(); const { mutateAsync: createImage } = useCreateImageMutation(); @@ -85,7 +80,10 @@ export const CreateImageTab = () => { enqueueSnackbar('Image scheduled for creation.', { variant: 'info', }); - push('/images'); + navigate({ + search: () => ({}), + to: '/images', + }); } catch (errors) { for (const error of errors) { if (error.field) { @@ -98,7 +96,7 @@ export const CreateImageTab = () => { }); const [selectedLinodeId, setSelectedLinodeId] = React.useState( - queryParams.selectedLinode ? +queryParams.selectedLinode : null + selectedLinodeFromSearch ? +selectedLinodeFromSearch : null ); const { data: selectedLinode } = useLinodeQuery( @@ -141,9 +139,6 @@ export const CreateImageTab = () => { (r) => r.id === selectedLinode?.region ); - const linodeIsInDistributedRegion = - selectedLinodeRegion?.site_type === 'distributed'; - /** * The 'Object Storage' capability indicates a region can store images */ @@ -213,33 +208,18 @@ export const CreateImageTab = () => { required value={selectedLinodeId} /> - {selectedLinode && - !linodeRegionSupportsImageStorage && - flags.imageServiceGen2 && - flags.imageServiceGen2Ga && ( - - This Linode’s region doesn’t support local image storage. This - image will be stored in the core compute region that’s{' '} - - geographically closest - - . After it’s stored, you can replicate it to other{' '} - - core compute regions - - . - - )} - {linodeIsInDistributedRegion && !flags.imageServiceGen2Ga && ( + {selectedLinode && !linodeRegionSupportsImageStorage && ( - This Linode is in a distributed compute region. These regions - can't store images. The image is stored in the core compute - region that is{' '} - + This Linode’s region doesn’t support local image storage. This + image will be stored in the core compute region that’s{' '} + geographically closest - . After it's stored, you can replicate it to other core compute - regions. + . After it’s stored, you can replicate it to other{' '} + + core compute regions + + . )} - import('./ImageUpload').then((module) => ({ default: module.ImageUpload })) -); +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useTabs } from 'src/hooks/useTabs'; const CreateImageTab = React.lazy(() => import('./CreateImageTab').then((module) => ({ @@ -18,34 +14,38 @@ const CreateImageTab = React.lazy(() => })) ); -export const ImageCreate = () => { - const { url } = useRouteMatch(); +const ImageUpload = React.lazy(() => + import('./ImageUpload').then((module) => ({ default: module.ImageUpload })) +); - const tabs: NavTab[] = [ +export const ImageCreate = () => { + const { handleTabChange, tabIndex, tabs } = useTabs([ { - render: , - routeName: `${url}/disk`, title: 'Capture Image', + to: '/images/create/disk', }, { - render: , - routeName: `${url}/upload`, title: 'Upload Image', + to: '/images/create/upload', }, - ]; + ]); return ( <> - }> - - + + + }> + + + + + + + + + + ); }; - -export const imageCreateLazyRoute = createLazyRoute('/images/create')({ - component: ImageCreate, -}); - -export default ImageCreate; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreateContainer.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreateContainer.tsx index 1bf25b5bac3..634845d5c5b 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreateContainer.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreateContainer.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; -import ImageCreate from './ImageCreate'; +import { ImageCreate } from './ImageCreate'; export const ImagesCreateContainer = () => { return ( @@ -21,5 +21,3 @@ export const ImagesCreateContainer = () => { ); }; - -export default ImagesCreateContainer; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index dcdf542b18e..166c47d6b01 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -9,12 +9,12 @@ import { TextField, Typography, } from '@linode/ui'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import React, { useState } from 'react'; import { flushSync } from 'react-dom'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { useDispatch } from 'react-redux'; -import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; @@ -45,19 +45,18 @@ import { uploadImageFile } from '../requests'; import { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; -import type { - ImageUploadFormData, - ImageUploadNavigationState, -} from './ImageUpload.utils'; +import type { ImageUploadFormData } from './ImageUpload.utils'; import type { AxiosError, AxiosProgressEvent } from 'axios'; import type { Dispatch } from 'src/hooks/types'; export const ImageUpload = () => { - const { location } = useHistory(); + const { imageDescription, imageLabel } = useSearch({ + strict: false, + }); + const navigate = useNavigate(); const dispatch = useDispatch(); const hasPendingUpload = usePendingUpload(); - const { push } = useHistory(); const flags = useFlags(); const [uploadProgress, setUploadProgress] = useState(); @@ -74,8 +73,8 @@ export const ImageUpload = () => { const form = useForm({ defaultValues: { - description: location.state?.imageDescription, - label: location.state?.imageLabel, + description: imageDescription, + label: imageLabel, }, mode: 'onBlur', resolver: yupResolver(ImageUploadSchema), @@ -125,7 +124,7 @@ export const ImageUpload = () => { dispatch(setPendingUpload(false)); }); - push('/images'); + navigate({ search: () => ({}), to: '/images' }); } catch (error) { // Handle an Axios error for the actual image upload form.setError('root', { message: (error as AxiosError).message }); @@ -173,7 +172,7 @@ export const ImageUpload = () => { dispatch(setPendingUpload(false)); - push(nextLocation); + navigate({ search: () => ({}), to: nextLocation }); }; return ( @@ -257,11 +256,6 @@ export const ImageUpload = () => { ( { inputRef: field.ref, onBlur: field.onBlur, }} + currentCapability="Object Storage" // Images use Object Storage as their storage backend disableClearable errorText={fieldState.error?.message} ignoreAccountAvailability diff --git a/packages/manager/src/features/Images/ImagesCreate/index.ts b/packages/manager/src/features/Images/ImagesCreate/index.ts deleted file mode 100644 index 89b3e6e91ec..00000000000 --- a/packages/manager/src/features/Images/ImagesCreate/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import ImageCreate from './ImageCreateContainer'; -export default ImageCreate; diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx index d21af710b74..c6e2c4ebd6e 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx @@ -9,6 +9,7 @@ import { EditImageDrawer } from './EditImageDrawer'; const props = { image: imageFactory.build(), + isFetching: false, onClose: vi.fn(), open: true, }; diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx index f2d07646087..9c591ab1ba5 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -15,11 +15,12 @@ import type { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; interface Props { image: Image | undefined; + isFetching: boolean; onClose: () => void; open: boolean; } export const EditImageDrawer = (props: Props) => { - const { image, onClose, open } = props; + const { image, isFetching, onClose, open } = props; const { canCreateImage } = useImageAndLinodeGrantCheck(); @@ -80,7 +81,12 @@ export const EditImageDrawer = (props: Props) => { }; return ( - + {!canCreateImage && ( { currentCapability="Object Storage" // Images use Object Storage as the storage backend disabledRegions={disabledRegions} errorText={errors.regions?.message} + ignoreAccountAvailability // Ignore the account capability because we are just using "Object Storage" for region compatibility label="Add Regions" placeholder="Select regions or type to search" regions={regions?.filter((r) => r.site_type === 'core') ?? []} diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index 0440e31b822..bdd27451cd9 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -21,13 +21,11 @@ describe('Image Table Row', () => { onDeploy: vi.fn(), onEdit: vi.fn(), onManageRegions: vi.fn(), - onRestore: vi.fn(), - onRetry: vi.fn(), + onRebuild: vi.fn(), }; - it('should render an image row with Image Service Gen2 enabled', async () => { + it('should render an image row with details', async () => { const image = imageFactory.build({ - capabilities: ['cloud-init', 'distributed-sites'], regions: [ { region: 'us-east', status: 'available' }, { region: 'us-southeast', status: 'available' }, @@ -37,16 +35,13 @@ describe('Image Table Row', () => { }); const { getByLabelText, getByText } = renderWithTheme( - wrapWithTableBody( - - ) + wrapWithTableBody() ); // Check to see if the row rendered some data expect(getByText(image.label)).toBeVisible(); expect(getByText(image.id)).toBeVisible(); expect(getByText('Available')).toBeVisible(); - expect(getByText('Cloud-init, Distributed')).toBeVisible(); expect(getByText('2 Regions')).toBeVisible(); expect(getByText('0.29 GB')).toBeVisible(); // Size is converted from MB to GB - 300 / 1024 = 0.292 expect(getByText('0.59 GB')).toBeVisible(); // Size is converted from MB to GB - 600 / 1024 = 0.585 @@ -62,17 +57,14 @@ describe('Image Table Row', () => { expect(getByText('Delete')).toBeVisible(); }); - it('should show a cloud-init icon with a tooltip when Image Service Gen 2 GA is enabled and the image supports cloud-init', () => { + it('should show a cloud-init icon if the image supports it', () => { const image = imageFactory.build({ capabilities: ['cloud-init'], regions: [{ region: 'us-east', status: 'available' }], }); const { getByLabelText } = renderWithTheme( - wrapWithTableBody( - , - { flags: { imageServiceGen2: true, imageServiceGen2Ga: true } } - ) + wrapWithTableBody() ); expect( @@ -80,29 +72,48 @@ describe('Image Table Row', () => { ).toBeVisible(); }); - it('does not show the compatibility column when Image Service Gen2 GA is enabled', () => { + it('should show an unencrypted icon if the image is not "Gen2" (does not have the distributed-site capability)', () => { const image = imageFactory.build({ - capabilities: ['cloud-init', 'distributed-sites'], + capabilities: ['cloud-init'], + regions: [], + status: 'available', + }); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect( + getByLabelText('This image is not encrypted.', { exact: false }) + ).toBeVisible(); + }); + + it('should not show an unencrypted icon when a "Gen2" Image is still "creating"', () => { + // The API does not populate the "distributed-sites" capability until the image is done creating. + // We must account for this because the image would show as "Unencrypted" while it is creating, + // then suddenly show as encrypted once it was done creating. We don't want that. + // Therefore, we decided we won't show the unencrypted icon until the image is done creating to + // prevent confusion. + const image = imageFactory.build({ + capabilities: ['cloud-init'], + status: 'creating', + type: 'manual', }); - const { queryByText } = renderWithTheme( - wrapWithTableBody( - , - { flags: { imageServiceGen2: true, imageServiceGen2Ga: true } } - ) + const { queryByLabelText } = renderWithTheme( + wrapWithTableBody() ); - expect(queryByText('Cloud-init, Distributed')).not.toBeInTheDocument(); + expect( + queryByLabelText('This image is not encrypted.', { exact: false }) + ).toBeNull(); }); - it('should show N/A if multiRegionsEnabled is true, but the Image does not have any regions', () => { + it('should show N/A if Image does not have any regions', () => { const image = imageFactory.build({ regions: [] }); const { getByText } = renderWithTheme( - wrapWithTableBody( - , - { flags: { imageServiceGen2: true } } - ) + wrapWithTableBody() ); expect(getByText('N/A')).toBeVisible(); @@ -114,9 +125,7 @@ describe('Image Table Row', () => { }); const { getByLabelText, getByText } = renderWithTheme( - wrapWithTableBody( - - ) + wrapWithTableBody() ); // Open action menu @@ -133,13 +142,9 @@ describe('Image Table Row', () => { expect(handlers.onDeploy).toBeCalledWith(image.id); await userEvent.click(getByText('Rebuild an Existing Linode')); - expect(handlers.onRestore).toBeCalledWith(image); + expect(handlers.onRebuild).toBeCalledWith(image); await userEvent.click(getByText('Delete')); - expect(handlers.onDelete).toBeCalledWith( - image.label, - image.id, - image.status - ); + expect(handlers.onDelete).toBeCalledWith(image); }); }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 1aa8bc50842..4e9ff09d3dc 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -2,11 +2,11 @@ import { Stack, Tooltip } from '@linode/ui'; import React from 'react'; import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; +import UnlockIcon from 'src/assets/icons/unlock.svg'; import { Hidden } from 'src/components/Hidden'; import { LinkButton } from 'src/components/LinkButton'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useFlags } from 'src/hooks/useFlags'; import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { pluralize } from 'src/utilities/pluralize'; @@ -16,22 +16,16 @@ import { ImagesActionMenu } from './ImagesActionMenu'; import { ImageStatus } from './ImageStatus'; import type { Handlers } from './ImagesActionMenu'; -import type { Event, Image, ImageCapabilities } from '@linode/api-v4'; - -const capabilityMap: Record = { - 'cloud-init': 'Cloud-init', - 'distributed-sites': 'Distributed', -}; +import type { Event, Image } from '@linode/api-v4'; interface Props { event?: Event; handlers: Handlers; image: Image; - multiRegionsEnabled?: boolean; // TODO Image Service v2: delete after GA } export const ImageRow = (props: Props) => { - const { event, handlers, image, multiRegionsEnabled } = props; + const { event, handlers, image } = props; const { capabilities, @@ -43,14 +37,10 @@ export const ImageRow = (props: Props) => { size, status, total_size, + type, } = image; const { data: profile } = useProfile(); - const flags = useFlags(); - - const compatibilitiesList = multiRegionsEnabled - ? capabilities.map((capability) => capabilityMap[capability]).join(', ') - : ''; const isFailedUpload = image.status === 'pending_upload' && event?.status === 'failed'; @@ -79,27 +69,39 @@ export const ImageRow = (props: Props) => { return ( - {capabilities.includes('cloud-init') && - flags.imageServiceGen2 && - flags.imageServiceGen2Ga ? ( - - -
- -
-
- {label} + + {label} + + {type === 'manual' && + status !== 'creating' && + !image.capabilities.includes('distributed-sites') && ( + +
+ +
+
+ )} + {type === 'manual' && capabilities.includes('cloud-init') && ( + +
+ +
+
+ )}
- ) : ( - label - )} +
- {multiRegionsEnabled && ( + {type === 'manual' && ( {regions.length > 0 ? ( @@ -112,15 +114,10 @@ export const ImageRow = (props: Props) => { )} - {multiRegionsEnabled && !flags.imageServiceGen2Ga && ( - - {compatibilitiesList} - - )} {getSizeForImage(size, status, event?.status)} - {multiRegionsEnabled && ( + {type === 'manual' && ( {getSizeForImage(total_size, status, event?.status)} @@ -134,16 +131,18 @@ export const ImageRow = (props: Props) => { })} - - {expiry && ( + {type === 'automatic' && ( + - {formatDate(expiry, { - timezone: profile?.timezone, - })} + {expiry + ? formatDate(expiry, { + timezone: profile?.timezone, + }) + : 'N/A'} - )} - - {multiRegionsEnabled && ( + + )} + {type === 'manual' && ( {id} diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index b664da0917d..e69469645c7 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -1,26 +1,22 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; - -import type { Event, Image, ImageStatus } from '@linode/api-v4'; -import type { Action } from 'src/components/ActionMenu/ActionMenu'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; + import { useImageAndLinodeGrantCheck } from '../utils'; +import type { Event, Image } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + export interface Handlers { onCancelFailed?: (imageID: string) => void; - onDelete?: (label: string, imageID: string, status?: ImageStatus) => void; + onDelete?: (image: Image) => void; onDeploy?: (imageID: string) => void; onEdit?: (image: Image) => void; onManageRegions?: (image: Image) => void; - onRestore?: (image: Image) => void; - onRetry?: ( - imageID: string, - label: string, - description: null | string - ) => void; + onRebuild?: (image: Image) => void; } interface Props { @@ -40,8 +36,7 @@ export const ImagesActionMenu = (props: Props) => { onDeploy, onEdit, onManageRegions, - onRestore, - onRetry, + onRebuild, } = handlers; const isImageReadOnly = useIsResourceRestricted({ @@ -65,94 +60,81 @@ export const ImagesActionMenu = (props: Props) => { const actions: Action[] = React.useMemo(() => { const isDisabled = status && status !== 'available'; const isAvailable = !isDisabled; - const isFailed = event?.status === 'failed'; - return isFailed - ? [ - { - onClick: () => onRetry?.(id, label, description), - title: 'Retry', - }, - { - onClick: () => onCancelFailed?.(id), - title: 'Cancel', - }, - ] - : [ - { - disabled: isImageReadOnly || isDisabled, - onClick: () => onEdit?.(image), - title: 'Edit', - tooltip: isImageReadOnly - ? getRestrictedResourceText({ - action: 'edit', - isSingular: true, - resourceType: 'Images', - }) - : isDisabled - ? 'Image is not yet available for use.' - : undefined, - }, - ...(onManageRegions && image.regions && image.regions.length > 0 - ? [ - { - disabled: isImageReadOnly || isDisabled, - onClick: () => onManageRegions(image), - title: 'Manage Replicas', - tooltip: isImageReadOnly - ? getRestrictedResourceText({ - action: 'edit', - isSingular: true, - resourceType: 'Images', - }) - : undefined, - }, - ] - : []), - { - disabled: isAddLinodeRestricted || isDisabled, - onClick: () => onDeploy?.(id), - title: 'Deploy to New Linode', - tooltip: isAddLinodeRestricted - ? getRestrictedResourceText({ - action: 'create', - isSingular: false, - resourceType: 'Linodes', - }) - : isDisabled - ? 'Image is not yet available for use.' - : undefined, - }, - { - disabled: !isAvailableLinodesPresent || isDisabled, - onClick: () => onRestore?.(image), - title: 'Rebuild an Existing Linode', - tooltip: !isAvailableLinodesPresent - ? getRestrictedResourceText({ - action: 'rebuild', - isSingular: false, - resourceType: 'Linodes', - }) - : isDisabled - ? 'Image is not yet available for use.' - : undefined, - }, - { - disabled: isImageReadOnly, - onClick: () => onDelete?.(label, id, status), - title: isAvailable ? 'Delete' : 'Cancel', - tooltip: isImageReadOnly - ? getRestrictedResourceText({ - action: 'delete', - isSingular: true, - resourceType: 'Images', - }) - : undefined, - }, - ]; + return [ + { + disabled: isImageReadOnly || isDisabled, + onClick: () => onEdit?.(image), + title: 'Edit', + tooltip: isImageReadOnly + ? getRestrictedResourceText({ + action: 'edit', + isSingular: true, + resourceType: 'Images', + }) + : isDisabled + ? 'Image is not yet available for use.' + : undefined, + }, + ...(onManageRegions && image.regions && image.regions.length > 0 + ? [ + { + disabled: isImageReadOnly || isDisabled, + onClick: () => onManageRegions(image), + title: 'Manage Replicas', + tooltip: isImageReadOnly + ? getRestrictedResourceText({ + action: 'edit', + isSingular: true, + resourceType: 'Images', + }) + : undefined, + }, + ] + : []), + { + disabled: isAddLinodeRestricted || isDisabled, + onClick: () => onDeploy?.(id), + title: 'Deploy to New Linode', + tooltip: isAddLinodeRestricted + ? getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Linodes', + }) + : isDisabled + ? 'Image is not yet available for use.' + : undefined, + }, + { + disabled: !isAvailableLinodesPresent || isDisabled, + onClick: () => onRebuild?.(image), + title: 'Rebuild an Existing Linode', + tooltip: !isAvailableLinodesPresent + ? getRestrictedResourceText({ + action: 'rebuild', + isSingular: false, + resourceType: 'Linodes', + }) + : isDisabled + ? 'Image is not yet available for use.' + : undefined, + }, + { + disabled: isImageReadOnly, + onClick: () => onDelete?.(image), + title: isAvailable ? 'Delete' : 'Cancel', + tooltip: isImageReadOnly + ? getRestrictedResourceText({ + action: 'delete', + isSingular: true, + resourceType: 'Images', + }) + : undefined, + }, + ]; }, [ status, event, - onRetry, id, label, description, @@ -161,7 +143,7 @@ export const ImagesActionMenu = (props: Props) => { image, onManageRegions, onDeploy, - onRestore, + onRebuild, onDelete, ]); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx index b2602f268c4..eb69ff694ff 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -1,22 +1,38 @@ -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { grantsFactory, imageFactory, profileFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; +import { + mockMatchMedia, + renderWithThemeAndRouter, +} from 'src/utilities/testHelpers'; import ImagesLanding from './ImagesLanding'; +const queryMocks = vi.hoisted(() => ({ + useParams: vi.fn().mockReturnValue({ action: undefined, imageId: undefined }), + useSearch: vi.fn().mockReturnValue({ query: undefined }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + useSearch: queryMocks.useSearch, + }; +}); + const mockHistory = { push: vi.fn(), replace: vi.fn(), }; -// Mock useHistory vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); + const actual = await vi.importActual('react-router-dom'); return { ...actual, useHistory: vi.fn(() => mockHistory), @@ -41,14 +57,14 @@ describe('Images Landing Table', () => { }) ); - const { getAllByText, getByTestId } = renderWithTheme(, { - flags: { imageServiceGen2: true }, - }); - - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); + const { getAllByText, queryByTestId } = await renderWithThemeAndRouter( + + ); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } // Two tables should render getAllByText('Custom Images'); @@ -58,7 +74,6 @@ describe('Images Landing Table', () => { expect(getAllByText('Image').length).toBe(2); expect(getAllByText('Status').length).toBe(2); expect(getAllByText('Replicated in').length).toBe(1); - expect(getAllByText('Compatibility').length).toBe(1); expect(getAllByText('Original Image').length).toBe(1); expect(getAllByText('All Replicas').length).toBe(1); expect(getAllByText('Created').length).toBe(2); @@ -78,9 +93,15 @@ describe('Images Landing Table', () => { }) ); - const { getByTestId, getByText } = renderWithTheme(); + const { getByText, queryByTestId } = await renderWithThemeAndRouter( + + ); + + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } - await waitForElementToBeRemoved(getByTestId(loadingTestId)); expect(getByText('No Custom Images to display.')).toBeInTheDocument(); }); @@ -97,9 +118,8 @@ describe('Images Landing Table', () => { }) ); - const { getByTestId, getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); expect(getByText('No Recovery Images to display.')).toBeInTheDocument(); }); @@ -110,9 +130,15 @@ describe('Images Landing Table', () => { }) ); - const { getByTestId, getByText } = renderWithTheme(); + const { getByText, queryByTestId } = await renderWithThemeAndRouter( + + ); + + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } - await waitForElementToBeRemoved(getByTestId(loadingTestId)); expect( getByText((text) => text.includes('Store custom Linux images')) ).toBeInTheDocument(); @@ -131,23 +157,33 @@ describe('Images Landing Table', () => { }) ); - const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( - - ); - - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); + const { + getAllByLabelText, + getByTestId, + getByText, + queryByTestId, + rerender, + } = await renderWithThemeAndRouter(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } - // Open action menu const actionMenu = getAllByLabelText( `Action menu for Image ${images[0].label}` )[0]; await userEvent.click(actionMenu); - await userEvent.click(getByText('Edit')); + queryMocks.useParams.mockReturnValue({ action: 'edit' }); + + rerender(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + getByText('Edit Image'); }); @@ -164,24 +200,36 @@ describe('Images Landing Table', () => { }) ); - const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( - - ); - - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); + const { + getAllByLabelText, + getByTestId, + getByText, + queryByTestId, + rerender, + } = await renderWithThemeAndRouter(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } - // Open action menu const actionMenu = getAllByLabelText( `Action menu for Image ${images[0].label}` )[0]; await userEvent.click(actionMenu); - await userEvent.click(getByText('Rebuild an Existing Linode')); - getByText('Rebuild an Existing Linode from an Image'); + queryMocks.useParams.mockReturnValue({ action: 'rebuild' }); + + rerender(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + await waitFor(() => { + getByText('Rebuild an Existing Linode from an Image'); + }); }); it('should allow deploying to a new Linode', async () => { @@ -197,26 +245,26 @@ describe('Images Landing Table', () => { }) ); - const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( - - ); - - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); + const { + getAllByLabelText, + getByText, + queryByTestId, + } = await renderWithThemeAndRouter(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } - // Open action menu const actionMenu = getAllByLabelText( `Action menu for Image ${images[0].label}` )[0]; await userEvent.click(actionMenu); - await userEvent.click(getByText('Deploy to New Linode')); + expect(mockHistory.push).toBeCalledWith({ pathname: '/linodes/create/', search: `?type=Images&imageID=${images[0].id}`, - state: { selectedImageId: images[0].id }, }); }); @@ -233,24 +281,36 @@ describe('Images Landing Table', () => { }) ); - const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( - - ); - - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); + const { + getAllByLabelText, + getByTestId, + getByText, + queryByTestId, + rerender, + } = await renderWithThemeAndRouter(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } - // Open action menu const actionMenu = getAllByLabelText( `Action menu for Image ${images[0].label}` )[0]; await userEvent.click(actionMenu); - await userEvent.click(getByText('Delete')); - getByText(`Delete Image ${images[0].label}`); + queryMocks.useParams.mockReturnValue({ action: 'delete' }); + + rerender(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + await waitFor(() => { + getByText('Are you sure you want to delete this Image?'); + }); }); it('disables the create button if the user does not have permission to create images', async () => { @@ -274,12 +334,14 @@ describe('Images Landing Table', () => { }) ); - const { getByTestId, getByText } = renderWithTheme(); - - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); + const { getByText, queryByTestId } = await renderWithThemeAndRouter( + + ); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } const createImageButton = getByText('Create Image').closest('button'); @@ -326,19 +388,16 @@ describe('Images Landing Table', () => { ); const { - getAllByLabelText, - getByTestId, findAllByLabelText, - } = renderWithTheme(, { - flags: { imageServiceGen2: true }, - }); - - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); + getAllByLabelText, + queryByTestId, + } = await renderWithThemeAndRouter(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const loadingElement = queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } - // Open action menu const actionMenu = getAllByLabelText( `Action menu for Image ${images[0].label}` )[0]; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 5fa53641f7a..0555516a2de 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -1,5 +1,4 @@ import { getAPIFilterFromQuery } from '@linode/search'; -import { Typography } from '@linode/ui'; import { CircleProgress, IconButton, @@ -7,13 +6,15 @@ import { Notice, Paper, TextField, + Typography, } from '@linode/ui'; import CloseIcon from '@mui/icons-material/Close'; import { useQueryClient } from '@tanstack/react-query'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; -import * as React from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import { useHistory } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; @@ -34,9 +35,9 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableSortCell } from 'src/components/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useFlags } from 'src/hooks/useFlags'; -import { useOrder } from 'src/hooks/useOrder'; -import { usePagination } from 'src/hooks/usePagination'; +import { useDialogData } from 'src/hooks/useDialogData'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { isEventImageUpload, @@ -46,10 +47,20 @@ import { useEventsInfiniteQuery } from 'src/queries/events/events'; import { imageQueries, useDeleteImageMutation, + useImageQuery, useImagesQuery, } from 'src/queries/images'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; +import { + AUTOMATIC_IMAGES_DEFAULT_ORDER, + AUTOMATIC_IMAGES_DEFAULT_ORDER_BY, + AUTOMATIC_IMAGES_ORDER_PREFERENCE_KEY, + AUTOMATIC_IMAGES_PREFERENCE_KEY, + MANUAL_IMAGES_DEFAULT_ORDER, + MANUAL_IMAGES_DEFAULT_ORDER_BY, + MANUAL_IMAGES_PREFERENCE_KEY, +} from '../constants'; import { getEventsForImages } from '../utils'; import { EditImageDrawer } from './EditImageDrawer'; import { ManageImageReplicasForm } from './ImageRegions/ManageImageRegionsForm'; @@ -58,10 +69,9 @@ import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; -import type { Filter, ImageStatus } from '@linode/api-v4'; +import type { Filter, Image, ImageStatus } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; - -const searchParamKey = 'query'; +import type { ImageAction, ImagesSearchParams } from 'src/routes/images'; const useStyles = makeStyles()((theme: Theme) => ({ imageTable: { @@ -79,34 +89,37 @@ const useStyles = makeStyles()((theme: Theme) => ({ interface ImageDialogState { error?: string; - image?: string; - imageID?: string; - open: boolean; status?: ImageStatus; submitting: boolean; } -const defaultDialogState = { +const defaultDialogState: ImageDialogState = { error: undefined, - image: '', - imageID: '', - open: false, submitting: false, }; export const ImagesLanding = () => { const { classes } = useStyles(); + const { + action, + imageId: selectedImageId, + }: { action: ImageAction; imageId: string } = useParams({ + strict: false, + }); + const search: ImagesSearchParams = useSearch({ from: '/images' }); + const { query } = search; const history = useHistory(); + const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); - const flags = useFlags(); - const location = useLocation(); const isImagesReadOnly = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_images', }); - const queryParams = new URLSearchParams(location.search); - const query = queryParams.get(searchParamKey) ?? ''; - const queryClient = useQueryClient(); + const [dialogState, setDialogState] = React.useState( + defaultDialogState + ); + const dialogStatus = + dialogState.status === 'pending_upload' ? 'cancel' : 'delete'; /** * At the time of writing: `label`, `tags`, `size`, `status`, `region` are filterable. @@ -132,20 +145,30 @@ export const ImagesLanding = () => { searchableFieldsWithoutOperator: ['label', 'tags'], }); - const paginationForManualImages = usePagination(1, 'images-manual', 'manual'); + const paginationForManualImages = usePaginationV2({ + currentRoute: '/images', + preferenceKey: MANUAL_IMAGES_PREFERENCE_KEY, + searchParams: (prev) => ({ + ...prev, + query: search.query, + }), + }); const { handleOrderChange: handleManualImagesOrderChange, order: manualImagesOrder, orderBy: manualImagesOrderBy, - } = useOrder( - { - order: 'asc', - orderBy: 'label', + } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: MANUAL_IMAGES_DEFAULT_ORDER, + orderBy: MANUAL_IMAGES_DEFAULT_ORDER_BY, + }, + from: '/images', }, - 'images-manual-order', - 'manual' - ); + preferenceKey: MANUAL_IMAGES_PREFERENCE_KEY, + prefix: 'manual', + }); const manualImagesFilter: Filter = { ['+order']: manualImagesOrder, @@ -182,23 +205,30 @@ export const ImagesLanding = () => { ); // Pagination, order, and query hooks for automatic/recovery images - const paginationForAutomaticImages = usePagination( - 1, - 'images-automatic', - 'automatic' - ); + const paginationForAutomaticImages = usePaginationV2({ + currentRoute: '/images', + preferenceKey: AUTOMATIC_IMAGES_PREFERENCE_KEY, + searchParams: (prev) => ({ + ...prev, + query: search.query, + }), + }); + const { handleOrderChange: handleAutomaticImagesOrderChange, order: automaticImagesOrder, orderBy: automaticImagesOrderBy, - } = useOrder( - { - order: 'asc', - orderBy: 'label', + } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: AUTOMATIC_IMAGES_DEFAULT_ORDER, + orderBy: AUTOMATIC_IMAGES_DEFAULT_ORDER_BY, + }, + from: '/images', }, - 'images-automatic-order', - 'automatic' - ); + preferenceKey: AUTOMATIC_IMAGES_ORDER_PREFERENCE_KEY, + prefix: 'automatic', + }); const automaticImagesFilter: Filter = { ['+order']: automaticImagesOrder, @@ -229,6 +259,16 @@ export const ImagesLanding = () => { } ); + const { + data: selectedImage, + isFetching: isFetchingSelectedImage, + } = useDialogData({ + enabled: !!selectedImageId, + paramKey: 'imageId', + queryHook: useImageQuery, + redirectToOnNotFound: '/images', + }); + const { mutateAsync: deleteImage } = useDeleteImageMutation(); const { events } = useEventsInfiniteQuery(); @@ -245,72 +285,58 @@ export const ImagesLanding = () => { imageEvents ); - // TODO Image Service V2: delete after GA - const multiRegionsEnabled = - (flags.imageServiceGen2 && - manualImages?.data.some((image) => image.regions?.length)) ?? - false; - // Automatic images with the associated events tied in. const automaticImagesEvents = getEventsForImages( automaticImages?.data ?? [], imageEvents ); - const [selectedImageId, setSelectedImageId] = React.useState(); - - const [ - isManageReplicasDrawerOpen, - setIsManageReplicasDrawerOpen, - ] = React.useState(false); - const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); - const [isRebuildDrawerOpen, setIsRebuildDrawerOpen] = React.useState(false); + const actionHandler = (image: Image, action: ImageAction) => { + navigate({ + params: { action, imageId: image.id }, + search: (prev) => prev, + to: '/images/$imageId/$action', + }); + }; - const selectedImage = - manualImages?.data.find((i) => i.id === selectedImageId) ?? - automaticImages?.data.find((i) => i.id === selectedImageId); + const handleEdit = (image: Image) => { + actionHandler(image, 'edit'); + }; - const [dialog, setDialogState] = React.useState( - defaultDialogState - ); + const handleRebuild = (image: Image) => { + actionHandler(image, 'rebuild'); + }; - const dialogAction = dialog.status === 'pending_upload' ? 'cancel' : 'delete'; - const dialogMessage = - dialogAction === 'cancel' - ? 'Are you sure you want to cancel this Image upload?' - : 'Are you sure you want to delete this Image?'; + const handleDelete = (image: Image) => { + actionHandler(image, 'delete'); + }; - const openDialog = (image: string, imageID: string, status: ImageStatus) => { - setDialogState({ - error: undefined, - image, - imageID, - open: true, - status, - submitting: false, - }); + const handleCloseDialog = () => { + setDialogState(defaultDialogState); + navigate({ search: (prev) => prev, to: '/images' }); }; - const closeDialog = () => { - setDialogState({ ...dialog, open: false }); + const handleManageRegions = (image: Image) => { + actionHandler(image, 'manage-replicas'); }; - const handleRemoveImage = () => { - if (!dialog.imageID) { + const handleDeleteImage = (image: Image) => { + if (!image.id) { setDialogState((dialog) => ({ ...dialog, error: 'Image is not available.', })); } + setDialogState((dialog) => ({ ...dialog, error: undefined, submitting: true, })); - deleteImage({ imageId: dialog.imageID! }) + deleteImage({ imageId: image.id }) .then(() => { - closeDialog(); + handleCloseDialog(); /** * request generated by the Pagey HOC. * @@ -330,72 +356,53 @@ export const ImagesLanding = () => { err, 'There was an error deleting the image.' ); - setDialogState((dialog) => ({ - ...dialog, + setDialogState({ + ...dialogState, error: _error, submitting: false, - })); + }); + handleCloseDialog(); }); }; - const onRetryClick = ( - imageId: string, - imageLabel: string, - imageDescription: string - ) => { - queryClient.invalidateQueries({ - queryKey: imageQueries.paginated._def, - }); - history.push('/images/create/upload', { - imageDescription, - imageLabel, - }); - }; - const onCancelFailedClick = () => { queryClient.invalidateQueries({ queryKey: imageQueries.paginated._def, }); }; - const deployNewLinode = (imageID: string) => { + const handleDeployNewLinode = (imageId: string) => { history.push({ pathname: `/linodes/create/`, - search: `?type=Images&imageID=${imageID}`, - state: { selectedImageId: imageID }, + search: `?type=Images&imageID=${imageId}`, }); }; const resetSearch = () => { - queryParams.delete(searchParamKey); - history.push({ search: queryParams.toString() }); + navigate({ + search: (prev) => ({ ...prev, query: undefined }), + to: '/images', + }); }; const onSearch = (e: React.ChangeEvent) => { - queryParams.delete('page'); - queryParams.set(searchParamKey, e.target.value); - history.push({ search: queryParams.toString() }); + navigate({ + search: (prev) => ({ + ...prev, + page: undefined, + query: e.target.value || undefined, + }), + to: '/images', + }); }; const handlers: ImageHandlers = { onCancelFailed: onCancelFailedClick, - onDelete: openDialog, - onDeploy: deployNewLinode, - onEdit: (image) => { - setSelectedImageId(image.id); - setIsEditDrawerOpen(true); - }, - onManageRegions: multiRegionsEnabled - ? (image) => { - setSelectedImageId(image.id); - setIsManageReplicasDrawerOpen(true); - } - : undefined, - onRestore: (image) => { - setSelectedImageId(image.id); - setIsRebuildDrawerOpen(true); - }, - onRetry: onRetryClick, + onDelete: handleDelete, + onDeploy: handleDeployNewLinode, + onEdit: handleEdit, + onManageRegions: handleManageRegions, + onRebuild: handleRebuild, }; if (manualImagesLoading || automaticImagesLoading) { @@ -421,6 +428,10 @@ export const ImagesLanding = () => { { resourceType: 'Images', }), }} + onButtonClick={() => + navigate({ search: () => ({}), to: '/images/create' }) + } disabledCreateButton={isImagesReadOnly} docsLink="https://techdocs.akamai.com/cloud-computing/docs/images" entity="Image" - onButtonClick={() => history.push('/images/create')} title="Images" /> { hideLabel label="Search" placeholder="Search Images" - value={query} + value={query ?? ''} />
@@ -483,29 +496,20 @@ export const ImagesLanding = () => { Status - {multiRegionsEnabled && ( - - Replicated in - - )} - {multiRegionsEnabled && !flags.imageServiceGen2Ga && ( - - Compatibility - - )} + + Replicated in + - {multiRegionsEnabled ? 'Original Image' : 'Size'} + Original Image - {multiRegionsEnabled && ( - - All Replicas - - )} + + All Replicas + { Created - {multiRegionsEnabled && ( - - Image ID - - )} + + Image ID + @@ -543,7 +545,6 @@ export const ImagesLanding = () => { handlers={handlers} image={manualImage} key={manualImage.id} - multiRegionsEnabled={multiRegionsEnabled} /> ))} @@ -637,22 +638,25 @@ export const ImagesLanding = () => { setIsEditDrawerOpen(false)} - open={isEditDrawerOpen} + isFetching={isFetchingSelectedImage} + onClose={handleCloseDialog} + open={action === 'edit'} /> setIsRebuildDrawerOpen(false)} - open={isRebuildDrawerOpen} + isFetching={isFetchingSelectedImage} + onClose={handleCloseDialog} + open={action === 'rebuild'} /> setIsManageReplicasDrawerOpen(false)} - open={isManageReplicasDrawerOpen} + isFetching={isFetchingSelectedImage} + onClose={handleCloseDialog} + open={action === 'manage-replicas'} title={`Manage Replicas for ${selectedImage?.label}`} > setIsManageReplicasDrawerOpen(false)} + onClose={handleCloseDialog} /> { primaryButtonProps={{ 'data-testid': 'submit', label: - dialogAction === 'cancel' ? 'Cancel Upload' : 'Delete Image', - loading: dialog.submitting, - onClick: handleRemoveImage, + dialogStatus === 'cancel' ? 'Cancel Upload' : 'Delete Image', + loading: dialogState.submitting, + onClick: () => handleDeleteImage(selectedImage!), }} secondaryButtonProps={{ 'data-testid': 'cancel', - label: dialogAction === 'cancel' ? 'Keep Image' : 'Cancel', - onClick: closeDialog, + label: dialogStatus === 'cancel' ? 'Keep Image' : 'Cancel', + onClick: handleCloseDialog, }} /> } title={ - dialogAction === 'cancel' + dialogStatus === 'cancel' ? 'Cancel Upload' - : `Delete Image ${dialog.image}` + : `Delete Image ${selectedImage?.label}` } - onClose={closeDialog} - open={dialog.open} + isFetching={isFetchingSelectedImage} + onClose={handleCloseDialog} + open={action === 'delete'} > - {dialog.error && } - {dialogMessage} + {dialogState.error && ( + + )} + + {dialogStatus === 'cancel' + ? 'Are you sure you want to cancel this Image upload?' + : 'Are you sure you want to delete this Image?'} + ); }; -export const imagesLandingLazyRoute = createLazyRoute('/images')({ - component: ImagesLanding, -}); - export default ImagesLanding; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.test.tsx index 36f82275a46..036449b78bf 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { grantsFactory, profileFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; @@ -24,7 +24,9 @@ describe('ImagesLandingEmptyState', () => { }) ); - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + + ); await waitFor(() => { const createImageButton = getByText('Create Image').closest('button'); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.tsx index 75d4c7fd744..82b71c35903 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLandingEmptyState.tsx @@ -1,5 +1,5 @@ +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import LinodeIcon from 'src/assets/icons/entityIcons/linode.svg'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; @@ -15,8 +15,7 @@ import { } from './ImagesLandingEmptyStateData'; export const ImagesLandingEmptyState = () => { - const { push } = useHistory(); - + const navigate = useNavigate(); const isImagesReadOnly = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_images', }); @@ -33,7 +32,9 @@ export const ImagesLandingEmptyState = () => { category: linkAnalyticsEvent.category, label: 'Create Image', }); - push('/images/create'); + navigate({ + to: '/images/create', + }); }, tooltipText: getRestrictedResourceText({ action: 'create', diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx index b2ddbb5aa01..d809685e05c 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx @@ -11,6 +11,7 @@ import { RebuildImageDrawer } from './RebuildImageDrawer'; const props = { changeLinode: vi.fn(), image: imageFactory.build(), + isFetching: false, onClose: vi.fn(), open: true, }; diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx index 9340c610165..6edf42af951 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -1,6 +1,7 @@ import { Divider, Notice, Stack } from '@linode/ui'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; +// eslint-disable-next-line no-restricted-imports import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -15,12 +16,13 @@ import type { Image } from '@linode/api-v4'; interface Props { image: Image | undefined; + isFetching: boolean; onClose: () => void; open: boolean; } export const RebuildImageDrawer = (props: Props) => { - const { image, onClose, open } = props; + const { image, isFetching, onClose, open } = props; const history = useHistory(); const { @@ -56,6 +58,7 @@ export const RebuildImageDrawer = (props: Props) => { return ( import('./ImagesLanding/ImagesLanding')); -const ImageCreate = React.lazy( - () => import('./ImagesCreate/ImageCreateContainer') -); - -export const ImagesRoutes = () => { - const { path } = useRouteMatch(); - - return ( - }> - - - - - - - - ); -}; - -export default ImagesRoutes; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeConfigDisplay.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeConfigDisplay.tsx index 8997d663b03..34c756e4b37 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeConfigDisplay.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeConfigDisplay.tsx @@ -39,6 +39,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ kubeconfigElement: { '&:first-of-type': { borderLeft: 'none', + paddingLeft: 0, }, '&:hover': { opacity: 0.7, @@ -47,13 +48,14 @@ const useStyles = makeStyles()((theme: Theme) => ({ borderLeft: `1px solid ${theme.tokens.color.Neutrals[40]}`, cursor: 'pointer', display: 'flex', + marginBottom: theme.spacing(1), + padding: `0 ${theme.spacing(0.6)}`, }, kubeconfigElements: { alignItems: 'center', color: theme.palette.primary.main, display: 'flex', flexWrap: 'wrap', - gap: theme.spacing(1), }, kubeconfigFileText: { color: theme.textColors.linkActiveLight, @@ -61,10 +63,10 @@ const useStyles = makeStyles()((theme: Theme) => ({ whiteSpace: 'nowrap', }, kubeconfigIcons: { - height: 16, + height: 14, margin: `0 ${theme.spacing(1)}`, objectFit: 'contain', - width: 16, + width: 14, }, label: { fontFamily: theme.font.bold, @@ -105,13 +107,17 @@ export const KubeConfigDisplay = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { classes, cx } = useStyles(); - const { isFetching, refetch: getKubeConfig } = useKubernetesKubeConfigQuery( + const { refetch: getKubeConfig } = useKubernetesKubeConfigQuery( clusterId, false ); + const [isCopyTokenLoading, setIsCopyTokenLoading] = React.useState( + false + ); const onCopyToken = async () => { try { + setIsCopyTokenLoading(true); const { data } = await getKubeConfig(); const token = data && data.match(/token:\s*(\S+)/); if (token && token[1]) { @@ -122,11 +128,13 @@ export const KubeConfigDisplay = (props: Props) => { variant: 'error', }); } + setIsCopyTokenLoading(false); } catch (error) { enqueueSnackbar({ message: (error as APIError[])[0].reason, variant: 'error', }); + setIsCopyTokenLoading(false); } }; @@ -197,22 +205,17 @@ export const KubeConfigDisplay = (props: Props) => { View - - {isFetching ? ( - + + {isCopyTokenLoading ? ( + ) : ( )} - - Copy Token - + Copy Token void; + open: boolean; +} + +interface LabelsAndTaintsFormFields { + labels: Label; + taints: Taint[]; +} + +export const LabelAndTaintDrawer = (props: Props) => { + const { clusterId, nodePool, onClose, open } = props; + + const [shouldShowLabelForm, setShouldShowLabelForm] = React.useState(false); + const [shouldShowTaintForm, setShouldShowTaintForm] = React.useState(false); + + const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); + + const { isPending, mutateAsync: updateNodePool } = useUpdateNodePoolMutation( + clusterId, + nodePool?.id ?? -1 + ); + + const { + control, + formState, + setValue, + watch, + ...form + } = useForm({ + defaultValues: { + labels: undefined, + taints: undefined, + }, + }); + + React.useEffect(() => { + if (!nodePool) { + return; + } + if (open) { + setValue('labels', nodePool?.labels); + setValue('taints', nodePool?.taints); + } + }, [nodePool, open]); + + const onSubmit = async (values: LabelsAndTaintsFormFields) => { + try { + await updateNodePool({ + labels: values.labels, + taints: values.taints, + }); + handleClose(); + } catch (errResponse) { + for (const error of errResponse) { + if (!error.field) { + form.setError('root', { + message: `${capitalize(error.reason)}`, + }); + } + // Format error nicely so it includes the label or taint key for identification, if possible. + if (error.field.includes('labels')) { + const invalidLabelKey = error.field.split('.')[1]; // error.field will be: labels.key + const invalidLabelPrefixText = invalidLabelKey + ? `Error on ${invalidLabelKey}: ` + : ''; + form.setError('root', { + message: `${invalidLabelPrefixText}${capitalize(error.reason)}`, + }); + } else if (error.field.includes('taints')) { + const index = error.field.slice(7, 8); // error.field will be: taints[i] + const _taints = watch('taints'); + const invalidTaintPrefixText = _taints[index].key + ? `Error on ${_taints[index].key}: ` + : ''; + form.setError('root', { + message: `${invalidTaintPrefixText}${capitalize(error.reason)}`, + }); + } + } + } + }; + + const handleClose = () => { + setShouldShowLabelForm(false); + setShouldShowTaintForm(false); + onClose(); + form.reset(); + }; + + const planType = typesQuery[0]?.data + ? extendType(typesQuery[0].data) + : undefined; + + return ( + + {formState.errors.root?.message ? ( + + ) : null} + +
+ theme.spacing(4)} + marginTop={(theme) => theme.spacing()} + > + Manage custom labels and taints directly through LKE. Changes are + applied to all nodes in this node pool.{' '} + + Learn more + + . + + + Labels + + Labels are key-value pairs that are used as identifiers. Review the + guidelines in the{' '} + + Kubernetes documentation + + . + + + + {shouldShowLabelForm && ( + + setShouldShowLabelForm(!shouldShowLabelForm) + } + /> + )} + + + + Taints + + Taints are used to control which pods can be placed on nodes in this + node pool. They consist of a key, value, and effect. Review the + guidelines in the{' '} + + Kubernetes documentation + + . + + + + {shouldShowTaintForm && ( + + setShouldShowTaintForm(!shouldShowTaintForm) + } + /> + )} + + + +
+
+ ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelInput.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelInput.tsx new file mode 100644 index 00000000000..2ef2dab800c --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelInput.tsx @@ -0,0 +1,87 @@ +import { Button, Stack, TextField } from '@linode/ui'; +import { kubernetesLabelSchema } from '@linode/validation'; +import React, { useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { Label } from '@linode/api-v4'; + +interface Props { + handleCloseInputForm: () => void; +} + +export const LabelInput = (props: Props) => { + const { handleCloseInputForm } = props; + + const { clearErrors, control, setError, setValue, watch } = useFormContext(); + + const [combinedLabel, setCombinedLabel] = useState(''); + + const _labels: Label = watch('labels'); + + const handleAddLabel = () => { + // Separate the combined label. + const [labelKey, labelValue] = combinedLabel + .split(':') + .map((str) => str.trim()); + + const newLabels = { ..._labels, [labelKey]: labelValue }; + + try { + clearErrors(); + kubernetesLabelSchema.validateSync(newLabels); + + // Add the new key-value pair to the existing labels object. + setValue('labels', newLabels, { shouldDirty: true }); + + handleCloseInputForm(); + } catch (e) { + setError( + 'labels', + { + message: e.message, + type: 'validate', + }, + { shouldFocus: true } + ); + } + }; + + const handleClose = () => { + clearErrors(); + handleCloseInputForm(); + }; + + return ( + <> + { + return ( + setCombinedLabel(e.target.value)} + placeholder="myapp.io/app: production" + value={combinedLabel} + /> + ); + }} + control={control} + name="labels" + /> + + + + + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelTable.styles.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelTable.styles.tsx new file mode 100644 index 00000000000..7ed1cbe1533 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelTable.styles.tsx @@ -0,0 +1,9 @@ +import { styled } from '@mui/material/styles'; + +import { Table } from 'src/components/Table'; + +export const StyledLabelTable = styled(Table, { + label: 'StyledLabelTable', +})(({ theme }) => ({ + margin: `${theme.spacing()} 0`, +})); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelTable.tsx new file mode 100644 index 00000000000..bd267f6d931 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelTable.tsx @@ -0,0 +1,78 @@ +import { IconButton, Stack, Typography } from '@linode/ui'; +import Close from '@mui/icons-material/Close'; +import * as React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow/TableRow'; + +import { StyledLabelTable } from './LabelTable.styles'; + +import type { Label } from '@linode/api-v4'; + +export const LabelTable = () => { + const { setValue, watch } = useFormContext(); + + const deleteButtonRefs = React.useRef<(HTMLButtonElement | null)[]>([]); + + const labels: Label = watch('labels'); + const labelsArray = labels ? Object.entries(labels) : []; + + const handleRemoveLabel = (labelKey: string, index: number) => { + const newLabels = Object.fromEntries( + labelsArray.filter(([key]) => key !== labelKey) + ); + setValue('labels', newLabels, { shouldDirty: true }); + + // Set focus to the 'x' button on the row above after selected label is removed + const newFocusedButtonIndex = Math.max(index - 1, 0); + setTimeout(() => { + deleteButtonRefs.current[newFocusedButtonIndex]?.focus(); + }); + }; + + return ( + + + + Node Label + + + + {labels && labelsArray.length > 0 ? ( + labelsArray.map(([key, value], i) => { + return ( + + + + + {key}: {value} + + handleRemoveLabel(key, i)} + ref={(node) => (deleteButtonRefs.current[i] = node)} + size="medium" + sx={{ marginLeft: 'auto' }} + > + + + + + + ); + }) + ) : ( + + + No labels + + + )} + + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/TaintInput.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/TaintInput.tsx new file mode 100644 index 00000000000..95984569387 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/TaintInput.tsx @@ -0,0 +1,117 @@ +import { Autocomplete, Button, Stack, TextField } from '@linode/ui'; +import { kubernetesTaintSchema } from '@linode/validation'; +import React, { useState } from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; + +import type { KubernetesTaintEffect } from '@linode/api-v4'; + +interface Props { + handleCloseInputForm: () => void; +} + +const effectOptions: { label: string; value: KubernetesTaintEffect }[] = [ + { label: 'NoExecute', value: 'NoExecute' }, + { label: 'NoSchedule', value: 'NoSchedule' }, + { label: 'PreferNoSchedule', value: 'PreferNoSchedule' }, +]; + +export const TaintInput = (props: Props) => { + const { handleCloseInputForm } = props; + + const { clearErrors, control, setError } = useFormContext(); + + const { append } = useFieldArray({ + control, + name: 'taints', + }); + + const [combinedTaint, setCombinedTaint] = useState(''); + const [selectedEffect, setSelectedEffect] = useState( + 'NoExecute' + ); + + const handleAddTaint = () => { + // Separate the combined taint. + const [taintKey, taintValue] = combinedTaint + .split(':') + .map((str) => str.trim()); + + const newTaint = { + effect: selectedEffect, + key: taintKey, + value: taintValue, + }; + + try { + clearErrors(); + kubernetesTaintSchema.validateSync(newTaint); + append(newTaint); + handleCloseInputForm(); + } catch (e) { + setError( + 'taints.combinedValue', + { + message: e.message, + type: 'validate', + }, + { shouldFocus: true } + ); + } + }; + + const handleClose = () => { + clearErrors(); + handleCloseInputForm(); + }; + + return ( + <> + { + return ( + setCombinedTaint(e.target.value)} + placeholder="myapp.io/app: production" + value={combinedTaint} + /> + ); + }} + control={control} + name="taints.combinedValue" + /> + ( + option.value === selectedEffect) ?? + undefined + } + disableClearable + label="Effect" + onChange={(e, option) => setSelectedEffect(option.value)} + options={effectOptions} + /> + )} + control={control} + name="taints.effect" + /> + + + + + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/TaintTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/TaintTable.tsx new file mode 100644 index 00000000000..05206391854 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/TaintTable.tsx @@ -0,0 +1,88 @@ +import { IconButton, Stack, Typography } from '@linode/ui'; +import Close from '@mui/icons-material/Close'; +import * as React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow/TableRow'; + +import { StyledLabelTable } from './LabelTable.styles'; + +import type { Taint } from '@linode/api-v4'; + +export const TaintTable = () => { + const { setValue, watch } = useFormContext(); + + const deleteButtonRefs = React.useRef<(HTMLButtonElement | null)[]>([]); + + const taints: Taint[] = watch('taints'); + + const handleRemoveTaint = (removedTaint: Taint, index: number) => { + setValue( + 'taints', + taints.filter( + (taint) => + taint.key !== removedTaint.key || + taint.value !== removedTaint.value || + taint.effect !== removedTaint.effect + ), + { shouldDirty: true } + ); + + // Set focus to the 'x' button on the row above after selected taint is removed + const newFocusedButtonIndex = Math.max(index - 1, 0); + setTimeout(() => { + deleteButtonRefs.current[newFocusedButtonIndex]?.focus(); + }); + }; + + return ( + + + + Node Taint + Effect + + + + {taints && taints.length > 0 ? ( + taints.map((taint, i) => { + return ( + + + {taint.key}: {taint.value} + + + + {taint.effect} + handleRemoveTaint(taint, i)} + ref={(node) => (deleteButtonRefs.current[i] = node)} + size="medium" + sx={{ marginLeft: 'auto' }} + > + + + + + + ); + }) + ) : ( + + + No taints + + + )} + + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx index 886e49d8b78..ad58cb796a9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx @@ -1,6 +1,6 @@ import { + Accordion, Box, - Paper, Stack, StyledActionButton, Tooltip, @@ -15,6 +15,7 @@ import { pluralize } from 'src/utilities/pluralize'; import { NodeTable } from './NodeTable'; +import type { StatusFilter } from './NodePoolsDisplay'; import type { AutoscaleSettings, KubernetesTier, @@ -29,6 +30,7 @@ interface Props { clusterTier: KubernetesTier; count: number; encryptionStatus: EncryptionStatus | undefined; + handleClickLabelsAndTaints: (poolId: number) => void; handleClickResize: (poolId: number) => void; isOnlyNodePool: boolean; nodes: PoolNodeResponse[]; @@ -37,6 +39,7 @@ interface Props { openRecycleAllNodesDialog: (poolId: number) => void; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; poolId: number; + statusFilter: StatusFilter; tags: string[]; typeLabel: string; } @@ -49,6 +52,7 @@ export const NodePool = (props: Props) => { clusterTier, count, encryptionStatus, + handleClickLabelsAndTaints, handleClickResize, isOnlyNodePool, nodes, @@ -57,104 +61,157 @@ export const NodePool = (props: Props) => { openRecycleAllNodesDialog, openRecycleNodeDialog, poolId, + statusFilter, tags, typeLabel, } = props; return ( - - - - {typeLabel} - ({ height: 16, margin: `4px ${theme.spacing(1)}` })} - /> - - {pluralize('Node', 'Nodes', count)} - - - - openAutoscalePoolDialog(poolId), - title: 'Autoscale Pool', - }, - { - onClick: () => handleClickResize(poolId), - title: 'Resize Pool', - }, - { - onClick: () => openRecycleAllNodesDialog(poolId), - title: 'Recycle Pool Nodes', - }, - { - disabled: isOnlyNodePool, - onClick: () => openDeletePoolDialog(poolId), - title: 'Delete Pool', - tooltip: isOnlyNodePool - ? 'Clusters must contain at least one node pool.' - : undefined, - }, - ]} - ariaLabel={`Action menu for Node Pool ${poolId}`} - /> - - - - openAutoscalePoolDialog(poolId)} - > - Autoscale Pool - + + + {typeLabel} + ({ + height: 16, + margin: `4px ${theme.spacing(1)}`, + })} + orientation="vertical" + /> + + {pluralize('Node', 'Nodes', count)} + + + {autoscaler.enabled && ( - - (Min {autoscaler.min} / Max {autoscaler.max}) - + theme.spacing(10)}> + + (Min {autoscaler.min} / Max {autoscaler.max}) + + )} - handleClickResize(poolId)} + theme.spacing(5)} > - Resize Pool - - openRecycleAllNodesDialog(poolId)} + handleClickLabelsAndTaints(poolId), + title: 'Labels and Taints', + }, + { + onClick: () => openAutoscalePoolDialog(poolId), + title: 'Autoscale Pool', + }, + { + onClick: () => handleClickResize(poolId), + title: 'Resize Pool', + }, + { + onClick: () => openRecycleAllNodesDialog(poolId), + title: 'Recycle Pool Nodes', + }, + { + disabled: isOnlyNodePool, + onClick: () => openDeletePoolDialog(poolId), + title: 'Delete Pool', + tooltip: isOnlyNodePool + ? 'Clusters must contain at least one node pool.' + : undefined, + }, + ]} + ariaLabel={`Action menu for Node Pool ${poolId}`} + stopClickPropagation + /> + + + + theme.spacing(5)} > - Recycle Pool Nodes - - -
- openDeletePoolDialog(poolId)} - > - Delete Pool - -
-
-
-
- + { + e.stopPropagation(); + handleClickLabelsAndTaints(poolId); + }} + compactY + > + Labels and Taints + + { + e.stopPropagation(); + openAutoscalePoolDialog(poolId); + }} + compactY + > + Autoscale Pool + + {autoscaler.enabled && ( + + (Min {autoscaler.min} / Max {autoscaler.max}) + + )} + { + e.stopPropagation(); + handleClickResize(poolId); + }} + compactY + > + Resize Pool + + { + e.stopPropagation(); + openRecycleAllNodesDialog(poolId); + }} + compactY + > + Recycle Pool Nodes + + e.stopPropagation()} + title="Clusters must contain at least one node pool." + > +
+ { + e.stopPropagation(); + openDeletePoolDialog(poolId); + }} + compactY + disabled={isOnlyNodePool} + > + Delete Pool + +
+
+ + +
+ } + data-qa-node-pool-id={poolId} + data-qa-node-pool-section + defaultExpanded={true} + > { nodes={nodes} openRecycleNodeDialog={openRecycleNodeDialog} poolId={poolId} + statusFilter={statusFilter} tags={tags} typeLabel={typeLabel} /> -
+ ); }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx index 073dada6906..9d6342313e4 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx @@ -1,8 +1,9 @@ -import { Button, CircleProgress, Stack, Typography } from '@linode/ui'; +import { Button, CircleProgress, Select, Stack, Typography } from '@linode/ui'; import React, { useState } from 'react'; import { Waypoint } from 'react-waypoint'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { FormLabel } from 'src/components/FormLabel'; import { useAllKubernetesNodePoolQuery } from 'src/queries/kubernetes'; import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; @@ -12,12 +13,41 @@ import { RecycleNodePoolDialog } from '../RecycleNodePoolDialog'; import { AddNodePoolDrawer } from './AddNodePoolDrawer'; import { AutoscalePoolDialog } from './AutoscalePoolDialog'; import { DeleteNodePoolDialog } from './DeleteNodePoolDialog'; +import { LabelAndTaintDrawer } from './LabelsAndTaints/LabelAndTaintDrawer'; import { NodePool } from './NodePool'; import { RecycleNodeDialog } from './RecycleNodeDialog'; import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; import type { KubernetesTier, Region } from '@linode/api-v4'; +export type StatusFilter = 'all' | 'offline' | 'provisioning' | 'running'; + +interface StatusFilterOption { + label: string; + value: StatusFilter; +} + +const statusOptions: StatusFilterOption[] = [ + { + label: 'Show All', + value: 'all', + }, + { + label: 'Running', + value: 'running', + }, + { + label: 'Offline', + value: 'offline', + }, + { + label: 'Provisioning', + value: 'provisioning', + }, +]; + +const ariaIdentifier = 'node-pool-status-filter'; + export interface Props { clusterCreated: string; clusterID: number; @@ -49,6 +79,10 @@ export const NodePoolsDisplay = (props: Props) => { const selectedPool = pools?.find((pool) => pool.id === selectedPoolId); const [isDeleteNodePoolOpen, setIsDeleteNodePoolOpen] = useState(false); + const [ + isLabelsAndTaintsDrawerOpen, + setIsLabelsAndTaintsDrawerOpen, + ] = useState(false); const [isResizeDrawerOpen, setIsResizeDrawerOpen] = useState(false); const [isRecycleAllPoolNodesOpen, setIsRecycleAllPoolNodesOpen] = useState( false @@ -64,6 +98,8 @@ export const NodePoolsDisplay = (props: Props) => { const typesQuery = useSpecificTypes(_pools?.map((pool) => pool.type) ?? []); const types = extendTypesQueryResult(typesQuery); + const [statusFilter, setStatusFilter] = React.useState('all'); + const handleShowMore = () => { if (numPoolsToDisplay < (pools?.length ?? 0)) { setNumPoolsToDisplay( @@ -83,6 +119,11 @@ export const NodePoolsDisplay = (props: Props) => { setIsResizeDrawerOpen(true); }; + const handleOpenLabelsAndTaintsDrawer = (poolId: number) => { + setSelectedPoolId(poolId); + setIsLabelsAndTaintsDrawerOpen(true); + }; + if (isLoading || pools === undefined) { return ; } @@ -95,10 +136,32 @@ export const NodePoolsDisplay = (props: Props) => { flexWrap="wrap" justifyContent="space-between" spacing={2} - sx={{ paddingLeft: { md: 0, sm: 1, xs: 1 } }} + sx={{ paddingLeft: { md: 0, sm: 1, xs: 1 }, paddingTop: 3 }} > Node Pools + + + + Status + + +