diff --git a/.gitignore b/.gitignore index fe569e46f54..3330338af8c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ lib .vscode .idea **/*.iml +*.mdc # misc .DS_Store diff --git a/docs/development-guide/02-component-structure.md b/docs/development-guide/02-component-structure.md index 1a18bbb39de..93f93f54c36 100644 --- a/docs/development-guide/02-component-structure.md +++ b/docs/development-guide/02-component-structure.md @@ -17,7 +17,7 @@ Here is a minimal code example demonstrating the basic structure of a component ```tsx import { omittedProps } from "@linode/ui"; import { styled } from "@mui/material/styles"; -import * as React from "react"; +import * as React from 'react'; // If not exported, it can just be named `Props` export interface SayHelloProps { diff --git a/docs/development-guide/05-fetching-data.md b/docs/development-guide/05-fetching-data.md index 7b37b724ead..4ac868dd9de 100644 --- a/docs/development-guide/05-fetching-data.md +++ b/docs/development-guide/05-fetching-data.md @@ -125,7 +125,7 @@ export const useProfile = () => Loading and error states are managed by React Query. The earlier username display example becomes greatly simplified: ```tsx -import * as React from "react"; +import * as React from 'react'; import { useProfile } from "src/queries/profile"; const UsernameDisplay = () => { @@ -166,7 +166,7 @@ Before React Query, Redux was used to store API data, loading, and error states. ```tsx // ---- OLD PATTERN, DON'T USE ---- // -import * as React from "react"; +import * as React from 'react'; import profileContainer, { Props as ProfileProps, } from "src/containers/profile.container"; diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index ac2e8fe9db4..2dab1948964 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -210,12 +210,13 @@ Environment variables related to Cypress logging and reporting, as well as repor |---------------------------------|----------------------------------------------------|------------------|----------------------------| | `CY_TEST_USER_REPORT` | Log test account information when tests begin | `1` | Unset; disabled by default | | `CY_TEST_JUNIT_REPORT` | Enable JUnit reporting | `1` | Unset; disabled by default | -| `CY_TEST_HTML_REPORT` | Generate html report containing E2E test results | `1` | Unset; disabled by default | +| `CY_TEST_HTML_REPORT` | Generate html report containing E2E test results | `1` | Unset; disabled by default | | `CY_TEST_DISABLE_FILE_WATCHING` | Disable file watching in Cypress UI | `1` | Unset; disabled by default | | `CY_TEST_DISABLE_RETRIES` | Disable test retries on failure in CI | `1` | Unset; disabled by default | | `CY_TEST_FAIL_ON_MANAGED` | Fail affected tests when Managed is enabled | `1` | Unset; disabled by default | | `CY_TEST_GENWEIGHTS` | Generate and output test weights to the given path | `./weights.json` | Unset; disabled by default | | `CY_TEST_RESET_PREFERENCES` | Reset user preferences when test run begins | `1` | Unset; disabled by default | +| `CY_TEST_RESOURCE_PREFIX` | Prefix to apply to labels of created resources | `test-resource` | Unset; default `cy-test` | ###### Performance diff --git a/docs/development-guide/11-feature-flags.md b/docs/development-guide/11-feature-flags.md index d38c468b12f..9eaffe1fb78 100644 --- a/docs/development-guide/11-feature-flags.md +++ b/docs/development-guide/11-feature-flags.md @@ -45,7 +45,7 @@ export interface Flags { To consume a feature flag from a function component, use the `useFlags` hook: ```tsx -import * as React from "react"; +import * as React from 'react'; import { useFlags } from "src/hooks/useFlags"; const ImagesPricingBanner = () => { @@ -62,7 +62,7 @@ const ImagesPricingBanner = () => { For (older) class components, use the `withFeatureFlagConsumer` HOC: ```tsx -import * as React from "react"; +import * as React from 'react'; import withFeatureFlags, { FeatureFlagConsumerProps, } from "src/containers/withFeatureFlagConsumer.container"; diff --git a/package.json b/package.json index 16ab74557d2..d665df62ebf 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,6 @@ "prepare": "husky" }, "resolutions": { - "node-fetch": "^2.6.7", "semver": "^7.5.2", "yaml": "^2.3.0" }, diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 34fd2e5faca..76d1f846c34 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,23 @@ +## [2025-07-01] - v0.143.0 + +### Changed: + +- Allow `authorized_keys` to be null in `Profile` type ([#12390](https://github.com/linode/manager/pull/12390)) + +### Removed: + +- `is_beta` flag from ServiceTypes interface in cloudpulse types ([#12386](https://github.com/linode/manager/pull/12386)) + +### Upcoming Features: + +- Add Beta ACLP alerts property to the `CreateLinodeRequest` type ([#12248](https://github.com/linode/manager/pull/12248)) +- Add `parent_entity` field to `FirewallDeviceEntity` ([#12283](https://github.com/linode/manager/pull/12283)) +- Fix `getMaintenancePolicies` to properly handle pagination params and return all maintenance policies ([#12334](https://github.com/linode/manager/pull/12334)) +- Add `scope` in `Alert` and `EditAlertDefinitionPayload` interfaces, rename `ServiceTypes` interface to `Service`, add `ServiceAlert` interface ([#12377](https://github.com/linode/manager/pull/12377)) +- Add maintenance policy types for VM maintenance API ([#12417](https://github.com/linode/manager/pull/12417)) +- Add `monitors` field to the `Region` type ([#12375](https://github.com/linode/manager/pull/12375)) +- Add CRUD CloudNAT types ([#12379](https://github.com/linode/manager/pull/12379)) + ## [2025-06-17] - v0.142.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 4e08f10be12..7227c650f0d 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.142.0", + "version": "0.143.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/account.ts b/packages/api-v4/src/account/account.ts index d423c840c3b..b967d70595e 100644 --- a/packages/api-v4/src/account/account.ts +++ b/packages/api-v4/src/account/account.ts @@ -70,7 +70,7 @@ export const updateAccountInfo = (data: Partial) => */ export const getAccountSettings = () => Request( - setURL(`${API_ROOT}/account/settings`), + setURL(`${BETA_API_ROOT}/account/settings`), setMethod('GET'), ); @@ -82,7 +82,7 @@ export const getAccountSettings = () => */ export const updateAccountSettings = (data: Partial) => Request( - setURL(`${API_ROOT}/account/settings`), + setURL(`${BETA_API_ROOT}/account/settings`), setMethod('PUT'), setData(data, UpdateAccountSettingsSchema), ); diff --git a/packages/api-v4/src/account/maintenance.ts b/packages/api-v4/src/account/maintenance.ts index 335022e5ca4..66a27b5212f 100644 --- a/packages/api-v4/src/account/maintenance.ts +++ b/packages/api-v4/src/account/maintenance.ts @@ -24,8 +24,10 @@ export const getAccountMaintenance = (params?: Params, filter?: Filter) => * Returns a list of maintenance policies that are available for Linodes in this account. * */ -export const getMaintenancePolicies = () => - Request( +export const getMaintenancePolicies = (params?: Params, filter?: Filter) => + Request>( setURL(`${BETA_API_ROOT}/maintenance/policies`), setMethod('GET'), + setParams(params), + setXFilter(filter), ); diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 2ebb5042ae7..d4dae2c0d75 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -109,7 +109,7 @@ export interface AccountSettings { backups_enabled: boolean; interfaces_for_new_linodes: LinodeInterfaceAccountSetting; longview_subscription: null | string; - maintenance_policy_id: MaintenancePolicyId; + maintenance_policy: MaintenancePolicySlug; managed: boolean; network_helper: boolean; object_storage: 'active' | 'disabled' | 'suspended'; @@ -504,8 +504,10 @@ export const EventActionKeys = [ export type EventAction = (typeof EventActionKeys)[number]; export type EventStatus = + | 'canceled' | 'failed' | 'finished' + | 'in-progress' | 'notification' | 'scheduled' | 'started'; @@ -514,25 +516,25 @@ export type EventSource = 'platform' | 'user'; export interface Event { action: EventAction; - complete_time?: null | string; // @TODO VM & Host Maintenance: verify new fields + complete_time?: null | string; created: string; - description?: null | string; // @TODO VM & Host Maintenance: verify new fields + description?: null | string; /* NOTE: events before the duration key was added will have a duration of 0 */ duration: null | number; entity: Entity | null; id: number; - maintenance_policy_set?: MaintenancePolicyType | null; // @TODO VM & Host Maintenance: verify new fields + maintenance_policy_set?: MaintenancePolicySlug | null; message: null | string; - not_before?: null | string; // @TODO VM & Host Maintenance: verify new fields + not_before?: null | string; percent_complete: null | number; rate: null | string; read: boolean; secondary_entity: Entity | null; seen: boolean; - source?: EventSource | null; // @TODO VM & Host Maintenance: verify new fields - start_time?: null | string; // @TODO VM & Host Maintenance: verify new fields + source?: EventSource | null; + start_time?: null | string; status: EventStatus; time_remaining: null | string; username: null | string; @@ -577,7 +579,7 @@ export interface AccountMaintenance { type: 'linode' | 'volume'; url: string; }; - maintenance_policy_set: MaintenancePolicyType; + maintenance_policy_set: MaintenancePolicySlug; not_before: string; reason: string; source: 'platform' | 'user'; @@ -589,25 +591,26 @@ export interface AccountMaintenance { | 'pending' | 'scheduled' | 'started'; - type: 'cold_migration' | 'live_migration' | 'reboot' | 'volume_migration'; + type: + | 'cold_migration' + | 'live_migration' + | 'migrate' + | 'power_off_on' + | 'reboot' + | 'volume_migration'; when: string; } -export const maintenancePolicies = [ - { id: 1, type: 'migrate' }, - { id: 2, type: 'power on/off' }, -] as const; - -export type MaintenancePolicyId = (typeof maintenancePolicies)[number]['id']; +// Note: In the future there will be more slugs, ie: 'private/1234'. +export type MaintenancePolicySlug = 'linode/migrate' | 'linode/power_off_on'; -export type MaintenancePolicyType = - (typeof maintenancePolicies)[number]['type']; - -export type MaintenancePolicy = (typeof maintenancePolicies)[number] & { +export type MaintenancePolicy = { description: string; is_default: boolean; - name: string; + label: 'Migrate' | 'Power Off / Power On'; notification_period_sec: number; + slug: MaintenancePolicySlug; + type: 'linode_migrate' | 'linode_power_off_on' | 'migrate' | 'power_off_on'; // Should not be needed for UX. Mainly for FleetOps. }; export interface PayPalData { diff --git a/packages/api-v4/src/cloudnat/cloudnats.ts b/packages/api-v4/src/cloudnat/cloudnats.ts new file mode 100644 index 00000000000..1b2e838ad0d --- /dev/null +++ b/packages/api-v4/src/cloudnat/cloudnats.ts @@ -0,0 +1,51 @@ +import { createCloudNATSchema } from '@linode/validation'; + +import { API_ROOT } from '../constants'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { + CloudNAT, + CreateCloudNATPayload, + UpdateCloudNATPayload, +} from './types'; + +export const getCloudNATs = (params?: Params, filter?: Filter) => + Request>( + setURL(`${API_ROOT}/networking/cloudnats`), + setMethod('GET'), + setParams(params), + setXFilter(filter), + ); + +export const getCloudNAT = (id: number) => + Request( + setURL(`${API_ROOT}/networking/cloudnats/${id}`), + setMethod('GET'), + ); + +export const createCloudNAT = (data: CreateCloudNATPayload) => + Request( + setURL(`${API_ROOT}/networking/cloudnats`), + setMethod('POST'), + setData(data, createCloudNATSchema), + ); + +export const updateCloudNAT = (id: number, data: UpdateCloudNATPayload) => + Request( + setURL(`${API_ROOT}/networking/cloudnats/${id}`), + setMethod('PUT'), + setData(data, createCloudNATSchema), + ); + +export const deleteCloudNAT = (id: number) => + Request<{}>( + setURL(`${API_ROOT}/networking/cloudnats/${id}`), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/cloudnat/index.ts b/packages/api-v4/src/cloudnat/index.ts new file mode 100644 index 00000000000..1bc715dc445 --- /dev/null +++ b/packages/api-v4/src/cloudnat/index.ts @@ -0,0 +1,3 @@ +export * from './cloudnats'; + +export * from './types'; diff --git a/packages/api-v4/src/cloudnat/types.ts b/packages/api-v4/src/cloudnat/types.ts new file mode 100644 index 00000000000..90c9fca71c9 --- /dev/null +++ b/packages/api-v4/src/cloudnat/types.ts @@ -0,0 +1,28 @@ +interface CloudNATIPAddress { + address: string; +} + +export interface CloudNAT { + addresses: CloudNATIPAddress[]; + id: number; + label: string; + port_prefix_default_len: number; + region: string; +} + +export interface CreateCloudNATPayload { + addresses?: CloudNATIPAddress[]; + label: string; + port_prefix_default_len?: ValidPortSize; + region: string; +} + +export interface UpdateCloudNATPayload { + label?: string; +} + +export const VALID_PORT_SIZES = [ + 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, +] as const; + +export type ValidPortSize = (typeof VALID_PORT_SIZES)[number]; diff --git a/packages/api-v4/src/cloudpulse/services.ts b/packages/api-v4/src/cloudpulse/services.ts index 870a185ebb0..f3a0e311f64 100644 --- a/packages/api-v4/src/cloudpulse/services.ts +++ b/packages/api-v4/src/cloudpulse/services.ts @@ -12,7 +12,7 @@ import type { JWEToken, JWETokenPayLoad, MetricDefinition, - ServiceTypes, + Service, ServiceTypesList, } from './types'; import type { Filter, Params, ResourcePage } from 'src/types'; @@ -51,7 +51,7 @@ export const getCloudPulseServiceTypes = () => ); export const getCloudPulseServiceByServiceType = (serviceType: string) => - Request( + Request( setURL(`${API_ROOT}/monitor/services/${encodeURIComponent(serviceType)}`), setMethod('GET'), ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 1d5f23d5a41..a9e0c410faa 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -10,6 +10,7 @@ export type DimensionFilterOperatorType = | 'startswith'; export type AlertDefinitionType = 'system' | 'user'; export type AlertStatusType = 'disabled' | 'enabled' | 'failed' | 'in progress'; +export type AlertDefinitionScope = 'account' | 'entity' | 'region'; export type CriteriaConditionType = 'ALL'; export type MetricUnitType = | 'bit_per_second' @@ -163,20 +164,21 @@ export interface CloudPulseMetricsList { values: [number, string][]; } -export interface ServiceTypes { - alert: { - evaluation_periods_seconds: number[]; - polling_interval_seconds: number[]; - scope: string[]; - }; - is_beta: boolean; +export interface ServiceAlert { + evaluation_periods_seconds: number[]; + polling_interval_seconds: number[]; + scope: AlertDefinitionScope[]; +} + +export interface Service { + alert: ServiceAlert; label: string; regions: string; service_type: string; } export interface ServiceTypesList { - data: ServiceTypes[]; + data: Service[]; } export interface CreateAlertDefinitionPayload { @@ -239,6 +241,7 @@ export interface Alert { rule_criteria: { rules: AlertDefinitionMetricCriteria[]; }; + scope: AlertDefinitionScope; service_type: AlertServiceType; severity: AlertSeverityType; status: AlertStatusType; @@ -326,6 +329,7 @@ export interface EditAlertDefinitionPayload { rule_criteria?: { rules: MetricCriteria[]; }; + scope: AlertDefinitionScope; severity?: AlertSeverityType; status?: AlertStatusType; tags?: string[]; @@ -349,3 +353,23 @@ export interface DeleteAlertPayload { alertId: number; serviceType: string; } + +/** + * Represents the payload for CloudPulse alerts, included only when the ACLP beta mode is enabled. + * + * In Beta mode, the `alerts` object contains enabled system and user alert IDs. + * - Legacy mode: `alerts` is not included (read-only mode). + * - Beta mode: `alerts` is passed and editable. + */ +export interface CloudPulseAlertsPayload { + /** + * Array of enabled system alert IDs in ACLP (Beta) mode. + * Only included in Beta mode. + */ + system: number[]; + /** + * Array of enabled user alert IDs in ACLP (Beta) mode. + * Only included in Beta mode. + */ + user: number[]; +} diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index b949ed34444..411c6cf501c 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -51,6 +51,7 @@ export interface FirewallRuleType { export interface FirewallDeviceEntity { id: number; label: null | string; + parent_entity: FirewallDeviceEntity | null; type: FirewallDeviceEntityType; url: string; } diff --git a/packages/api-v4/src/iam/iam.ts b/packages/api-v4/src/iam/iam.ts index 34a9eaec16f..a2a34709f53 100644 --- a/packages/api-v4/src/iam/iam.ts +++ b/packages/api-v4/src/iam/iam.ts @@ -2,7 +2,7 @@ import { BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, setURL } from '../request'; import type { - EntityTypePermissions, + AccessType, IamAccountRoles, IamUserRoles, PermissionType, @@ -84,12 +84,12 @@ export const getUserAccountPermissions = (username: string) => * Returns the current permissions for this User on the entity. * * @param username { string } the username to look up. - * @param entityType { EntityTypePermissions } the entityType to look up. + * @param entityType { AccessType } the entityType to look up. * @param entityId { number } the entityId to look up. */ export const getUserEntityPermissions = ( username: string, - entityType: EntityTypePermissions, + entityType: AccessType, entityId: number, ) => Request( diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 7a160e95c4f..534336be0c4 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -1,207 +1,363 @@ -export type EntityTypePermissions = - | 'account' - | 'database' - | 'domain' - | 'firewall' - | 'image' - | 'linode' - | 'lkecluster' - | 'longview' - | 'nodebalancer' - | 'placement_group' - | 'stackscript' - | 'volume' - | 'vpc'; - -export type AccountAccessRole = +import type { EntityType } from 'src/entities/types'; + +export type AccountType = 'account'; + +export type AccessType = AccountType | EntityType; + +export type AccountRoleType = | 'account_admin' + | 'account_billing_admin' + | 'account_billing_viewer' + | 'account_database_creator' + | 'account_domain_creator' + | 'account_event_viewer' + | 'account_firewall_admin' + | 'account_firewall_creator' + | 'account_image_creator' + | 'account_ip_admin' + | 'account_ip_viewer' | 'account_linode_admin' + | 'account_linode_creator' + | 'account_lkecluster_creator' + | 'account_longview_creator' + | 'account_longview_subscription_admin' + | 'account_maintenance_viewer' + | 'account_nodebalancer_creator' + | 'account_notification_viewer' + | 'account_oauth_client_admin' + | 'account_oauth_client_viewer' + | 'account_placement_group_creator' + | 'account_stackscript_creator' | 'account_viewer' - | 'account_volume_admin' - | 'firewall_creator' - | 'linode_contributor' - | 'linode_creator' - | 'stackscript_creator'; + | 'account_vlan_admin' + | 'account_vlan_viewer' + | 'account_volume_creator' + | 'account_vpc_creator'; -export type EntityAccessRole = +export type EntityRoleType = | 'database_admin' + | 'database_viewer' + | 'domain_admin' + | 'domain_viewer' | 'firewall_admin' - | 'firewall_creator' + | 'firewall_contributor' | 'firewall_viewer' + | 'image_admin' | 'image_viewer' + | 'linode_admin' | 'linode_contributor' - | 'linode_creator' | 'linode_viewer' + | 'lkecluster_admin' + | 'lkecluster_viewer' + | 'longview_admin' + | 'longview_viewer' + | 'nodebalancer_admin' + | 'nodebalancer_viewer' + | 'placement_group_admin' + | 'placement_group_viewer' | 'stackscript_admin' | 'stackscript_viewer' - | 'update_firewall'; + | 'volume_admin' + | 'volume_viewer' + | 'vpc_admin' + | 'vpc_viewer'; -export interface IamUserRoles { - account_access: AccountAccessRole[]; - entity_access: EntityAccess[]; -} -export interface EntityAccess { - id: number; - roles: EntityAccessRole[]; - type: EntityTypePermissions; -} +export type RoleName = AccountRoleType | EntityRoleType; -export type PermissionType = +/** Permissions associated with the "account_admin" role. */ +export type AccountAdmin = + | 'accept_service_transfer' | 'acknowledge_account_agreement' - | 'add_nodebalancer_config' - | 'add_nodebalancer_config_node' - | 'allocate_ip' - | 'allocate_linode_ip_address' - | 'assign_ips' - | 'assign_ipv4' - | 'attach_volume' - | 'boot_linode' + | 'answer_profile_security_questions' | 'cancel_account' - | 'cancel_linode_backups' - | 'clone_linode' - | 'clone_linode_disk' - | 'clone_volume' - | 'create_firewall' - | 'create_firewall_device' - | 'create_image' - | 'create_ipv6_range' - | 'create_linode' - | 'create_linode_backup_snapshot' - | 'create_linode_config_profile' - | 'create_linode_config_profile_interface' - | 'create_linode_disk' - | 'create_nodebalancer' - | 'create_oauth_client' - | 'create_payment_method' - | 'create_promo_code' + | 'cancel_service_transfer' + | 'create_profile_pat' + | 'create_profile_ssh_key' + | 'create_profile_tfa_secret' | 'create_service_transfer' | 'create_user' - | 'create_volume' - | 'create_vpc' - | 'create_vpc_subnet' - | 'delete_firewall' - | 'delete_firewall_device' - | 'delete_image' - | 'delete_linode' - | 'delete_linode_config_profile' - | 'delete_linode_config_profile_interface' - | 'delete_linode_disk' - | 'delete_linode_ip_address' - | 'delete_nodebalancer' - | 'delete_nodebalancer_config' - | 'delete_nodebalancer_config_node' - | 'delete_payment_method' + | 'delete_profile_pat' + | 'delete_profile_phone_number' + | 'delete_profile_ssh_key' | 'delete_user' - | 'delete_volume' - | 'delete_vpc' - | 'delete_vpc_subnet' - | 'detach_volume' - | 'enable_linode_backups' + | 'disable_profile_tfa' | 'enable_managed' + | 'enable_profile_tfa' | 'enroll_beta_program' + | 'is_account_admin' | 'list_account_agreements' | 'list_account_logins' - | 'list_all_vpc_ipaddresses' | 'list_available_services' - | 'list_child_accounts' + | 'list_default_firewalls' | 'list_enrolled_beta_programs' - | 'list_events' - | 'list_firewall_devices' - | 'list_firewalls' - | 'list_images' + | 'list_service_transfers' + | 'list_user_grants' + | 'revoke_profile_app' + | 'revoke_profile_device' + | 'send_profile_phone_number_verification_code' + | 'update_account' + | 'update_account_settings' + | 'update_profile' + | 'update_profile_pat' + | 'update_profile_ssh_key' + | 'update_user' + | 'update_user_grants' + | 'update_user_preferences' + | 'verify_profile_phone_number' + | 'view_account' + | 'view_account_login' + | 'view_account_settings' + | 'view_enrolled_beta_program' + | 'view_network_usage' + | 'view_region_available_service' + | 'view_service_transfer' + | 'view_user' + | 'view_user_preferences' + | AccountBillingAdmin + | AccountEventViewer + | AccountFirewallAdmin + | AccountLinodeAdmin + | AccountMaintenanceViewer + | AccountNotificationViewer + | AccountOauthClientAdmin + | AccountProfileViewer; + +/** Permissions associated with the "account_billing_admin" role. */ +export type AccountBillingAdmin = + | 'create_payment_method' + | 'create_promo_code' + | 'delete_payment_method' + | 'make_billing_payment' + | 'set_default_payment_method' + | AccountBillingViewer; + +/** Permissions associated with the "account_billing_viewer" role. */ +export type AccountBillingViewer = + | 'list_billing_invoices' + | 'list_billing_payments' | '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' + | 'view_billing_invoice' + | 'view_billing_payment' + | 'view_payment_method'; + +/** Permissions associated with the "account_event_viewer" role. */ +export type AccountEventViewer = + | 'list_events' + | 'mark_event_seen' + | 'view_event'; + +/** Permissions associated with the "account_firewall_admin" role. */ +export type AccountFirewallAdmin = AccountFirewallCreator | FirewallAdmin; + +/** Permissions associated with the "account_firewall_creator" role. */ +export type AccountFirewallCreator = 'create_firewall'; + +/** Permissions associated with the "account_linode_admin" role. */ +export type AccountLinodeAdmin = AccountLinodeCreator | LinodeAdmin; + +/** Permissions associated with the "account_linode_creator" role. */ +export type AccountLinodeCreator = 'create_linode'; + +/** Permissions associated with the "account_maintenance_viewer" role. */ +export type AccountMaintenanceViewer = 'list_maintenances'; + +/** Permissions associated with the "account_notification_viewer" role. */ +export type AccountNotificationViewer = 'list_notifications'; + +/** Permissions associated with the "account_oauth_client_admin" role. */ +export type AccountOauthClientAdmin = + | 'create_oauth_client' + | 'delete_oauth_client' + | 'reset_oauth_client_secret' + | 'update_oauth_client' + | 'update_oauth_client_thumbnail' + | AccountOauthClientViewer; + +/** Permissions associated with the "account_oauth_client_viewer" role. */ +export type AccountOauthClientViewer = + | 'list_oauth_clients' + | 'view_oauth_client' + | 'view_oauth_client_thumbnail'; + +/** + * Permissions associated with the user profile. + * Note: There is no "profile_viewer" role as the profile is always viewable by the user. + * However, this is a UI type to facilitate UX integration and type safety. + */ +export type AccountProfileViewer = + | 'list_profile_apps' + | 'list_profile_devices' + | 'list_profile_grants' + | 'list_profile_logins' + | 'list_profile_pats' + | 'list_profile_security_questions' + | 'list_profile_ssh_keys' + | 'view_profile' + | 'view_profile_app' + | 'view_profile_device' + | 'view_profile_login' + | 'view_profile_pat' + | 'view_profile_ssh_key'; + +/** Permissions associated with the "account_viewer" role. */ +export type AccountViewer = + | 'list_account_agreements' + | 'list_account_logins' + | 'list_available_services' + | 'list_default_firewalls' + | 'list_enrolled_beta_programs' | 'list_service_transfers' - | 'list_users' - | 'list_volumes' - | 'list_vpc_ip_addresses' - | 'list_vpc_subnets' - | 'list_vpcs' - | 'make_payment' + | 'list_user_grants' + | 'view_account' + | 'view_account_login' + | 'view_account_settings' + | 'view_enrolled_beta_program' + | 'view_network_usage' + | 'view_region_available_service' + | 'view_service_transfer' + | 'view_user' + | 'view_user_preferences' + | AccountBillingViewer + | AccountEventViewer + | AccountMaintenanceViewer + | AccountNotificationViewer + | AccountOauthClientViewer + | AccountProfileViewer + | FirewallViewer + | LinodeViewer; + +/** Permissions associated with the "firewall_admin role. */ +export type FirewallAdmin = + | 'delete_firewall' + | 'delete_firewall_device' + | FirewallContributor; + +/** Permissions associated with the "firewall_contributor role. */ +export type FirewallContributor = + | 'create_firewall_device' + | 'update_firewall' + | 'update_firewall_rules' + | FirewallViewer; + +/** Permissions associated with the "firewall_viewer" role. */ +export type FirewallViewer = + | 'list_firewall_devices' + | 'list_firewall_rule_versions' + | 'list_firewall_rules' + | 'view_firewall' + | 'view_firewall_device' + | 'view_firewall_rule_version'; + +/** Permissions associated with the "linode_admin" role. */ +export type LinodeAdmin = + | 'cancel_linode_backups' + | 'delete_linode' + | 'delete_linode_config_profile' + | 'delete_linode_config_profile_interface' + | 'delete_linode_disk' + | LinodeContributor; + +/** Permissions associated with the "linode_contributor" role. */ +export type LinodeContributor = + | 'apply_linode_firewalls' + | 'boot_linode' + | 'clone_linode' + | 'clone_linode_disk' + | 'create_linode_backup_snapshot' + | 'create_linode_config_profile' + | 'create_linode_config_profile_interface' + | 'create_linode_disk' + | 'enable_linode_backups' + | 'generate_linode_lish_token' + | 'generate_linode_lish_token_remote' | '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' | 'resize_linode_disk' - | 'resize_volume' | 'restore_linode_backup' - | 'set_default_payment_method' - | 'share_ips' - | 'share_ipv4' | 'shutdown_linode' - | 'update_account' - | 'update_account_settings' - | 'update_firewall' - | 'update_firewall_rules' - | 'update_image' | 'update_linode' | 'update_linode_config_profile' | 'update_linode_config_profile_interface' | 'update_linode_disk' - | 'update_linode_ip_address' - | 'update_nodebalancer' - | 'update_nodebalancer_config' - | 'update_nodebalancer_config_node' - | 'update_user' - | 'update_volume' - | 'update_vpc' - | 'update_vpc_subnet' + | 'update_linode_firewalls' | 'upgrade_linode' - | 'upload_image' - | 'view_account' - | 'view_account_settings' - | 'view_firewall' - | 'view_firewall_device' - | 'view_image' - | 'view_invoice' + | LinodeViewer; + +/** Permissions associated with the "linode_viewer" role. */ +export type LinodeViewer = + | 'list_linode_firewalls' + | 'list_linode_nodebalancers' + | 'list_linode_volumes' | 'view_linode' | 'view_linode_backup' | 'view_linode_config_profile' | 'view_linode_config_profile_interface' | '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_network_usage' - | 'view_nodebalancer' - | 'view_nodebalancer_config' - | 'view_nodebalancer_config_node' - | 'view_nodebalancer_statistics' - | 'view_payment' - | 'view_payment_method' - | 'view_user' - | 'view_volume' - | 'view_vpc' - | 'view_vpc_subnet'; + | 'view_linode_stats'; + +/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */ +export type AccountRoleFacade = + | 'account_database_creator' + | 'account_domain_creator' + | 'account_image_creator' + | 'account_ip_admin' + | 'account_ip_viewer' + | 'account_lkecluster_creator' + | 'account_longview_creator' + | 'account_longview_subscription_admin' + | 'account_nodebalancer_creator' + | 'account_placement_group_creator' + | 'account_stackscript_creator' + | 'account_vlan_admin' + | 'account_vlan_viewer' + | 'account_volume_creator' + | 'account_vpc_creator'; + +/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */ +export type EntityRoleFacade = + | 'database_admin' + | 'database_viewer' + | 'domain_admin' + | 'domain_viewer' + | 'image_admin' + | 'image_viewer' + | 'lkecluster_admin' + | 'lkecluster_viewer' + | 'longview_admin' + | 'longview_viewer' + | 'nodebalancer_admin' + | 'nodebalancer_viewer' + | 'placement_group_admin' + | 'placement_group_viewer' + | 'stackscript_admin' + | 'stackscript_viewer' + | 'volume_admin' + | 'volume_viewer' + | 'vpc_admin'; + +/** Union of all permissions */ +export type PermissionType = AccountAdmin; + +export interface IamUserRoles { + account_access: AccountRoleType[]; + entity_access: EntityAccess[]; +} + +export interface EntityAccess { + id: number; + roles: EntityRoleType[]; + type: AccessType; +} export interface IamAccountRoles { account_access: IamAccess[]; @@ -210,12 +366,12 @@ export interface IamAccountRoles { export interface IamAccess { roles: Roles[]; - type: EntityTypePermissions; + type: AccessType; } export interface Roles { description: string; - name: string; + name: RoleName; permissions: PermissionType[]; } diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 0971eb34474..b0937f2cd5c 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -2,6 +2,8 @@ export * from './account'; export * from './betas'; +export * from './cloudnat'; + export * from './cloudpulse'; export * from './databases'; diff --git a/packages/api-v4/src/linodes/linodes.ts b/packages/api-v4/src/linodes/linodes.ts index 2423bd8d6d1..39d81d58aef 100644 --- a/packages/api-v4/src/linodes/linodes.ts +++ b/packages/api-v4/src/linodes/linodes.ts @@ -3,7 +3,7 @@ import { UpdateLinodeSchema, } from '@linode/validation/lib/linodes.schema'; -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, @@ -31,7 +31,7 @@ import type { CreateLinodeRequest, Linode, LinodeLishData } from './types'; */ export const getLinode = (linodeId: number) => Request( - setURL(`${API_ROOT}/linode/instances/${encodeURIComponent(linodeId)}`), + setURL(`${BETA_API_ROOT}/linode/instances/${encodeURIComponent(linodeId)}`), setMethod('GET'), ); @@ -79,7 +79,7 @@ export const getLinodeVolumes = ( */ export const getLinodes = (params?: Params, filter?: Filter) => Request>( - setURL(`${API_ROOT}/linode/instances`), + setURL(`${BETA_API_ROOT}/linode/instances`), setMethod('GET'), setXFilter(filter), setParams(params), @@ -97,7 +97,7 @@ export const getLinodes = (params?: Params, filter?: Filter) => */ export const createLinode = (data: CreateLinodeRequest) => Request( - setURL(`${API_ROOT}/linode/instances`), + setURL(`${BETA_API_ROOT}/linode/instances`), setMethod('POST'), setData(data, CreateLinodeSchema), ); @@ -113,7 +113,7 @@ export const createLinode = (data: CreateLinodeRequest) => */ export const updateLinode = (linodeId: number, values: DeepPartial) => Request( - setURL(`${API_ROOT}/linode/instances/${encodeURIComponent(linodeId)}`), + setURL(`${BETA_API_ROOT}/linode/instances/${encodeURIComponent(linodeId)}`), setMethod('PUT'), setData(values, UpdateLinodeSchema), ); diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index cb71e96a4fa..6b1cdcb20e4 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -1,3 +1,5 @@ +import type { MaintenancePolicySlug } from '../account/types'; +import type { CloudPulseAlertsPayload } from '../cloudpulse/types'; import type { IPAddress, IPRange } from '../networking/types'; import type { LinodePlacementGroupPayload } from '../placement-groups/types'; import type { Region, RegionSite } from '../regions'; @@ -8,7 +10,6 @@ import type { UpdateLinodeInterfaceSettingsSchema, UpgradeToLinodeInterfaceSchema, } from '@linode/validation'; -import type { MaintenancePolicyId } from 'src/account'; import type { VPCIP } from 'src/vpcs'; import type { InferType } from 'yup'; @@ -43,7 +44,7 @@ export interface Linode { ipv6: null | string; label: string; lke_cluster_id: null | number; - maintenance_policy_id?: MaintenancePolicyId; + maintenance_policy?: MaintenancePolicySlug; placement_group: LinodePlacementGroupPayload | null; region: string; site_type: RegionSite; @@ -539,7 +540,11 @@ export interface CreateLinodePlacementGroupPayload { export interface CreateLinodeRequest { /** - * A list of public SSH keys that will be automatically appended to the root user’s + * Beta Aclp alerts + */ + alerts?: CloudPulseAlertsPayload | null; + /** + * A list of public SSH keys that will be automatically appended to the root user's * `~/.ssh/authorized_keys`file when deploying from an Image. */ authorized_keys?: null | string[]; @@ -550,7 +555,7 @@ export interface CreateLinodeRequest { */ authorized_users?: null | string[]; /** - * A Backup ID from another Linode’s available backups. + * A Backup ID from another Linode's available backups. * * Your User must have read_write access to that Linode, * the Backup must have a status of successful, @@ -601,7 +606,7 @@ export interface CreateLinodeRequest { */ interface_generation?: InterfaceGenerationType | null; /** - * An array of Network Interfaces to add to this Linode’s Configuration Profile. + * An array of Network Interfaces to add to this Linode's Configuration Profile. */ interfaces?: CreateLinodeInterfacePayload[] | InterfacePayload[]; /** @@ -610,7 +615,7 @@ export interface CreateLinodeRequest { */ ipv4?: string[]; /** - * The Linode’s label is for display purposes only. + * The Linode's label is for display purposes only. * If no label is provided for a Linode, a default will be assigned. */ label?: null | string; @@ -618,7 +623,7 @@ export interface CreateLinodeRequest { * Allows customers to specify which strategy this Linode should follow during * maintenance events. */ - maintenance_policy_id?: null | number; + maintenance_policy?: MaintenancePolicySlug; /** * An object containing user-defined data relevant to the creation of Linodes. */ @@ -642,7 +647,7 @@ export interface CreateLinodeRequest { */ region: string; /** - * This sets the root user’s password on a newly-created Linode Disk when deploying from an Image. + * This sets the root user's password on a newly-created Linode Disk when deploying from an Image. */ root_pass?: string; /** diff --git a/packages/api-v4/src/profile/types.ts b/packages/api-v4/src/profile/types.ts index 054e5bce838..ecda9295fb4 100644 --- a/packages/api-v4/src/profile/types.ts +++ b/packages/api-v4/src/profile/types.ts @@ -10,9 +10,10 @@ export interface Referrals { } export type TPAProvider = 'github' | 'google' | 'password'; + export interface Profile { authentication_type: TPAProvider; - authorized_keys: string[]; + authorized_keys: null | string[]; email: string; email_notifications: boolean; ip_whitelist_enabled: boolean; diff --git a/packages/api-v4/src/regions/regions.ts b/packages/api-v4/src/regions/regions.ts index 4048afd5837..19ac6b8ca49 100644 --- a/packages/api-v4/src/regions/regions.ts +++ b/packages/api-v4/src/regions/regions.ts @@ -1,4 +1,4 @@ -import { API_ROOT } from '../constants'; +import { BETA_API_ROOT } from '../constants'; import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; import { Region } from './types'; @@ -19,7 +19,7 @@ import type { RegionAvailability } from './types'; */ export const getRegions = (params?: Params) => Request>( - setURL(`${API_ROOT}/regions`), + setURL(`${BETA_API_ROOT}/regions`), setMethod('GET'), setParams(params), ); @@ -34,7 +34,7 @@ export const getRegions = (params?: Params) => */ export const getRegion = (regionId: string) => Request( - setURL(`${API_ROOT}/regions/${encodeURIComponent(regionId)}`), + setURL(`${BETA_API_ROOT}/regions/${encodeURIComponent(regionId)}`), setMethod('GET'), ); @@ -47,7 +47,7 @@ export { Region }; */ export const getRegionAvailabilities = (params?: Params, filter?: Filter) => Request>( - setURL(`${API_ROOT}/regions/availability`), + setURL(`${BETA_API_ROOT}/regions/availability`), setMethod('GET'), setParams(params), setXFilter(filter), @@ -62,6 +62,8 @@ export const getRegionAvailabilities = (params?: Params, filter?: Filter) => */ export const getRegionAvailability = (regionId: string) => Request( - setURL(`${API_ROOT}/regions/${encodeURIComponent(regionId)}/availability`), + setURL( + `${BETA_API_ROOT}/regions/${encodeURIComponent(regionId)}/availability`, + ), setMethod('GET'), ); diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 8c40c392d84..762ac3a8934 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -27,6 +27,11 @@ export type Capabilities = | 'Vlans' | 'VPCs'; +export interface MonitoringCapabilities { + alerts: Capabilities[]; + metrics: Capabilities[]; +} + export interface DNSResolvers { ipv4: string; // Comma-separated IP addresses ipv6: string; // Comma-separated IP addresses @@ -41,6 +46,13 @@ export interface Region { country: Country; id: string; label: string; + /** + * CloudPulse monitoring capabilities that are available in the region. + * + * **Upcoming Feature Notice:** this property may not be available to all customers + * and may change in subsequent releases. + */ + monitors?: MonitoringCapabilities; placement_group_limits: { maximum_linodes_per_pg: number; maximum_pgs_per_customer: null | number; // This value can be unlimited for some customers, for which the API returns the `null` value. diff --git a/packages/manager/.changeset/pr-12441-fixed-1751029952198.md b/packages/manager/.changeset/pr-12441-fixed-1751029952198.md new file mode 100644 index 00000000000..cded7043d92 --- /dev/null +++ b/packages/manager/.changeset/pr-12441-fixed-1751029952198.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +ACLP: change `scope` in `CreateAlertDefinitionForm` to optional ([#12441](https://github.com/linode/manager/pull/12441)) diff --git a/packages/manager/.storybook/main.ts b/packages/manager/.storybook/main.ts index b1270f5ba05..552fe867e5a 100644 --- a/packages/manager/.storybook/main.ts +++ b/packages/manager/.storybook/main.ts @@ -9,13 +9,8 @@ const config: StorybookConfig = { '../../ui/src/components/**/*.@(mdx|stories.@(js|ts|jsx|tsx))', ], addons: [ + '@vueless/storybook-dark-mode', '@storybook/addon-docs', - '@storybook/addon-controls', - '@storybook/addon-viewport', - '@storybook/addon-measure', - '@storybook/addon-actions', - 'storybook-dark-mode', - '@storybook/addon-storysource', '@storybook/addon-a11y', ], staticDirs: ['../public'], @@ -43,18 +38,12 @@ const config: StorybookConfig = { reactDocgen: 'react-docgen-typescript', }, docs: { - autodocs: true, defaultName: 'Documentation', }, async viteFinal(config) { return mergeConfig(config, { optimizeDeps: { - include: [ - '@storybook/react', - '@storybook/react-vite', - 'react', - 'react-dom', - ], + include: ['@storybook/react-vite', 'react', 'react-dom'], esbuildOptions: { target: 'esnext', }, diff --git a/packages/manager/.storybook/manager-head.html b/packages/manager/.storybook/manager-head.html deleted file mode 100644 index 13d93017d02..00000000000 --- a/packages/manager/.storybook/manager-head.html +++ /dev/null @@ -1,5 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/.storybook/manager.ts b/packages/manager/.storybook/manager.ts deleted file mode 100644 index 19cc78d5356..00000000000 --- a/packages/manager/.storybook/manager.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { create } from '@storybook/theming'; -import { addons } from '@storybook/manager-api'; -import Logo from '../src/assets/logo/akamai-logo-color.svg'; - -const theme = create({ - base: 'light', - brandTitle: 'Akamai', - brandUrl: 'https://www.linode.com', - brandImage: Logo, -}); - -addons.setConfig({ - theme, -}); diff --git a/packages/manager/.storybook/preview-head.html b/packages/manager/.storybook/preview-head.html deleted file mode 100644 index 05da1e9dfbf..00000000000 --- a/packages/manager/.storybook/preview-head.html +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 6de6f42f5ce..ad9783a7b65 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -1,41 +1,57 @@ import React from 'react'; -import { Preview } from '@storybook/react'; -import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport'; +import { Preview } from '@storybook/react-vite'; +import { storybookWorker } from '../src/mocks/mswWorkers'; +import { DocsContainer as BaseContainer } from '@storybook/addon-docs/blocks'; +import { + DARK_MODE_EVENT_NAME, + useDarkMode, +} from '@vueless/storybook-dark-mode'; +import { PropsWithChildren } from 'react'; +import { addons } from 'storybook/preview-api'; +import { themes } from './themes'; import { Title, Subtitle, Description, Primary, Controls, - Stories, -} from '@storybook/blocks'; + DocsContainerProps, +} from '@storybook/addon-docs/blocks'; import { wrapWithTheme, wrapWithThemeAndRouter, } from '../src/utilities/testHelpers'; -import { useDarkMode } from 'storybook-dark-mode'; -import { DocsContainer as BaseContainer } from '@storybook/addon-docs'; -import { themes } from '@storybook/theming'; -import { storybookWorker } from '../src/mocks/mswWorkers'; - import '../src/index.css'; -// TODO: M3-6705 Remove this when replacing @reach/tabs with MUI Tabs -import '@reach/tabs/styles.css'; +import '@reach/tabs/styles.css'; // @todo M3-6705 Remove this when replacing @reach/tabs with MUI Tabs -MINIMAL_VIEWPORTS.mobile1.styles = { - height: '667px', - width: '375px', -}; +const channel = addons.getChannel(); + +storybookWorker.start({ onUnhandledRequest: 'bypass' }); + +/** + * This exists as a workaround to get dark mode working in "docs" pages. + * See https://github.com/hipstersmoothie/storybook-dark-mode/issues/282#issuecomment-2246463856 + */ +function useDocsDarkMode() { + const [isDark, setDark] = React.useState(channel.last(DARK_MODE_EVENT_NAME)); -export const DocsContainer = ({ children, context }) => { - const isDark = useDarkMode(); + React.useEffect(() => { + channel.on(DARK_MODE_EVENT_NAME, setDark); + return () => channel.off(DARK_MODE_EVENT_NAME, setDark); + }, [channel, setDark]); + + return isDark; +} + +export const DocsContainer = ({ + children, + context, +}: PropsWithChildren) => { + const isDark = useDocsDarkMode(); return ( {children} @@ -54,17 +70,7 @@ const preview: Preview = { : wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); }, ], - loaders: [ - async () => ({ - msw: await storybookWorker?.start(), - }), - ], parameters: { - backgrounds: { - grid: { - disable: true, - }, - }, options: { storySort: { method: 'alphabetical', @@ -78,12 +84,9 @@ const preview: Preview = { ], }, }, - viewport: { - viewports: MINIMAL_VIEWPORTS, - }, darkMode: { - dark: { ...themes.dark }, - light: { ...themes.normal }, + dark: themes.dark, + light: themes.light, }, controls: { expanded: true, @@ -104,8 +107,10 @@ const preview: Preview = { ), + codePanel: true, }, }, + tags: ['autodocs'], }; export default preview; diff --git a/packages/manager/.storybook/themes.ts b/packages/manager/.storybook/themes.ts new file mode 100644 index 00000000000..86f800db74d --- /dev/null +++ b/packages/manager/.storybook/themes.ts @@ -0,0 +1,19 @@ +import { create, ThemeVarsColors } from 'storybook/theming'; + +const baseTheme: Partial = { + brandTitle: 'Akamai', + brandUrl: 'https://www.linode.com', + brandImage: + 'https://raw.githubusercontent.com/linode/manager/refs/heads/develop/packages/manager/src/assets/logo/akamai-logo-color.svg', +}; + +export const themes = { + light: create({ + base: 'light', + ...baseTheme, + }), + dark: create({ + base: 'dark', + ...baseTheme, + }), +}; diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 9eeae97c9d5..a2a05778882 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,68 @@ 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-07-01] - v1.145.0 + + +### Changed: + +- Kubernetes cluster details to show restricted access warnings and disabled actions ([#12360](https://github.com/linode/manager/pull/12360)) +- Improve LISH Console Settings page ([#12390](https://github.com/linode/manager/pull/12390)) +- Use white icons for Marketplace dark mode ([#12414](https://github.com/linode/manager/pull/12414)) + +### Fixed: + +- ACLP-Alerting: UI misalignments in Alert Create/Edit form in the Criteria section ([#12402](https://github.com/linode/manager/pull/12402)) +- Incorrect checkbox colors in dark mode causing check icon to appear white instead of black ([#12409](https://github.com/linode/manager/pull/12409)) +- Incorrectly styled Linode Disk row action menu and buttons ([#12413](https://github.com/linode/manager/pull/12413)) +- Incorrectly styled LKE cluster node row action button ([#12413](https://github.com/linode/manager/pull/12413)) + +### Removed: + +- Move IAM queries and dependencies to shared `queries` package ([#12370](https://github.com/linode/manager/pull/12370)) +- Move network transfer queries and dependencies to shared `queries` package ([#12381](https://github.com/linode/manager/pull/12381)) + +### Tech Stories: + +- Reroute Kubernetes ([#12294](https://github.com/linode/manager/pull/12294)) +- Reroute IAM ([#12312](https://github.com/linode/manager/pull/12312)) +- Reroute Profile ([#12338](https://github.com/linode/manager/pull/12338)) +- Disable `no-await-in-loop` ESLint rule ([#12362](https://github.com/linode/manager/pull/12362)) +- Disable `prefer-screen-queries` ESLint rule ([#12362](https://github.com/linode/manager/pull/12362)) +- Update to Storybook v9 ([#12416](https://github.com/linode/manager/pull/12416)) +- TextField and Autocomplete components to wrap startAdornment and endAdornment props using InputAdornment ([#12387](https://github.com/linode/manager/pull/12387)) + +### Tests: + +- Add Cypress tests for Linode details metrics when beta is disabled ([#12337](https://github.com/linode/manager/pull/12337)) +- Fix test flake in `bucket-create-multicluster.spec.ts` ([#12347](https://github.com/linode/manager/pull/12347)) +- Attempt to fix `Select.test.tsx` test flake ([#12362](https://github.com/linode/manager/pull/12362)) +- Account for region availability when selecting regions for tests ([#12378](https://github.com/linode/manager/pull/12378)) +- Disable `nodebalancerVpc` flag in cypress tests ([#12389](https://github.com/linode/manager/pull/12389)) +- Fix Cypress Linode clone test ([#12403](https://github.com/linode/manager/pull/12403)) +- Allow Cypress test resource label prefix to be set via `CY_TEST_RESOURCE_PREFIX` env var ([#12407](https://github.com/linode/manager/pull/12407)) +- Fix flaky test in `VPCSubnetsTable.test.tsx` ([#12425](https://github.com/linode/manager/pull/12425)) + +### Upcoming Features: + +- Assign alert definitions to a Linode during creation ([#12248](https://github.com/linode/manager/pull/12248)) +- Streamline Linode Interface logic for Firewall Landing and Device tables to use new API fields ([#12283](https://github.com/linode/manager/pull/12283)) +- Add VM Host Maintenance Policy selection and display sections to Linode detail view ([#12334](https://github.com/linode/manager/pull/12334)) +- IAM RBAC: Standardize drawer error messages, add an error message for the lasc account admin ([#12371](https://github.com/linode/manager/pull/12371)) +- Update ACLP region support logic for Beta ACLP features based on `/regions` endpoint ([#12375](https://github.com/linode/manager/pull/12375)) +- ACLP - Alerts: enabled and integrated Delete API, added test cases to verify the deletion ([#12376](https://github.com/linode/manager/pull/12376)) +- Add `EntityScopeSelect` component, add `serviceAlertFactory` in services.ts factory file ([#12377](https://github.com/linode/manager/pull/12377)) +- Change `AclpBetaServices` interface in `featureFlags.ts`, add `beta chip` in `metrics & alerts` components based on the `AclpBetaServices` feature flag in cloudpulse ([#12386](https://github.com/linode/manager/pull/12386)) +- Add feature flag support for Cloud NAT ([#12388](https://github.com/linode/manager/pull/12388)) +- Add beta `Alerts Assigned +n` count to Linode Create flow Summary ([#12396](https://github.com/linode/manager/pull/12396)) +- Make copy updates to VM Host Maintenance Banners ([#12397](https://github.com/linode/manager/pull/12397)) +- Add new maintenance policy icons to linode rows ([#12398](https://github.com/linode/manager/pull/12398)) +- IAM RBAC: add the docs links, fix typo and styling issue ([#12410](https://github.com/linode/manager/pull/12410)) +- Add maintenance policy support for VM maintenance API ([#12417](https://github.com/linode/manager/pull/12417)) +- Add VM Host Maintenance support to Linode headers and rows ([#12418](https://github.com/linode/manager/pull/12418)) +- Fix incorrect filter for in-progress maintenance ([#12436](https://github.com/linode/manager/pull/12436)) +- Add CRUD CloudNAT factories and mocks ([#12379](https://github.com/linode/manager/pull/12379)) + ## [2025-06-17] - v1.144.0 diff --git a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts index d9de39ecb13..b27943a4e06 100644 --- a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts @@ -79,7 +79,10 @@ describe('User verification banner', () => { .should('be.visible') .should('be.enabled') .click(); - cy.url().should('endWith', `/profile/auth`); + cy.url().should( + 'endWith', + `/profile/auth?focusSecurityQuestions=true&focusTel=false` + ); }); /* @@ -157,7 +160,10 @@ describe('User verification banner', () => { .should('be.visible') .should('be.enabled') .click(); - cy.url().should('endWith', `/profile/auth`); + cy.url().should( + 'endWith', + `/profile/auth?focusSecurityQuestions=true&focusTel=false` + ); }); /* diff --git a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts new file mode 100644 index 00000000000..d5c9e977b4b --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts @@ -0,0 +1,202 @@ +import { linodeFactory } from '@linode/utilities'; +import { regionFactory } from '@linode/utilities'; +import { mockGetCloudPulseDashboardByIdError } from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodeDetails } from 'support/intercepts/linodes'; +import { mockGetLinodeStats } from 'support/intercepts/linodes'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { + assertBetaMetricsNotAvailable, + generateMockLegacyStats, +} from 'support/util/cloudpulse'; +import { randomLabel, randomNumber } from 'support/util/random'; + +import type { Stats } from '@linode/api-v4'; + +/** + * If feature flag and region supports aclp: + * If user preference enables aclp, then UI displays beta metrics w/ option to switch to legacy metrics + * If user preference disables aclp, then UI displays legacy metrics w/ option to switch to beta metrics + * + * If region does not support aclp, then the UI displays legacy metrics w/ no option to switch to beta metrics + */ +describe('ACLP Components UI varies according to ACLP support by region and user preference', function () { + beforeEach(function () { + mockAppendFeatureFlags({ + aclpBetaServices: { + linode: { + alerts: false, + metrics: true, + }, + }, + }).as('getFeatureFlags'); + }); + describe('toggle user preference when region supports aclp', function () { + beforeEach(function () { + const mockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + monitors: { + metrics: ['Linodes'], + }, + }); + mockGetRegions([mockRegion]).as('getRegions'); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockRegion.id, + }); + cy.wrap(mockLinode.id).as('mockLinodeId'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + + // linodeDashboardId is hardcoded in LinodeMetrics.tsx to 2 + // error param is not displayed + const mockDashboardId = 2; + cy.wrap(mockDashboardId).as('mockDashboardId'); + const mockDashboardError = 'mock dashboard error occurred'; + mockGetCloudPulseDashboardByIdError( + mockDashboardId, + mockDashboardError + ).as('getDashboardError'); + }); + // UI displays beta metrics, can switch to legacy view + it('user preference enables aclp', function () { + mockGetUserPreferences({ isAclpMetricsBeta: true }).as( + 'getUserPreferences' + ); + cy.visitWithLogin(`/linodes/${this.mockLinodeId}/metrics`); + cy.wait([ + '@getFeatureFlags', + '@getUserPreferences', + '@getRegions', + '@getLinode', + '@getDashboardError', + ]); + // tab header is "Metrics Beta", not "Metrics" + cy.get('[data-testid="Metrics"]') + .should('be.visible') + .should('be.enabled') + .within(() => { + cy.get('[data-testid="betaChip"]').should('be.visible'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + // banner container but indeterminate what its content is + cy.get('[data-testid="metrics-preference-banner-text"]').should( + 'be.visible' + ); + cy.contains( + 'Welcome to Metrics (Beta) with more options and greater flexibility for better data analysis.' + ).should('be.visible'); + + ui.button + .findByTitle('Switch to legacy Metrics') + .should('be.visible') + .should('be.enabled'); + // UI displays mock error msg + cy.contains( + `Error while loading Dashboard with Id - ${this.mockDashboardId}` + ); + }); + }); + + // UI displays legacy metrics, can switch to beta view + it('user preference disables aclp', function () { + const msgBeta = + 'Try the new Metrics (Beta) with more options and greater flexibility for better data analysis. You can switch back to the current view at any time.'; + mockGetUserPreferences({ isAclpMetricsBeta: false }).as( + 'getUserPreferences' + ); + const mockLegacyStats: Stats = generateMockLegacyStats(); + mockGetLinodeStats(this.mockLinodeId, mockLegacyStats).as( + 'getLinodeStats' + ); + cy.visitWithLogin(`/linodes/${this.mockLinodeId}/metrics`); + cy.wait([ + '@getFeatureFlags', + '@getLinode', + '@getLinodeStats', + '@getUserPreferences', + ]); + // tab header is "Metrics", not "Metrics Beta" + cy.get('[data-testid="Metrics"]') + .should('be.visible') + .should('be.enabled') + .within(() => { + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + // banner container but indeterminate what its content is + cy.get('[data-testid="metrics-preference-banner-text"]').should( + 'be.visible' + ); + // expect legacy metrics view of LinodeSummary component to be displayed + cy.get('[data-testid="linode-summary"]').should('be.visible'); + cy.contains(msgBeta).should('be.visible'); + // switch to beta metrics + ui.button + .findByTitle('Try the Metrics (Beta)') + .should('be.visible') + .should('be.enabled') + .click(); + // wait for dashboard query to complete + cy.wait('@getDashboardError'); + cy.contains( + 'Welcome to Metrics (Beta) with more options and greater flexibility for better data analysis.' + ).should('be.visible'); + cy.contains(msgBeta).should('not.exist'); + ui.button + .findByTitle('Switch to legacy Metrics') + .should('be.visible') + .should('be.enabled'); + }); + // tab header is "Metrics Beta", not "Metrics" + cy.get('[data-testid="Metrics"]') + .should('be.visible') + .should('be.enabled') + .within(() => { + cy.get('[data-testid="betaChip"]').should('be.visible'); + }); + }); + }); + + describe('region does not support aclp', () => { + beforeEach(() => { + // does not support metrics bc missing monitors + const mockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + }); + mockGetRegions([mockRegion]).as('getRegions'); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockRegion.id, + }); + cy.wrap(mockLinode.id).as('mockLinodeId'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + const mockLegacyStats = generateMockLegacyStats(); + mockGetLinodeStats(mockLinode.id, mockLegacyStats).as('getLinodeStats'); + }); + // UI displays legacy metrics, no option to switch to beta view + it('user preference enables aclp', function () { + mockGetUserPreferences({ isAclpMetricsBeta: true }).as( + 'getUserPreferences' + ); + cy.visitWithLogin(`/linodes/${this.mockLinodeId}/metrics`); + assertBetaMetricsNotAvailable(); + }); + + // UI displays legacy metrics, no option to switch to beta view + it('user preference disables aclp', function () { + mockGetUserPreferences({ isAclpMetricsBeta: false }).as( + 'getUserPreferences' + ); + cy.visitWithLogin(`/linodes/${this.mockLinodeId}/metrics`); + assertBetaMetricsNotAvailable(); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index a4438ce6289..0d685bd2e93 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -6,6 +6,7 @@ import { profileFactory } from '@linode/utilities'; import { cloudPulseServiceMap } from 'support/constants/cloudpulse'; import { mockGetAccount } from 'support/intercepts/account'; import { + mockDeleteAlert, mockGetAllAlertDefinitions, mockGetCloudPulseServices, mockUpdateAlertDefinitions, @@ -22,6 +23,7 @@ import { } from 'src/features/CloudPulse/Alerts/AlertsListing/constants'; import { alertStatuses, + DELETE_ALERT_SUCCESS_MESSAGE, UPDATE_ALERT_SUCCESS_MESSAGE, } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; @@ -33,12 +35,19 @@ const alertDefinitionsUrl = '/alerts/definitions'; const mockProfile = profileFactory.build({ timezone: 'gmt', }); -const flags: Partial = { aclp: { beta: true, enabled: true } }; +const flags: Partial = { + aclp: { beta: true, enabled: true }, + aclpBetaServices: { + dbaas: { metrics: true, alerts: true }, + linode: { metrics: true, alerts: true }, + }, +}; const mockAccount = accountFactory.build(); const now = new Date(); const mockAlerts = [ alertFactory.build({ created_by: 'user1', + id: 1, entity_ids: ['1', '2', '3', '4', '5'], label: 'Alert-1', service_type: 'dbaas', @@ -50,6 +59,7 @@ const mockAlerts = [ }), alertFactory.build({ created_by: 'user4', + id: 2, updated_by: 'updated4', entity_ids: ['1', '2', '3', '4', '5'], label: 'Alert-2', @@ -61,6 +71,7 @@ const mockAlerts = [ }), alertFactory.build({ created_by: 'user2', + id: 3, updated_by: 'updated2', entity_ids: ['1', '2', '3', '4', '5'], label: 'Alert-3', @@ -72,6 +83,7 @@ const mockAlerts = [ }), alertFactory.build({ created_by: 'user3', + id: 4, updated_by: 'updated3', entity_ids: ['1', '2', '3', '4', '5'], label: 'Alert-4', @@ -161,7 +173,7 @@ const validateAlertDetails = (alert: Alert) => { cy.get(`[data-qa-alert-cell="${id}"]`).within(() => { cy.findByText(cloudPulseServiceMap[service_type]) .should('be.visible') - .and('have.text', cloudPulseServiceMap[service_type]); + .and('have.text', `${cloudPulseServiceMap[service_type]} beta`); cy.findByText(alertStatuses[status]) .should('be.visible') @@ -212,6 +224,7 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { ); cy.visitWithLogin(alertDefinitionsUrl); cy.wait('@getAlertDefinitionsList'); + mockDeleteAlert('dbaas', 1).as('deleteAlert'); }); it('should verify sorting functionality for multiple columns in ascending and descending order', () => { @@ -490,7 +503,9 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { updated_by: `updated_user-${index}`, }) ); - + mockAlerts.forEach((alert) => { + mockDeleteAlert('dbaas', alert.id).as(`deleteAlert-${alert.label}`); + }); mockGetAllAlertDefinitions(mockAlerts).as('getAlertDefinitionsList'); cy.visitWithLogin(alertDefinitionsUrl); cy.wait('@getAlertDefinitionsList'); @@ -515,4 +530,141 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { 'Creation of 25 alerts has failed as indicated in the status column. Please open a support ticket for assistance.' ); }); + + /** + * Validates the delete flow for an alert based on its label and whether deletion is allowed. + * @param {string} label - The label of the alert to be deleted. + * @param {boolean} canDelete - Whether the alert can be deleted. + */ + const validateDeleteFlow = (label: string, canDelete: boolean) => { + // Locate the alert row by label and open the action menu + cy.findByText(label) + .should('be.visible') + .closest('tr') + .within(() => { + // Open the action menu for the alert + ui.actionMenu + .findByTitle(`Action menu for Alert ${label}`) + .should('be.visible') + .click(); + }); + if (canDelete) { + // Click the "Delete" option from the action menu + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + // Verify the delete confirmation dialog appears with the correct title + ui.dialog + .findByTitle(`Delete ${label}? `) + .should('be.visible') + .within(() => { + // Focus the "Alert Label" confirmation input + cy.findByLabelText('Alert Label').click(); + + // Type the alert label to enable the Delete button + cy.focused().type(label); + + // Click the Delete button to confirm + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + + // Wait for the DELETE request to be intercepted + cy.wait(`@deleteAlert-${label}`); + + // Verify the user is redirected to the alerts listing page + cy.url().should('endWith', '/alerts/definitions'); + + // Confirm the success toast message is shown + ui.toast.assertMessage(DELETE_ALERT_SUCCESS_MESSAGE); + + // Ensure the deleted alert no longer appears in the list + cy.findByText(label).should('not.exist'); + } else { + ui.actionMenuItem.findByTitle('Delete').should('be.disabled'); + } + }; + + statusList.forEach((status, index) => { + // Loop through each alert status in the list + it(`should validate the delete alert behavior based on its status: ${status} `, () => { + const label = `Alert-${index + 1}`; + const id = index + 1; + + const mockAlert = alertFactory.build({ + id, + label, + service_type: 'dbaas', + status, + type: 'user', + created_by: 'user1', + updated_by: 'user1', + }); + // Build a mock alert object with required fields + + mockGetAllAlertDefinitions([mockAlert]).as(`getAlerts-${label}`); + // Mock the API call to fetch alerts with this mock alert + + mockDeleteAlert('dbaas', id).as(`deleteAlert-${label}`); + // Mock the API call to delete this alert + + cy.visitWithLogin(alertDefinitionsUrl); + // Visit the alerts page as a logged-in user + + cy.wait(`@getAlerts-${label}`); + // Wait for the alerts to load + + const canDelete = status === 'enabled' || status === 'disabled'; + // Determine if the alert should allow deletion + + validateDeleteFlow(label, canDelete); + // Run the test steps to verify the delete behavior + }); + }); + + it('should validate that the delete button is not available for a system alert with status: enabled', () => { + const label = 'system-alert'; + + const mockAlert = alertFactory.build({ + id: 1, + label, + service_type: 'dbaas', + status: 'enabled', + type: 'system', + }); + + // Mock the alert list API with a system alert + mockGetAllAlertDefinitions([mockAlert]).as('getSystemAlert'); + + // Visit the Alert Definitions page as a logged-in user + cy.visitWithLogin(alertDefinitionsUrl); + + // Wait for alerts to load and find the row containing the system alert + cy.wait('@getSystemAlert'); + // Verify that the system alert is visible in the table + cy.findByText(label) + .should('be.visible') + .closest('tr') + .within(() => { + // Open the action menu + ui.actionMenu + .findByTitle(`Action menu for Alert ${label}`) + .should('be.visible') + .click(); + }); + + // Verify that the Delete menu item is NOT present + cy.get('[data-qa-action-menu-item="Delete"]').should('not.exist'); + + // Define the list of expected visible action menu items + const visibleItems = ['Edit', 'Show Details']; + // Loop through each expected item and assert that it exists and is visible in the action menu + visibleItems.forEach((item) => { + cy.get(`[data-qa-action-menu-item="${item}"]`) + .should('exist') + .and('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/firewalls/add-device-to-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/add-device-to-firewall.spec.ts index 27c1e3a9680..73624d9b9ee 100644 --- a/packages/manager/cypress/e2e/core/firewalls/add-device-to-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/add-device-to-firewall.spec.ts @@ -80,6 +80,13 @@ describe('Can add Linode and Linode Interface devices to firewalls', () => { type: 'linode_interface', id: mockLinodeInterface.id, url: `/v4/linode/instances/${mockNewInterfaceLinode.id}/interfaces/${mockLinodeInterface.id}`, + label: null, + parent_entity: { + label: mockNewInterfaceLinode.label, + id: mockNewInterfaceLinode.id, + type: 'linode', + url: `/v4/linode/instances/${mockNewInterfaceLinode.id}`, + }, }, }); @@ -226,6 +233,13 @@ describe('Can add Linode and Linode Interface devices to firewalls', () => { type: 'linode_interface', id: mockVPCInterface.id, url: `/v4/linode/instances/${mockMultipleEligibleInterfacesLinode.id}/interfaces/${mockVPCInterface.id}`, + label: null, + parent_entity: { + label: mockMultipleEligibleInterfacesLinode.label, + id: mockMultipleEligibleInterfacesLinode.id, + type: 'linode', + url: `/v4/linode/instances/${mockMultipleEligibleInterfacesLinode.id}`, + }, }, }); @@ -356,6 +370,13 @@ describe('Can add Linode and Linode Interface devices to firewalls', () => { type: 'linode_interface', id: mockPublicInterface.id, url: `/v4/linode/instances/${mockAlreadyAssignedLILinode.id}/interfaces/${mockPublicInterface.id}`, + label: null, + parent_entity: { + label: mockAlreadyAssignedLILinode.label, + id: mockAlreadyAssignedLILinode.id, + type: 'linode', + url: `/v4/linode/instances/${mockAlreadyAssignedLILinode.id}`, + }, }, }); diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index 5cb4683671f..f979b0cd28a 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -64,7 +64,7 @@ describe('open support tickets', () => { const image = 'test_screenshot.png'; const ticketDescription = 'this is a test ticket'; - const ticketLabel = 'cy-test ticket'; + const ticketLabel = randomLabel(); const ticketId = Math.floor(Math.random() * 99999999 + 10000000); const ts = new Date(); @@ -89,7 +89,7 @@ describe('open support tickets', () => { opened: ts.toISOString(), opened_by: user, status: 'new', - summary: 'cy-test ticket', + summary: ticketLabel, updated: ts.toISOString(), updated_by: user, }); 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 705324c3f4f..25cfe3dd3cc 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1047,6 +1047,8 @@ describe('LKE cluster updates', () => { cy.findByLabelText('Add 1').should('be.visible').click(); }); + ui.button.findByTitle('Add pool').scrollIntoView(); + ui.button .findByTitle('Add pool') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 6b13bcd7b07..d42fe32577c 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -21,6 +21,7 @@ import { LINODE_CREATE_TIMEOUT, } from 'support/constants/linodes'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { interceptEvents } from 'support/intercepts/events'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptCloneLinode, @@ -46,7 +47,7 @@ import { } from 'support/util/random'; import { chooseRegion, extendRegion } from 'support/util/regions'; -import type { Linode } from '@linode/api-v4'; +import type { Event, Linode } from '@linode/api-v4'; /** * Returns the Cloud Manager URL to clone a given Linode. @@ -78,7 +79,7 @@ describe('clone linode', () => { */ it('can clone a Linode from Linode details page', () => { cy.tag('method:e2e', 'purpose:dcTesting'); - const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); + const linodeRegion = chooseRegion({ capabilities: ['Linodes', 'Vlans'] }); const linodePayload = createLinodeRequestFactory.build({ booted: false, label: randomLabel(), @@ -96,6 +97,7 @@ describe('clone linode', () => { createTestLinode(linodePayload, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { interceptCloneLinode(linode.id).as('cloneLinode'); + interceptEvents().as('cloneEvents'); cy.visitWithLogin(`/linodes/${linode.id}`); // Wait for Linode to boot, then initiate clone flow. @@ -138,9 +140,30 @@ describe('clone linode', () => { }); ui.toast.assertMessage(`Your Linode ${newLinodeLabel} is being created.`); - ui.toast.assertMessage( - `Linode ${linode.label} has been cloned to ${newLinodeLabel}.`, - { timeout: LINODE_CLONE_TIMEOUT } + + // Change the way to check the clone progress due to M3-9860 + cy.wait('@cloneEvents').then((xhr) => { + const eventData: Event[] = xhr.response?.body?.data; + const cloneEvent = eventData.filter( + (event: Event) => event['action'] === 'linode_clone' + ); + cy.get('[id="menu-button--notification-events-menu"]') + .should('be.visible') + .click(); + cy.get(`[data-qa-event="${cloneEvent[0]['id']}"]`).should('be.visible'); + cy.get('[data-testid="linear-progress"]').should('be.visible'); + // The progress bar should disappear when the clone is done. + cy.get('[data-testid="linear-progress"]', { + timeout: LINODE_CLONE_TIMEOUT, + }).should('not.exist'); + }); + + cy.visit('/linodes'); + cy.findByText(newLinodeLabel, { timeout: LINODE_CLONE_TIMEOUT }).should( + 'be.visible' + ); + cy.findByText(linode.label, { timeout: LINODE_CLONE_TIMEOUT }).should( + 'be.visible' ); }); }); 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 c4d8ad31c55..835a396b0b7 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -349,7 +349,7 @@ describe('Linode Config management', () => { 'Waiting for 2 Linodes to be created' ).then(([sourceLinode, destLinode]: [Linode, Linode]) => { const kernel = findKernelById(kernels, 'linode/latest-64bit'); - const sharedConfigLabel = 'cy-test-sharable-config'; + const sharedConfigLabel = `${randomLabel()}-shareable`; cy.visitWithLogin(`/linodes/${sourceLinode.id}/configurations`); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index a0042681ffc..9c25e0ea761 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -9,6 +9,7 @@ import { import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; +import { randomLabel } from 'support/util/random'; import type { Linode } from '@linode/api-v4'; @@ -165,7 +166,7 @@ describe('linode storage tab', () => { * - Confirms that Cloud Manager UI automatically updates to reflect deleted disk. */ it('delete disk', () => { - const diskName = 'cy-test-disk'; + const diskName = randomLabel(); cy.defer(() => createTestLinode({ image: null })).then((linode) => { interceptDeleteDisks(linode.id).as('deleteDisk'); interceptAddDisks(linode.id).as('addDisk'); @@ -202,7 +203,7 @@ describe('linode storage tab', () => { * - Confirms that Cloud Manager UI automatically updates to reflect new disk. */ it('add a disk', () => { - const diskName = 'cy-test-disk'; + const diskName = randomLabel(); cy.defer(() => createTestLinode({ image: null })).then((linode: Linode) => { interceptAddDisks(linode.id).as('addDisk'); cy.visitWithLogin(`/linodes/${linode.id}/storage`); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts index d28f24324a9..62cee07d285 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts @@ -65,7 +65,11 @@ describe('Object Storage Multicluster Bucket create', () => { cy.visitWithLogin('/object-storage'); cy.wait(['@getRegions', '@getBuckets']); - ui.button.findByTitle('Create Bucket').should('be.visible').click(); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + ui.button.findByTitle('Create Bucket').should('be.visible').click(); + }); ui.drawer .findByTitle('Create Bucket') diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts index 084f1b6cf29..30931ed1c2e 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts @@ -35,7 +35,7 @@ describe('Placement Groups navigation', () => { .findByTitle('Create Placement Group') .should('be.visible') .click(); - cy.url().should('endWith', '/placement-groups/create'); + cy.url().should('endWith', '/placement-groups?action=create'); ui.drawer .findByTitle('Create Placement Group') diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts index 954ea4fe5f5..ef9807d401d 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts @@ -2,6 +2,7 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; import { grantsFactory, profileFactory } from '@linode/utilities'; import { subnetFactory, vpcFactory } from '@src/factories'; import { mockGetUser } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; /** * @file Integration tests for VPC create flow. */ @@ -48,6 +49,13 @@ describe('VPC create flow', () => { * - Confirms that UI handles API errors gracefully. * - Confirms that UI redirects to created VPC page after creating a VPC. */ + beforeEach(() => { + // TODO - Remove mock once `nodebalancerVpc` feature flag is removed. + mockAppendFeatureFlags({ + nodebalancerVpc: false, + }).as('getFeatureFlags'); + }); + it('can create a VPC', () => { const mockVPCRegion = extendRegion( regionFactory.build({ @@ -83,7 +91,7 @@ describe('VPC create flow', () => { mockGetRegions([mockVPCRegion]).as('getRegions'); cy.visitWithLogin('/vpcs/create'); - cy.wait('@getRegions'); + cy.wait(['@getRegions', '@getFeatureFlags']); ui.regionSelect.find().click(); cy.focused().type(`${mockVPCRegion.label}{enter}`); @@ -281,7 +289,7 @@ describe('VPC create flow', () => { mockGetRegions([mockVPCRegion]).as('getRegions'); cy.visitWithLogin('/vpcs/create'); - cy.wait('@getRegions'); + cy.wait(['@getRegions', '@getFeatureFlags']); ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); 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 f497cfdc54c..ad30bf81a72 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 @@ -16,6 +16,7 @@ import { mockDeleteLinodeConfigInterface, mockGetLinodeConfigs, } from 'support/intercepts/configs'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockCreateSubnet, @@ -47,6 +48,13 @@ describe('VPC assign/unassign flows', () => { }); }); + beforeEach(() => { + // TODO - Remove mock once `nodebalancerVpc` feature flag is removed. + mockAppendFeatureFlags({ + nodebalancerVpc: false, + }).as('getFeatureFlags'); + }); + /* * - Confirms that can assign a Linode to the VPC when feature is enabled. */ @@ -84,7 +92,7 @@ describe('VPC assign/unassign flows', () => { mockGetLinodes([mockLinode]).as('getLinodes'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getVPC', '@getSubnets']); + cy.wait(['@getVPC', '@getSubnets', '@getFeatureFlags']); // confirm that vpc and subnet details get displayed cy.findByText(mockVPC.label).should('be.visible'); @@ -230,7 +238,7 @@ describe('VPC assign/unassign flows', () => { mockGetLinodes([mockLinode, mockSecondLinode]).as('getLinodes'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getVPC', '@getSubnets', '@getLinodes']); + cy.wait(['@getVPC', '@getSubnets', '@getLinodes', '@getFeatureFlags']); // confirm that subnet should get displayed on VPC's detail page cy.findByText(mockVPC.label).should('be.visible'); diff --git a/packages/manager/cypress/support/api/stackscripts.ts b/packages/manager/cypress/support/api/stackscripts.ts index b14094d35ff..9e21cbde9b6 100644 --- a/packages/manager/cypress/support/api/stackscripts.ts +++ b/packages/manager/cypress/support/api/stackscripts.ts @@ -1,5 +1,6 @@ import { deleteStackScript, getStackScripts } from '@linode/api-v4'; import { pageSize } from 'support/constants/api'; +import { entityPrefix } from 'support/constants/cypress'; import { depaginate } from 'support/util/paginate'; import { isTestLabel } from './common'; @@ -11,7 +12,7 @@ const userStackScriptFilter: Filter = { '+and': [ { label: { - '+contains': 'cy-test-', + '+contains': entityPrefix, }, }, { diff --git a/packages/manager/cypress/support/api/tags.ts b/packages/manager/cypress/support/api/tags.ts index 37365d8d425..cdfcb2316ff 100644 --- a/packages/manager/cypress/support/api/tags.ts +++ b/packages/manager/cypress/support/api/tags.ts @@ -18,7 +18,6 @@ export const deleteAllTestTags = async (): Promise => { for (const testTag of testTags) { // Accounts can have thousands of tags, so we want to send these requests // sequentially to avoid overloading the API. - // eslint-disable-next-line no-await-in-loop await deleteTag(testTag.label); } }; diff --git a/packages/manager/cypress/support/constants/cypress.ts b/packages/manager/cypress/support/constants/cypress.ts index c5ab5c563da..94f1e1b06b9 100644 --- a/packages/manager/cypress/support/constants/cypress.ts +++ b/packages/manager/cypress/support/constants/cypress.ts @@ -5,7 +5,7 @@ /** * Tag to identify test entities, resources, etc. */ -export const entityTag = 'cy-test'; +export const entityTag = Cypress.env()['CY_TEST_RESOURCE_PREFIX'] || 'cy-test'; /** * Prefix for entity names and labels that will be created by Cypress tests. diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts index 0dc4df75750..35c311bfb16 100644 --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts @@ -17,6 +17,7 @@ import type { Dashboard, MetricDefinition, NotificationChannel, + ServiceType, } from '@linode/api-v4'; /** @@ -61,6 +62,44 @@ export const mockGetCloudPulseServices = ( ); }; +/** + * Mocks the API response for the '/monitor/services/:serviceType' endpoint with the provided service types. + * This function intercepts the GET request for the specified API and returns a mocked response + * with service types + * @param {string } serviceType - A single service type (e.g., 'linode') + * @returns {Cypress.Chainable} - Returns a Cypress chainable object to continue the test. + */ +export const mockGetCloudPulseServiceByServiceType = ( + serviceType: string, + service: ServiceType +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/monitor/services/${serviceType}`), + makeResponse(service) + ); +}; + +/** + * Mocks an error response for for the '/monitor/services/:serviceType' endpoint with the provided service types. + * + * This function intercepts the `GET` request made to the CloudPulse API and + * simulates an error response + * + * @param {string} serviceType - The service type for which the metric definitions are being mocked (linode or dbaas). + * + * @returns {Cypress.Chainable} - A Cypress chainable object, indicating that the interception is part of a Cypress test chain. + */ +export const mockGetCloudPulseServiceByServiceTypeError = ( + serviceType: string +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/monitor/services/${serviceType}`), + makeErrorResponse('error occurred', 404) + ); +}; + /** + * Intercepts GET requests to fetch dashboards and mocks response. + * @@ -516,3 +555,26 @@ export const mockUpdateAlertDefinitionsError = ( makeErrorResponse(errorMessage, 500) ); }; + +/** + * Intercepts DELETE request to delete an alert definition for a specific service and mocks response. + * + * @param serviceType - Type of the service (e.g., 'linode'). + * @param alertId - ID of the alert definition to delete. + * + * @returns Cypress chainable. + */ +export const mockDeleteAlert = ( + serviceType: string, + alertId: number, + statusCode: number = 200 +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`monitor/services/${serviceType}/alert-definitions/${alertId}`), + { + body: {}, + statusCode, + } + ); +}; diff --git a/packages/manager/cypress/support/intercepts/events.ts b/packages/manager/cypress/support/intercepts/events.ts index 0e12a2f8fb9..4aec4922f51 100644 --- a/packages/manager/cypress/support/intercepts/events.ts +++ b/packages/manager/cypress/support/intercepts/events.ts @@ -83,3 +83,11 @@ export const mockGetNotifications = ( paginateResponse(notifications) ); }; + +/* Intercepts GET request to get progress events. + * + * @returns Cypress chainable. + */ +export const interceptEvents = (): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`account/events*`)); +}; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 7ee9fb7f817..e6459c25926 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -17,6 +17,7 @@ import type { LinodeInterfaces, LinodeIPsResponse, LinodeType, + Stats, UpgradeInterfaceData, Volume, } from '@linode/api-v4'; @@ -741,3 +742,21 @@ export const mockUpgradeNewLinodeInterfaceError = ( makeErrorResponse(errorMessage, statusCode) ); }; + +/** + * Intercepts GET request to retrieve network stats for a linode + * + * @param linodeId - ID of Linode for intercepted request. + * + * @returns Cypress chainable. + */ +export const mockGetLinodeStats = ( + linodeId: number, + stats: Stats +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/stats`), + makeResponse(stats) + ); +}; diff --git a/packages/manager/cypress/support/plugins/fetch-linode-regions.ts b/packages/manager/cypress/support/plugins/fetch-linode-regions.ts index 476c8d45492..ec8fc76f055 100644 --- a/packages/manager/cypress/support/plugins/fetch-linode-regions.ts +++ b/packages/manager/cypress/support/plugins/fetch-linode-regions.ts @@ -1,7 +1,7 @@ -import { getRegions } from '@linode/api-v4'; +import { getAccountAvailabilities, getRegions } from '@linode/api-v4'; import type { CypressPlugin } from './plugin'; -import type { Region, ResourcePage } from '@linode/api-v4'; +import type { AccountAvailability, Region, ResourcePage } from '@linode/api-v4'; // TODO Clean up. /** @@ -24,12 +24,12 @@ export const getCloudManagerLabel = (region: Region) => { }; /** - * Fetches Linode regions and stores data in Cypress `cloudManagerRegions` env. - * - * Throws an error if no OAuth token (used for regions API request) is defined. + * Fetches and stores Linode region data in Cypress environment object. */ export const fetchLinodeRegions: CypressPlugin = async (on, config) => { const regions: ResourcePage = await getRegions({ page_size: 500 }); + const availability: ResourcePage = + await getAccountAvailabilities(); const extendedRegions = regions.data.map((apiRegion: Region) => { return { @@ -44,6 +44,7 @@ export const fetchLinodeRegions: CypressPlugin = async (on, config) => { env: { ...config.env, cloudManagerRegions: extendedRegions, + cloudManagerAvailability: availability.data, }, }; }; diff --git a/packages/manager/cypress/support/plugins/index.ts b/packages/manager/cypress/support/plugins/index.ts index da6ca84f186..624c3a9beb4 100644 --- a/packages/manager/cypress/support/plugins/index.ts +++ b/packages/manager/cypress/support/plugins/index.ts @@ -9,7 +9,6 @@ export const setupPlugins = async ( for (const plugin of plugins) { // We want to run plugins sequentially to ensure configuration is modified // in the correct order. - // eslint-disable-next-line no-await-in-loop const result = await plugin(on, modifiedConfig); if (result) { modifiedConfig = result; diff --git a/packages/manager/cypress/support/plugins/post-run-cleanup.ts b/packages/manager/cypress/support/plugins/post-run-cleanup.ts index 5a0bf3f7eb1..663d8024bf6 100644 --- a/packages/manager/cypress/support/plugins/post-run-cleanup.ts +++ b/packages/manager/cypress/support/plugins/post-run-cleanup.ts @@ -38,7 +38,7 @@ import type { */ // Test resource label/name prefix. -const TEST_TAG_PREFIX = 'cy-test-'; +const TEST_TAG_PREFIX = process.env['CY_TEST_RESOURCE_PREFIX'] || 'cy-test-'; // Desired number of items per page of a paginated API request. const PAGE_SIZE = 500; @@ -160,7 +160,6 @@ export const postRunCleanup: CypressPlugin = async (on) => { console.log(`- Cleaning up test ${resourceCleanUpItem.name}...`); try { // Perform clean-up sequentially. - // eslint-disable-next-line no-await-in-loop await resourceCleanUpItem.cleanUp(); } catch (e) { console.error( diff --git a/packages/manager/cypress/support/util/backoff.ts b/packages/manager/cypress/support/util/backoff.ts index 52e337a759d..07f00593d65 100644 --- a/packages/manager/cypress/support/util/backoff.ts +++ b/packages/manager/cypress/support/util/backoff.ts @@ -58,8 +58,6 @@ export const attemptWithBackoff = async ( await timeout(initialDelay); } - // Disable ESLint rule because we do not want to parallelize the async operations. - /* eslint-disable no-await-in-loop */ for (let attempt = 1; attempt <= maxAttempts; attempt++) { const nextAttempt = attempt + 1; try { diff --git a/packages/manager/cypress/support/util/cleanup.ts b/packages/manager/cypress/support/util/cleanup.ts index dcc06f4147f..3dd61106092 100644 --- a/packages/manager/cypress/support/util/cleanup.ts +++ b/packages/manager/cypress/support/util/cleanup.ts @@ -72,7 +72,6 @@ export const cleanUp = (resources: CleanUpResource | CleanUpResource[]) => { for (const resource of resourcesArray) { const cleanFunction = cleanUpMap[resource]; // Perform clean-up sequentially to avoid API rate limiting. - // eslint-disable-next-line no-await-in-loop await cleanFunction(); } }; diff --git a/packages/manager/cypress/support/util/cloudpulse.ts b/packages/manager/cypress/support/util/cloudpulse.ts index a760bc84f39..a5a4f8b4555 100644 --- a/packages/manager/cypress/support/util/cloudpulse.ts +++ b/packages/manager/cypress/support/util/cloudpulse.ts @@ -1,5 +1,6 @@ // Function to generate random values based on the number of points +import type { Stats } from '@linode/api-v4'; import type { CloudPulseMetricsResponseData } from '@linode/api-v4'; import type { Labels } from 'src/features/CloudPulse/shared/CloudPulseTimeRangeSelect'; @@ -49,3 +50,65 @@ export const generateRandomMetricsData = ( result_type: 'matrix', }; }; + +/* +Common assertions for multiple tests w/ different setups which +assume legacy metrics will be displayed +with no option to view beta metrics +*/ +export const assertBetaMetricsNotAvailable = () => { + cy.wait([ + '@getFeatureFlags', + '@getUserPreferences', + '@getRegions', + '@getLinode', + '@getLinodeStats', + ]); + // tab header is "Metrics", not "Metrics Beta" + cy.get('[data-testid="Metrics"]') + .should('be.visible') + .should('be.enabled') + .within(() => { + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + // banner and button to switch to beta do not exist + cy.get('[data-testid="metrics-preference-banner-text"]').should( + 'not.exist' + ); + cy.findByText('Try the Metrics (Beta)').should('not.exist'); + // legacy metrics view of LinodeSummary component is displayed + cy.get('[data-testid="linode-summary"]').should('be.visible'); + }); +}; + +export const generateMockLegacyStats = (): Stats => { + const generateStats = (modifier = 1): [number, number][] => { + const stat: [number, number][] = []; + let i = 0; + for (i; i < 200; i++) { + const item: [number, number] = [0, 0]; + item[0] = Date.now() - i * 300000; + item[1] = Math.random() * modifier; + stat.push(item); + } + return stat; + }; + const netStats = { + in: generateStats(3), + out: generateStats(2), + private_in: generateStats(0), + private_out: generateStats(0), + }; + return { + data: { + cpu: generateStats(4), + io: { io: generateStats(3), swap: generateStats(3) }, + netv4: netStats, + netv6: netStats, + }, + title: 'mock title', + }; +}; diff --git a/packages/manager/cypress/support/util/regions.ts b/packages/manager/cypress/support/util/regions.ts index 506577723dd..6c399851cbe 100644 --- a/packages/manager/cypress/support/util/regions.ts +++ b/packages/manager/cypress/support/util/regions.ts @@ -3,7 +3,7 @@ import { randomItem } from 'support/util/random'; import { buildArray, shuffleArray } from './arrays'; -import type { Capabilities, Region } from '@linode/api-v4'; +import type { AccountAvailability, Capabilities, Region } from '@linode/api-v4'; /** * Extended Region type to assist with Cloud Manager-specific label handling. @@ -82,6 +82,7 @@ export const getRegionFromExtendedRegion = ( resolvers: extendedRegion.resolvers, site_type: extendedRegion.site_type, status: extendedRegion.status, + monitors: extendedRegion.monitors, }; }; @@ -159,6 +160,15 @@ export const regions: ExtendedRegion[] = Cypress.env( 'cloudManagerRegions' ) as ExtendedRegion[]; +/** + * Region availability data for Cloud Manager regions. + * + * Retrieved via Linode APIv4 during Cypress start-up. + */ +export const availability: AccountAvailability[] = Cypress.env( + 'cloudManagerAvailability' +) as AccountAvailability[]; + /** * Linode region(s) exposed to Cypress for testing. * @@ -249,7 +259,7 @@ interface ChooseRegionOptions { } /** - * Returns `true` if the given Region has all of the given capabilities. + * Returns `true` if the given Region has all of the given capabilities and availability for each capability. * * @param region - Region to check capabilities. * @param capabilities - Capabilities to check. @@ -260,9 +270,20 @@ const regionHasCapabilities = ( region: Region, capabilities: Capabilities[] ): boolean => { - return capabilities.every((capability) => + const hasCapability = capabilities.every((capability) => region.capabilities.includes(capability) ); + + const isUnavailable = availability.some((regionAvailability) => { + return ( + regionAvailability.region === region.id && + capabilities.some((capability) => + regionAvailability.unavailable.includes(capability) + ) + ); + }); + + return hasCapability && !isUnavailable; }; /** @@ -312,7 +333,7 @@ const resolveSearchRegions = ( throw new Error( `Override region ${overrideRegion.id} (${ overrideRegion.label - }) does not support one or more capabilities: ${requiredCapabilities.join( + }) does not support or lacks availability for one or more capabilities: ${requiredCapabilities.join( ', ' )}` ); diff --git a/packages/manager/eslint.config.js b/packages/manager/eslint.config.js index ee405783659..7b5fbd8dd72 100644 --- a/packages/manager/eslint.config.js +++ b/packages/manager/eslint.config.js @@ -84,7 +84,6 @@ export const baseConfig = [ 'comma-dangle': 'off', curly: 'warn', eqeqeq: 'warn', - 'no-await-in-loop': 'error', 'no-bitwise': 'error', 'no-caller': 'error', 'no-case-declarations': 'warn', @@ -355,7 +354,10 @@ export const baseConfig = [ ], ]; } - if (rule === 'prefer-explicit-assert') { + if ( + rule === 'prefer-explicit-assert' || + rule === 'prefer-screen-queries' + ) { return [`testing-library/${rule}`, 'off']; } // All other rules just get set to warn @@ -413,6 +415,7 @@ export const baseConfig = [ 'src/features/Events/**/*', 'src/features/Firewalls/**/*', 'src/features/Help/**/*', + 'src/features/IAM/**/*', 'src/features/Images/**/*', 'src/features/Kubernetes/**/*', 'src/features/Longview/**/*', @@ -420,6 +423,7 @@ export const baseConfig = [ 'src/features/NodeBalancers/**/*', 'src/features/ObjectStorage/**/*', 'src/features/PlacementGroups/**/*', + 'src/features/Profile/**/*', 'src/features/Search/**/*', 'src/features/TopMenu/SearchBar/**/*', 'src/components/Tag/**/*', diff --git a/packages/manager/package.json b/packages/manager/package.json index 992011495f2..85cec5cae60 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.144.0", + "version": "1.145.0", "private": true, "type": "module", "bugs": { @@ -70,9 +70,9 @@ "notistack": "^3.0.1", "qrcode.react": "^4.2.0", "ramda": "~0.25.0", - "react": "^18.2.0", + "react": "^19.1.0", "react-csv": "^2.0.3", - "react-dom": "^18.2.0", + "react-dom": "^19.1.0", "react-dropzone": "~11.2.0", "react-hook-form": "^7.51.0", "react-number-format": "^3.5.0", @@ -126,20 +126,9 @@ }, "devDependencies": { "@4tw/cypress-drag-drop": "^2.3.0", - "@storybook/addon-a11y": "^8.6.7", - "@storybook/addon-actions": "^8.6.7", - "@storybook/addon-controls": "^8.6.7", - "@storybook/addon-docs": "^8.6.7", - "@storybook/addon-mdx-gfm": "^8.6.7", - "@storybook/addon-measure": "^8.6.7", - "@storybook/addon-storysource": "^8.6.7", - "@storybook/addon-viewport": "^8.6.7", - "@storybook/blocks": "^8.6.7", - "@storybook/manager-api": "^8.6.7", - "@storybook/preview-api": "^8.6.7", - "@storybook/react": "^8.6.7", - "@storybook/react-vite": "^8.6.7", - "@storybook/theming": "^8.6.7", + "@storybook/addon-a11y": "^9.0.12", + "@storybook/addon-docs": "^9.0.12", + "@storybook/react-vite": "^9.0.12", "@swc/core": "^1.10.9", "@testing-library/cypress": "^10.0.3", "@testing-library/dom": "^10.1.0", @@ -160,9 +149,9 @@ "@types/mocha": "^10.0.2", "@types/node": "^20.17.0", "@types/ramda": "0.25.16", - "@types/react": "^18.2.55", + "@types/react": "^19.1.6", "@types/react-csv": "^1.1.3", - "@types/react-dom": "^18.2.18", + "@types/react-dom": "^19.1.6", "@types/react-redux": "~7.1.7", "@types/react-router-dom": "~5.3.3", "@types/react-router-hash-link": "^1.2.1", @@ -193,8 +182,8 @@ "msw": "^2.2.3", "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", - "storybook": "^8.6.7", - "storybook-dark-mode": "4.0.1", + "storybook": "^9.0.12", + "@vueless/storybook-dark-mode": "^9.0.5", "vite": "^6.3.4", "vite-plugin-svgr": "^3.2.0" }, diff --git a/packages/manager/public/assets/white/woocommerce.svg b/packages/manager/public/assets/white/woocommerce.svg index e493809d4cd..61b3fd5420e 100644 --- a/packages/manager/public/assets/white/woocommerce.svg +++ b/packages/manager/public/assets/white/woocommerce.svg @@ -1,28 +1,3 @@ - - - -WooCommerce - - - - - - - \ No newline at end of file + + + diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index e0e4f7e30ba..54c01403d56 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -36,7 +36,6 @@ import { ENABLE_MAINTENANCE_MODE } from './constants'; import { complianceUpdateContext } from './context/complianceUpdateContext'; import { sessionExpirationContext } from './context/sessionExpirationContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; -import { useIsIAMEnabled } from './features/IAM/hooks/useIsIAMEnabled'; import { TOPMENU_HEIGHT } from './features/TopMenu/constants'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { migrationRouter } from './routes'; @@ -111,17 +110,6 @@ const LinodesRoutes = React.lazy(() => default: module.LinodesRoutes, })) ); -const Profile = React.lazy(() => - import('src/features/Profile/Profile').then((module) => ({ - default: module.Profile, - })) -); - -const IAM = React.lazy(() => - import('src/features/IAM').then((module) => ({ - default: module.IdentityAccessManagement, - })) -); export const MainContent = () => { const contentRef = React.useRef(null); @@ -158,8 +146,6 @@ export const MainContent = () => { const { data: accountSettings } = useAccountSettings(); const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; - const { isIAMEnabled } = useIsIAMEnabled(); - const isNarrowViewport = useMediaQuery((theme: Theme) => theme.breakpoints.down(960) ); @@ -291,10 +277,6 @@ export const MainContent = () => { component={LinodesRoutes} path="/linodes" /> - {isIAMEnabled && ( - - )} - {/** We don't want to break any bookmarks. This can probably be removed eventually. */} diff --git a/packages/manager/src/__data__/distributedRegionsData.ts b/packages/manager/src/__data__/distributedRegionsData.ts index 349695ef995..32484b6acba 100644 --- a/packages/manager/src/__data__/distributedRegionsData.ts +++ b/packages/manager/src/__data__/distributedRegionsData.ts @@ -21,6 +21,7 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -42,6 +43,7 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -63,6 +65,7 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -84,6 +87,7 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -105,6 +109,7 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -126,6 +131,7 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -147,6 +153,7 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -168,6 +175,7 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -189,5 +197,6 @@ export const distributedRegions: Region[] = [ }, site_type: 'distributed', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, ]; diff --git a/packages/manager/src/__data__/firewallDevices.ts b/packages/manager/src/__data__/firewallDevices.ts index ff11aecc672..1fa2e81c0d9 100644 --- a/packages/manager/src/__data__/firewallDevices.ts +++ b/packages/manager/src/__data__/firewallDevices.ts @@ -7,6 +7,7 @@ export const device: FirewallDevice = { label: 'Some Linode', type: 'linode' as any, url: 'v4/linode/instances/16621754', + parent_entity: null, }, id: 1, updated: '2020-01-01', @@ -19,6 +20,7 @@ export const device2: FirewallDevice = { label: 'Other Linode', type: 'linode' as any, url: 'v4/linode/instances/15922741', + parent_entity: null, }, id: 2, updated: '2020-01-01', diff --git a/packages/manager/src/__data__/firewalls.ts b/packages/manager/src/__data__/firewalls.ts index 24de901f7d5..29eb3dcdd0c 100644 --- a/packages/manager/src/__data__/firewalls.ts +++ b/packages/manager/src/__data__/firewalls.ts @@ -9,6 +9,7 @@ export const firewall: Firewall = { label: 'my-linode', type: 'linode' as FirewallDeviceEntityType, url: '/test', + parent_entity: null, }, ], id: 1, @@ -50,6 +51,7 @@ export const firewall2: Firewall = { label: 'my-linode', type: 'linode' as FirewallDeviceEntityType, url: '/test', + parent_entity: null, }, ], id: 2, diff --git a/packages/manager/src/__data__/productionRegionsData.ts b/packages/manager/src/__data__/productionRegionsData.ts index dc970754b82..b1ef94ca7ff 100644 --- a/packages/manager/src/__data__/productionRegionsData.ts +++ b/packages/manager/src/__data__/productionRegionsData.ts @@ -32,6 +32,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -60,6 +61,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -88,6 +90,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -118,6 +121,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: ['Linodes'], metrics: ['Linodes'] }, }, { capabilities: [ @@ -149,6 +153,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -181,6 +186,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -211,6 +217,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -240,6 +247,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -269,6 +277,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -298,6 +307,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -327,6 +337,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -356,6 +367,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -386,6 +398,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -415,6 +428,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -444,6 +458,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -473,6 +488,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -502,6 +518,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -530,6 +547,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -558,6 +576,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -588,6 +607,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -618,6 +638,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: ['Linodes'], metrics: [] }, }, { capabilities: [ @@ -646,6 +667,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -676,6 +698,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -706,6 +729,7 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, { capabilities: [ @@ -734,5 +758,6 @@ export const productionRegions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }, ]; diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.stories.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.stories.tsx index a5af36f7e26..c64ef06c1e1 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.stories.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.stories.tsx @@ -1,10 +1,10 @@ -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { ActionMenu } from './ActionMenu'; import type { Action, ActionMenuProps } from './ActionMenu'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const standardActions = [ { diff --git a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx index 1630fa5d7f1..a9b7807295d 100644 --- a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx +++ b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx @@ -2,6 +2,7 @@ import { Box, Stack, Typography } from '@linode/ui'; import { replaceNewlinesWithLineBreaks } from '@linode/utilities'; import { useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; +import type { JSX } from 'react'; import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; diff --git a/packages/manager/src/components/AreaChart/AreaChart.stories.tsx b/packages/manager/src/components/AreaChart/AreaChart.stories.tsx index 96b881a94fc..9c90d4a0f79 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.stories.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.stories.tsx @@ -6,12 +6,12 @@ import { tooltipValueFormatter } from 'src/components/AreaChart/utils'; import { AreaChart } from './AreaChart'; import { customLegendData, timeData } from './utils'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: AreaChart, decorators: [ - (Story: StoryFn) => ( + (Story) => (
diff --git a/packages/manager/src/components/Avatar/Avatar.stories.tsx b/packages/manager/src/components/Avatar/Avatar.stories.tsx index 9817951902b..1751782e943 100644 --- a/packages/manager/src/components/Avatar/Avatar.stories.tsx +++ b/packages/manager/src/components/Avatar/Avatar.stories.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Avatar } from 'src/components/Avatar/Avatar'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import type { AvatarProps } from 'src/components/Avatar/Avatar'; export const Default: StoryObj = { diff --git a/packages/manager/src/components/BarPercent/BarPercent.stories.tsx b/packages/manager/src/components/BarPercent/BarPercent.stories.tsx index b46458419a5..e66dfb58207 100644 --- a/packages/manager/src/components/BarPercent/BarPercent.stories.tsx +++ b/packages/manager/src/components/BarPercent/BarPercent.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { BarPercent } from './BarPercent'; import type { BarPercentProps } from './BarPercent'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; /** Default BarPercent */ export const Default: StoryObj = { diff --git a/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx b/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx index e21a97c4b5e..1268e680529 100644 --- a/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx +++ b/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx @@ -1,10 +1,10 @@ import { Chip } from '@linode/ui'; -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { Breadcrumb } from './Breadcrumb'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const withBadgeCrumbs = [ { diff --git a/packages/manager/src/components/Breadcrumb/FinalCrumbPrefix.tsx b/packages/manager/src/components/Breadcrumb/FinalCrumbPrefix.tsx index d637d867471..b08c79e1b77 100644 --- a/packages/manager/src/components/Breadcrumb/FinalCrumbPrefix.tsx +++ b/packages/manager/src/components/Breadcrumb/FinalCrumbPrefix.tsx @@ -1,5 +1,6 @@ import { styled } from '@mui/material'; import * as React from 'react'; +import type { JSX } from 'react'; interface Props { prefixComponent: JSX.Element | null; diff --git a/packages/manager/src/components/Breadcrumb/types.ts b/packages/manager/src/components/Breadcrumb/types.ts index 8a5204ab713..53bd39657fd 100644 --- a/packages/manager/src/components/Breadcrumb/types.ts +++ b/packages/manager/src/components/Breadcrumb/types.ts @@ -1,4 +1,4 @@ -import type { CSSProperties } from 'react'; +import type { CSSProperties, JSX } from 'react'; export interface LabelProps { linkTo?: string; diff --git a/packages/manager/src/components/CheckoutBar/CheckoutBar.stories.tsx b/packages/manager/src/components/CheckoutBar/CheckoutBar.stories.tsx index 8271d93929b..8a417ff47c1 100644 --- a/packages/manager/src/components/CheckoutBar/CheckoutBar.stories.tsx +++ b/packages/manager/src/components/CheckoutBar/CheckoutBar.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { CheckoutBar } from './CheckoutBar'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; @@ -25,7 +25,7 @@ const meta: Meta = { }, component: CheckoutBar, decorators: [ - (Story: StoryFn) => ( + (Story) => (
diff --git a/packages/manager/src/components/CheckoutBar/CheckoutBar.tsx b/packages/manager/src/components/CheckoutBar/CheckoutBar.tsx index 89e2db6901c..f8a50ae22fd 100644 --- a/packages/manager/src/components/CheckoutBar/CheckoutBar.tsx +++ b/packages/manager/src/components/CheckoutBar/CheckoutBar.tsx @@ -1,6 +1,7 @@ import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { DisplayPrice } from 'src/components/DisplayPrice'; diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.stories.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.stories.tsx index 1843e0ce4eb..30ff4536982 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.stories.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { CheckoutSummary } from './CheckoutSummary'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; @@ -24,7 +24,7 @@ const defaultArgs = { const meta: Meta = { component: CheckoutSummary, decorators: [ - (Story: StoryFn) => ( + (Story) => (
diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx index 8cfb23885ed..ebb2d702a2b 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx @@ -4,6 +4,7 @@ import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; +import type { JSX } from 'react'; import { SummaryItem } from './SummaryItem'; diff --git a/packages/manager/src/components/CodeBlock/CodeBlock.stories.tsx b/packages/manager/src/components/CodeBlock/CodeBlock.stories.tsx index 7dd44e57590..0edf697a230 100644 --- a/packages/manager/src/components/CodeBlock/CodeBlock.stories.tsx +++ b/packages/manager/src/components/CodeBlock/CodeBlock.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CodeBlock } from './CodeBlock'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const code = `/** * Given a user's preference (light | dark | system), get the name of the actual diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx index 7bce1a98114..0d28d4112ff 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx @@ -2,6 +2,7 @@ import Box from '@mui/material/Box'; import Collapse from '@mui/material/Collapse'; import IconButton from '@mui/material/IconButton'; import * as React from 'react'; +import type { JSX } from 'react'; import KeyboardCaretDownIcon from 'src/assets/icons/caret_down.svg'; import KeyboardCaretRightIcon from 'src/assets/icons/caret_right.svg'; diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.stories.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.stories.tsx index bef901614e9..5be5a242623 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.stories.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.stories.tsx @@ -8,7 +8,7 @@ import { TableRow } from 'src/components/TableRow'; import { CollapsibleTable } from './CollapsibleTable'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { args: { diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx index 7f23deeca03..b056444c85e 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.stories.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.stories.tsx index ea406d68902..e50ba66bf7b 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.stories.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ColorPalette } from './ColorPalette'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: () => , diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index a7ee81b07c0..a6d8d9bea47 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -14,18 +14,11 @@ interface Color { const useStyles = makeStyles()((theme: Theme) => ({ alias: { - color: theme.tokens.color.Neutrals[90], font: FontTypography.Code, }, color: { - color: theme.tokens.color.Neutrals[60], font: FontTypography.Code, }, - root: { - '& h2': { - color: theme.tokens.color.Neutrals[90], - }, - }, swatch: { border: `1px solid ${theme.tokens.color.Neutrals[60]}`, borderRadius: 3, @@ -198,7 +191,7 @@ export const ColorPalette = () => { }; return ( - + {renderColor('Primary Colors', primaryColors)} {renderColor('Etc.', etc)} {renderColor('Background Colors', bgColors)} diff --git a/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx b/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx index aba8758b071..84395710906 100644 --- a/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx +++ b/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { ColorPicker } from 'src/components/ColorPicker/ColorPicker'; import type { ColorPickerProps } from './ColorPicker'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { args: { diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx index caa5772f732..543ece93982 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx @@ -1,10 +1,10 @@ import { ActionsPanel } from '@linode/ui'; -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx index dd7856e6529..4364153df61 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,6 +1,7 @@ import { Dialog, Stack } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import type { APIError } from '@linode/api-v4'; import type { DialogProps } from '@linode/ui'; diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.stories.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.stories.tsx index f4f84e59db9..98b72a91c15 100644 --- a/packages/manager/src/components/CopyTooltip/CopyTooltip.stories.tsx +++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.stories.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import type { CopyTooltipProps } from './CopyTooltip'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.stories.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.stories.tsx index 2e12633f855..5e5d8296726 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.stories.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CopyableTextField } from './CopyableTextField'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { args: { @@ -11,7 +11,7 @@ const meta: Meta = { }, component: CopyableTextField, decorators: [ - (Story: StoryFn) => ( + (Story) => (
diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx index b527095664e..3f6c1220699 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx @@ -1,4 +1,4 @@ -import { Box, TextField } from '@linode/ui'; +import { Box, InputAdornment, TextField } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -40,12 +40,14 @@ export const CopyableTextField = (props: CopyableTextFieldProps) => { disabled InputProps={{ endAdornment: hideIcons ? undefined : ( - - {showDownloadIcon && ( - - )} - - + + + {showDownloadIcon && ( + + )} + + + ), }} /> diff --git a/packages/manager/src/components/Currency/Currency.stories.tsx b/packages/manager/src/components/Currency/Currency.stories.tsx index dccf4e80c49..1ee4457021c 100644 --- a/packages/manager/src/components/Currency/Currency.stories.tsx +++ b/packages/manager/src/components/Currency/Currency.stories.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Currency } from './Currency'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx index 640fae75510..ed2b8a3edcc 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx @@ -1,9 +1,9 @@ -import { action } from '@storybook/addon-actions'; import * as React from 'react'; +import { action } from 'storybook/actions'; import { DatePicker } from './DatePicker'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import type { DateTime } from 'luxon'; type Story = StoryObj; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx index 0cbfdc6b400..7fe5cd724e3 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx @@ -1,9 +1,9 @@ -import { action } from '@storybook/addon-actions'; import * as React from 'react'; +import { action } from 'storybook/actions'; import { DateTimePicker } from './DateTimePicker'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import type { DateTime } from 'luxon'; type Story = StoryObj; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx index a39a1de55a2..968e577a1ea 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx @@ -1,10 +1,10 @@ -import { action } from '@storybook/addon-actions'; import { DateTime } from 'luxon'; import * as React from 'react'; +import { action } from 'storybook/actions'; import { DateTimeRangePicker } from './DateTimeRangePicker'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.stories.tsx b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.stories.tsx index b0ff4fe89a5..ca99d404a57 100644 --- a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.stories.tsx +++ b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.stories.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { DateTimeDisplay } from './DateTimeDisplay'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.tsx index 42e8d540205..34e8c795509 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.tsx @@ -1,9 +1,9 @@ -import { action } from '@storybook/addon-actions'; import * as React from 'react'; +import { action } from 'storybook/actions'; import { DebouncedSearchTextField } from './DebouncedSearchTextField'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.stories.tsx b/packages/manager/src/components/DescriptionList/DescriptionList.stories.tsx index 39096fd00cc..a97fa497e00 100644 --- a/packages/manager/src/components/DescriptionList/DescriptionList.stories.tsx +++ b/packages/manager/src/components/DescriptionList/DescriptionList.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { DescriptionList } from 'src/components/DescriptionList/DescriptionList'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import type { DescriptionListProps } from 'src/components/DescriptionList/DescriptionList'; const defaultItems = [ diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.stories.tsx b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.stories.tsx index 33aa84e9052..efe63684420 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.stories.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.stories.tsx @@ -5,7 +5,7 @@ import { Link } from 'src/components/Link'; import { DismissibleBanner } from './DismissibleBanner'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx index c2be8ac8f0f..d04c4ac964f 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx @@ -1,5 +1,6 @@ import { CloseIcon, IconButton, Notice, Stack } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; diff --git a/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx b/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx index 1c3aebab2b3..c73ef868de5 100644 --- a/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx +++ b/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx @@ -2,14 +2,14 @@ import * as React from 'react'; import { DisplayPrice } from './DisplayPrice'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; const meta: Meta = { component: DisplayPrice, decorators: [ - (Story: StoryFn) => ( + (Story) => (
diff --git a/packages/manager/src/components/DocsLink/DocsLink.stories.tsx b/packages/manager/src/components/DocsLink/DocsLink.stories.tsx index 4fc8d94f977..2322be396a8 100644 --- a/packages/manager/src/components/DocsLink/DocsLink.stories.tsx +++ b/packages/manager/src/components/DocsLink/DocsLink.stories.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { DocsLink } from './DocsLink'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import type { DocsLinkProps } from 'src/components/DocsLink/DocsLink'; export const Default: StoryObj = { diff --git a/packages/manager/src/components/DocsLink/DocsLink.tsx b/packages/manager/src/components/DocsLink/DocsLink.tsx index e6a6f417a15..a01381d1cda 100644 --- a/packages/manager/src/components/DocsLink/DocsLink.tsx +++ b/packages/manager/src/components/DocsLink/DocsLink.tsx @@ -1,5 +1,6 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import DocsIcon from 'src/assets/icons/docs.svg'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/components/DownloadTooltip.stories.tsx b/packages/manager/src/components/DownloadTooltip.stories.tsx index b5f1ffd09ab..ac564e58779 100644 --- a/packages/manager/src/components/DownloadTooltip.stories.tsx +++ b/packages/manager/src/components/DownloadTooltip.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { DownloadTooltip } from './DownloadTooltip'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: DownloadTooltip, diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSection.tsx index e4317a5f9e1..999de0fb1c1 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSection.tsx @@ -1,5 +1,6 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; interface ResourcesLinksSectionProps { children: JSX.Element | JSX.Element[]; /** diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSubSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSubSection.tsx index 010f0e16837..b0ea56ee5d4 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSubSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSubSection.tsx @@ -1,6 +1,7 @@ import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; interface ResourcesLinksSubSectionProps { children?: JSX.Element | JSX.Element[]; diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.stories.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.stories.tsx index 46638d0aa54..12d004503e7 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.stories.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.stories.tsx @@ -10,7 +10,7 @@ import { import { ResourcesSection } from './ResourcesSection'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { args: { @@ -31,7 +31,7 @@ const meta: Meta = { }, component: ResourcesSection, decorators: [ - (Story: StoryFn) => ( + (Story) => (
diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx index 5d5dd94df7a..c039e851ed1 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx @@ -1,5 +1,6 @@ import { Typography } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import DocsIcon from 'src/assets/icons/docs.svg'; import PointerIcon from 'src/assets/icons/pointer.svg'; diff --git a/packages/manager/src/components/Encryption/Encryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx index ef7a41c8d74..17b66bb61d3 100644 --- a/packages/manager/src/components/Encryption/Encryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -1,6 +1,7 @@ import { Box, Checkbox, Notice, Typography } from '@linode/ui'; import { List, ListItem } from '@mui/material'; import * as React from 'react'; +import type { JSX } from 'react'; import { checkboxTestId, descriptionTestId, headerTestId } from './constants'; diff --git a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.stories.tsx b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.stories.tsx index ddb25f893b7..4b0b61b3ca7 100644 --- a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.stories.tsx +++ b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.stories.tsx @@ -1,9 +1,9 @@ -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { EnhancedNumberInput } from 'src/components/EnhancedNumberInput/EnhancedNumberInput'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx index 37b71485bba..d9cb9f55a56 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx @@ -1,12 +1,12 @@ import { linodeFactory } from '@linode/utilities'; -import { action } from '@storybook/addon-actions'; import * as React from 'react'; +import { action } from 'storybook/actions'; import { LinodeEntityDetail } from 'src/features/Linodes/LinodeEntityDetail'; import { EntityDetail } from './EntityDetail'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.tsx index 4a9b86e113f..0526c276393 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.tsx @@ -2,6 +2,7 @@ import { omittedProps } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; export interface EntityDetailProps { body?: JSX.Element; diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx index e630d1172a8..1015ab1646e 100644 --- a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx +++ b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx @@ -1,14 +1,14 @@ import { Box, Button } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; import { LinodeActionMenu } from 'src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu'; import { Link } from '../Link'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const sxBoxFlex = { alignItems: 'center', diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.tsx index c0547894662..833e3b58ad2 100644 --- a/packages/manager/src/components/EntityHeader/EntityHeader.tsx +++ b/packages/manager/src/components/EntityHeader/EntityHeader.tsx @@ -1,6 +1,7 @@ import { Box, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import type { TypographyProps } from '@linode/ui'; diff --git a/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx b/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx index 0c7d23271d2..62f5e03fccc 100644 --- a/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx +++ b/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import type { EntityVariants } from 'src/components/EntityIcon/EntityIcon'; const meta: Meta = { diff --git a/packages/manager/src/components/Flag.stories.tsx b/packages/manager/src/components/Flag.stories.tsx index 6687e02e3ba..42715ace52f 100644 --- a/packages/manager/src/components/Flag.stories.tsx +++ b/packages/manager/src/components/Flag.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Flag } from './Flag'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: Flag, diff --git a/packages/manager/src/components/FormGroup.stories.tsx b/packages/manager/src/components/FormGroup.stories.tsx index b03cf18701c..3b274f65039 100644 --- a/packages/manager/src/components/FormGroup.stories.tsx +++ b/packages/manager/src/components/FormGroup.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { FormGroup } from './FormGroup'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: FormGroup, diff --git a/packages/manager/src/components/FormLabel.stories.tsx b/packages/manager/src/components/FormLabel.stories.tsx index d928c7275e4..58e00c6938d 100644 --- a/packages/manager/src/components/FormLabel.stories.tsx +++ b/packages/manager/src/components/FormLabel.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { FormLabel } from './FormLabel'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: FormLabel, diff --git a/packages/manager/src/components/GaugePercent/GaugePercent.stories.tsx b/packages/manager/src/components/GaugePercent/GaugePercent.stories.tsx index 49602c78fdb..22e61791194 100644 --- a/packages/manager/src/components/GaugePercent/GaugePercent.stories.tsx +++ b/packages/manager/src/components/GaugePercent/GaugePercent.stories.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { GaugePercent } from './GaugePercent'; import type { GaugePercentProps } from './GaugePercent'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/components/GaugePercent/GaugePercent.tsx b/packages/manager/src/components/GaugePercent/GaugePercent.tsx index 5d528d14f5a..d30f60601a2 100644 --- a/packages/manager/src/components/GaugePercent/GaugePercent.tsx +++ b/packages/manager/src/components/GaugePercent/GaugePercent.tsx @@ -1,6 +1,7 @@ import { useTheme } from '@mui/material/styles'; import { Chart } from 'chart.js'; import * as React from 'react'; +import type { JSX } from 'react'; import { StyledGaugeWrapperDiv, diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.stories.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.stories.tsx index d7d9a56a200..6ba2433196d 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.stories.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.stories.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ImageSelect } from './ImageSelect'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.stories.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.stories.tsx index 914e76b2daf..41c00a77ef3 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.stories.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.stories.tsx @@ -1,9 +1,9 @@ -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { InlineMenuAction } from './InlineMenuAction'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: {}, diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx index a0cadfdd91a..2656371b9cb 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx @@ -1,10 +1,10 @@ import { Button } from '@linode/ui'; -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { LandingHeader } from './LandingHeader'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.tsx index c57cfa1c057..a617543b766 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.tsx @@ -3,6 +3,7 @@ import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; +import type { JSX } from 'react'; import BetaFeedbackIcon from 'src/assets/icons/icon-feedback.svg'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; diff --git a/packages/manager/src/components/LineGraph/LineGraph.stories.tsx b/packages/manager/src/components/LineGraph/LineGraph.stories.tsx index 8be41bc9d22..08dcedf7fed 100644 --- a/packages/manager/src/components/LineGraph/LineGraph.stories.tsx +++ b/packages/manager/src/components/LineGraph/LineGraph.stories.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { LineGraph } from 'src/components/LineGraph/LineGraph'; import type { DataSet, LineGraphProps } from './LineGraph'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const data: DataSet['data'] = [ [1644330600000, 0.45], diff --git a/packages/manager/src/components/LinearProgress.stories.tsx b/packages/manager/src/components/LinearProgress.stories.tsx index b6d978878d9..17c4839441a 100644 --- a/packages/manager/src/components/LinearProgress.stories.tsx +++ b/packages/manager/src/components/LinearProgress.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { LinearProgress } from './LinearProgress'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: LinearProgress, diff --git a/packages/manager/src/components/Link.stories.tsx b/packages/manager/src/components/Link.stories.tsx index ce598f7eec3..6dd888ee436 100644 --- a/packages/manager/src/components/Link.stories.tsx +++ b/packages/manager/src/components/Link.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import DocsIcon from 'src/assets/icons/docs.svg'; import { Link } from 'src/components/Link'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import type { LinkProps } from 'src/components/Link'; /** diff --git a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.test.tsx b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.test.tsx index b3913393ba2..2bcd38a04a7 100644 --- a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.test.tsx +++ b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable testing-library/prefer-screen-queries */ import React from 'react'; import { accountMaintenanceFactory } from 'src/factories'; diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx index 18eef546841..24b65905534 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx @@ -170,6 +170,10 @@ export const maintenanceActionTextMap: Record< 'During this time, your Linode will be shut down, cold migrated to a new host, then returned to its last state (running or powered off).', live_migration: 'During this time, your Linode will be live migrated to a new host, then returned to its last state (running or powered off).', + migrate: + 'During this time, your Linode will be migrated to a new host, then returned to its last state (running or powered off).', + power_off_on: + 'During this time, your Linode will be shut down and remain offline, then returned to its last state (running or powered off).', reboot: 'During this time, your Linode will be shut down and remain offline, then returned to its last state (running or powered off).', volume_migration: diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx index 0cc2e989355..aba6a8511b4 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable testing-library/prefer-screen-queries */ import React from 'react'; import { accountMaintenanceFactory } from 'src/factories'; diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx index a6fdf7af250..fb10e4e65cf 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx @@ -2,23 +2,19 @@ import { useAllAccountMaintenanceQuery } from '@linode/queries'; import { Notice, Typography } from '@linode/ui'; import { pluralize } from '@linode/utilities'; import React from 'react'; -import { useLocation } from 'react-router-dom'; import { PENDING_MAINTENANCE_FILTER } from 'src/features/Account/Maintenance/utilities'; import { isPlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; import { Link } from '../Link'; -export const MaintenanceBannerV2 = () => { +export const MaintenanceBannerV2 = ({ pathname }: { pathname?: string }) => { const { data: allMaintenance } = useAllAccountMaintenanceQuery( {}, PENDING_MAINTENANCE_FILTER ); - const location = useLocation(); - - const hideAccountMaintenanceLink = - location.pathname === '/account/maintenance'; + const hideAccountMaintenanceLink = pathname === '/account/maintenance'; // Filter out platform maintenance, since that is handled separately const linodeMaintenance = diff --git a/packages/manager/src/components/MaintenancePolicySelect/DefaultPolicyChip.tsx b/packages/manager/src/components/MaintenancePolicySelect/DefaultPolicyChip.tsx new file mode 100644 index 00000000000..3618015fc59 --- /dev/null +++ b/packages/manager/src/components/MaintenancePolicySelect/DefaultPolicyChip.tsx @@ -0,0 +1,21 @@ +import { Chip, Tooltip } from '@linode/ui'; +import React from 'react'; + +import type { ChipProps } from '@linode/ui'; + +interface Props { + chipProps?: Partial; + tooltipText?: React.ReactNode; +} + +export const DefaultPolicyChip = (props: Props) => { + const { chipProps, tooltipText } = props; + return ( + + + + ); +}; diff --git a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.stories.tsx b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.stories.tsx new file mode 100644 index 00000000000..0f3b361b0da --- /dev/null +++ b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.stories.tsx @@ -0,0 +1,23 @@ +import { MaintenancePolicySelect } from './MaintenancePolicySelect'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta: Meta = { + title: 'Components/Selects/MaintenancePolicySelect', + component: MaintenancePolicySelect, +}; + +export default meta; +type Story = StoryObj; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; + +export const WithError: Story = { + args: { + errorText: 'This field is required', + }, +}; diff --git a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.test.tsx b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.test.tsx new file mode 100644 index 00000000000..093118731e5 --- /dev/null +++ b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.test.tsx @@ -0,0 +1,135 @@ +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { accountSettingsFactory } from 'src/factories'; +import { maintenancePolicyFactory } from 'src/factories/maintenancePolicy'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { MaintenancePolicySelect } from './MaintenancePolicySelect'; + +describe('MaintenancePolicySelect', () => { + it('should render a label', () => { + const { getByLabelText } = renderWithTheme( + + ); + + expect(getByLabelText('Maintenance Policy')).toBeVisible(); + }); + + it('should be disabled when disabled prop is true', () => { + const { getByRole } = renderWithTheme( + + ); + + expect(getByRole('combobox')).toBeDisabled(); + }); + + it('should show helper text from props', () => { + const { getByText } = renderWithTheme( + + ); + + expect( + getByText('Maintenance Policy is not available in this region.') + ).toBeVisible(); + }); + + it('should show maintenance policy options returned by the API', async () => { + const policies = [ + maintenancePolicyFactory.build({ label: 'Power Off / Power On' }), + maintenancePolicyFactory.build({ label: 'Migrate' }), + ]; + + server.use( + http.get('*/maintenance/policies', () => { + return HttpResponse.json(makeResourcePage(policies)); + }) + ); + + const { getByRole, findByText } = renderWithTheme( + + ); + + await userEvent.click(getByRole('combobox')); + + expect(await findByText('Migrate')).toBeVisible(); + expect(await findByText('Power Off / Power On')).toBeVisible(); + }); + + it('should call onChange with the policy when one is chosen', async () => { + const onChange = vi.fn(); + const policies = [ + maintenancePolicyFactory.build({ label: 'Power Off / Power On' }), + maintenancePolicyFactory.build({ label: 'Migrate' }), + ]; + + server.use( + http.get('*/maintenance/policies', () => { + return HttpResponse.json(makeResourcePage(policies)); + }) + ); + + const { getByRole, findByText } = renderWithTheme( + + ); + + await userEvent.click(getByRole('combobox')); + + const option = await findByText('Power Off / Power On'); + + await userEvent.click(option); + + expect(onChange).toHaveBeenCalledWith({ + ...policies[0], + label: policies[0].label, + }); + }); + + it('should show a default chip for the account default', async () => { + const policies = [ + maintenancePolicyFactory.build({ + label: 'Power Off / Power On', + slug: 'linode/power_off_on', + }), + maintenancePolicyFactory.build({ + label: 'Migrate', + slug: 'linode/migrate', + }), + ]; + const accountSettings = accountSettingsFactory.build({ + maintenance_policy: 'linode/migrate', + }); + + server.use( + http.get('*/maintenance/policies', () => { + return HttpResponse.json(makeResourcePage(policies)); + }), + http.get('*/account/settings', () => { + return HttpResponse.json(accountSettings); + }) + ); + + const { getByRole, findByText } = renderWithTheme( + + ); + + await userEvent.click(getByRole('combobox')); + + expect(await findByText('Migrate')).toBeVisible(); + expect(await findByText('Power Off / Power On')).toBeVisible(); + + expect(await findByText('DEFAULT')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx new file mode 100644 index 00000000000..1d382dd3f17 --- /dev/null +++ b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx @@ -0,0 +1,128 @@ +import { + useAccountMaintenancePoliciesQuery, + useAccountSettings, +} from '@linode/queries'; +import { + Autocomplete, + InputAdornment, + SelectedIcon, + Stack, + Typography, +} from '@linode/ui'; +import React from 'react'; + +import { + MAINTENANCE_POLICY_OPTION_DESCRIPTIONS, + MIGRATE_TOOLTIP_TEXT, + POWER_OFF_TOOLTIP_TEXT, +} from './constants'; +import { DefaultPolicyChip } from './DefaultPolicyChip'; + +import type { MaintenancePolicy } from '@linode/api-v4'; +import type { TextFieldProps } from '@linode/ui'; + +interface Props { + disabled?: boolean; + errorText?: string; + hideDefaultChip?: boolean; + onChange: (policy: MaintenancePolicy) => void; + textFieldProps?: Partial; + value?: 'linode/migrate' | 'linode/power_off_on' | null; +} + +export const MaintenancePolicySelect = (props: Props) => { + const { + disabled, + errorText, + onChange, + value, + hideDefaultChip, + textFieldProps, + } = props; + + const { data: policies, isFetching } = useAccountMaintenancePoliciesQuery(); + const { data: accountSettings } = useAccountSettings(); + + const options = policies ?? []; + + const defaultPolicyId = + accountSettings?.maintenance_policy ?? + policies?.find((p) => p.is_default)?.slug; + + const selectedOption = + (value + ? options.find((o) => o.slug === value) + : options.find((o) => o.is_default)) ?? null; + + return ( + { + if (policy) { + onChange(policy); + } + }} + options={options} + renderOption={(props, policy, state) => { + const { key } = props; + return ( +
  • + + + {policy.label} + {!hideDefaultChip && defaultPolicyId === policy.slug && ( + + )} + + + ({ + font: theme.tokens.alias.Typography.Label.Regular.Xs, + })} + > + {MAINTENANCE_POLICY_OPTION_DESCRIPTIONS[policy.slug] ?? + policy.description} + + {state.selected && } + + +
  • + ); + }} + textFieldProps={{ + InputProps: { + endAdornment: !hideDefaultChip && + selectedOption?.slug === defaultPolicyId && ( + + + + ), + }, + tooltipText: ( + + + Migrate: {MIGRATE_TOOLTIP_TEXT} + + + Power Off / Power On: {POWER_OFF_TOOLTIP_TEXT} + + + ), + tooltipWidth: 410, + ...textFieldProps, + }} + value={selectedOption} + /> + ); +}; diff --git a/packages/manager/src/components/MaintenancePolicySelect/constants.ts b/packages/manager/src/components/MaintenancePolicySelect/constants.ts new file mode 100644 index 00000000000..54eb35b08b0 --- /dev/null +++ b/packages/manager/src/components/MaintenancePolicySelect/constants.ts @@ -0,0 +1,31 @@ +import type { MaintenancePolicySlug } from '@linode/api-v4'; + +export const MIGRATE_TOOLTIP_TEXT = + 'Migrates the Linode to a new host while it is still running. During the migration, the instance remains fully operational, though there is a temporary performance impact. For most maintenance events and Linode types, no reboot is required after the migration completes. If a reboot is required, it is automatically performed.'; + +export const POWER_OFF_TOOLTIP_TEXT = + 'Powers off the Linode at the start of the maintenance event and reboots it once the maintenance finishes. Depending on the maintenance event and Linode type, the Linode may or may not remain on the same host. Do not select this option for Linodes that are used for container orchestration solutions like Kubernetes.'; + +export const MAINTENANCE_POLICY_TITLE = 'Host Maintenance Policy'; + +export const MAINTENANCE_POLICY_DESCRIPTION = + 'Select the preferred default host maintenance policy for this Linode. During host maintenance events (such as host upgrades), this policy setting determines the type of migration that is used. Learn more.'; + +export const MAINTENANCE_POLICY_OPTION_DESCRIPTIONS: Record< + MaintenancePolicySlug, + string +> = { + 'linode/migrate': + 'Migrates the Linode to a new host while it remains fully operational. Recommended for maximizing availability.', + 'linode/power_off_on': + 'Powers off the Linode at the start of the maintenance event and reboots it once the maintenance finishes. Recommended for maximizing performance.', +}; + +export const MAINTENANCE_POLICY_LEARN_MORE_URL = + 'https://techdocs.akamai.com/cloud-computing/docs/host-maintenance-policy'; + +export const MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT = + 'Maintenance policy is not available in the selected region.'; + +export const GPU_PLAN_NOTICE = + 'GPU plan does not support live migration and will perform a warm migration and then cold migration as fallbacks.'; diff --git a/packages/manager/src/components/Markdown/Markdown.stories.tsx b/packages/manager/src/components/Markdown/Markdown.stories.tsx index 6daff08aeb7..7fb0f46b139 100644 --- a/packages/manager/src/components/Markdown/Markdown.stories.tsx +++ b/packages/manager/src/components/Markdown/Markdown.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Markdown } from './Markdown'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const markdown = ` # h1 Heading diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx index e97a40c0033..537f5fdf4cd 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.tsx @@ -1,6 +1,7 @@ import { usePreferences } from '@linode/queries'; import { Stack, Typography, VisibilityTooltip } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { createMaskedText } from 'src/utilities/createMaskedText'; diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.stories.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.stories.tsx index 7bf2157e428..0f3ed1f067b 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.stories.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.stories.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { MultipleIPInput } from './MultipleIPInput'; import type { MultipeIPInputProps } from './MultipleIPInput'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; @@ -18,7 +18,7 @@ const defaultArgs = { const meta: Meta = { component: MultipleIPInput, decorators: [ - (Story: StoryFn) => ( + (Story) => (
    diff --git a/packages/manager/src/components/NavTabs/NavTabs.test.ts b/packages/manager/src/components/NavTabs/NavTabs.test.ts deleted file mode 100644 index 9d3c82dff09..00000000000 --- a/packages/manager/src/components/NavTabs/NavTabs.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getTabMatch } from './NavTabs'; - -import type { NavTab } from './NavTabs'; - -describe('getTabMatch', () => { - const tabs: NavTab[] = [ - { - component: null as any, - routeName: '/databases/1234/backups', - title: 'Backups', - }, - { - component: null as any, - routeName: '/databases/1234/settings', - title: 'Settings', - }, - ]; - - it('returns the index of the matched tab', () => { - expect(getTabMatch(tabs, '/databases/1234/settings')).toEqual({ - idx: 1, - isExact: true, - }); - }); - - it('returns whether the match is exact', () => { - expect(getTabMatch(tabs, '/databases/1234/settings/unknown-path')).toEqual({ - idx: 1, - isExact: false, - }); - }); - - it('returns an index of `-1` if there is no match', () => { - expect(getTabMatch(tabs, '/databases/1234/unknown-path').idx).toBe(-1); - }); -}); diff --git a/packages/manager/src/components/NavTabs/NavTabs.tsx b/packages/manager/src/components/NavTabs/NavTabs.tsx deleted file mode 100644 index 7d534de399d..00000000000 --- a/packages/manager/src/components/NavTabs/NavTabs.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import * as React from 'react'; -import { matchPath, Redirect, useHistory, useLocation } from 'react-router-dom'; - -import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; -import { TabPanel } from 'src/components/Tabs/TabPanel'; -import { TabPanels } from 'src/components/Tabs/TabPanels'; -import { Tabs } from 'src/components/Tabs/Tabs'; - -import { TabLinkList } from '../Tabs/TabLinkList'; - -export interface NavTab { - // especially when a component behind a tab performs network requests. - backgroundRendering?: boolean; - component?: - | React.ComponentType - | React.LazyExoticComponent; - render?: JSX.Element; - routeName: string; - // Whether or not this tab should be rendered in the background (even when - // not on screen). Consumers should consider performance implications, - title: string; -} - -export interface NavTabsProps { - navToTabRouteOnChange?: boolean; - tabs: NavTab[]; -} - -export const NavTabs = React.memo((props: NavTabsProps) => { - const history = useHistory(); - const reactRouterLocation = useLocation(); - - const { navToTabRouteOnChange, tabs } = props; - - // Defaults to `true`. - const _navToTabRouteOnChange = navToTabRouteOnChange ?? true; - - const navToURL = (index: number) => { - if (tabs[index]) { - history.push(tabs[index].routeName); - } - }; - - const tabMatch = getTabMatch(tabs, reactRouterLocation.pathname); - - // Redirect to the first tab's route name if the pathname is unknown. - if (tabMatch.idx === -1) { - return ; - } - - // Redirect to the exact route name if the pathname doesn't match precisely. - if (!tabMatch.isExact) { - return ; - } - - return ( - - - }> - - {tabs.map((thisTab, i) => { - if (!thisTab.render && !thisTab.component) { - return null; - } - - const _TabPanelComponent = thisTab.backgroundRendering - ? TabPanel - : SafeTabPanel; - - return ( - <_TabPanelComponent index={i} key={thisTab.routeName}> - {thisTab.component ? ( - - ) : thisTab.render ? ( - thisTab.render - ) : null} - - ); - })} - - - - ); -}); - -// Given tabs and a pathname, return the index of the matched tab, and whether -// or not it's an exact match. If no match is found, the returned index is -1. -export const getTabMatch = (tabs: NavTab[], pathname: string) => { - return tabs.reduce( - (acc, thisTab, i) => { - const match = matchPath(pathname, { - exact: false, - path: thisTab.routeName, - }); - - if (match) { - acc.idx = i; - acc.isExact = match.isExact; - } - - return acc; - }, - { idx: -1, isExact: false } - ); -}; diff --git a/packages/manager/src/components/OSIcon.stories.tsx b/packages/manager/src/components/OSIcon.stories.tsx index dd16e698fb5..fb6c9d6aa59 100644 --- a/packages/manager/src/components/OSIcon.stories.tsx +++ b/packages/manager/src/components/OSIcon.stories.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { OSIcon } from './OSIcon'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx b/packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx index f512dc2645b..fc5e10eb86c 100644 --- a/packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx +++ b/packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx @@ -1,9 +1,9 @@ -import { useArgs } from '@storybook/preview-api'; import * as React from 'react'; +import { useArgs } from 'storybook/preview-api'; import { PaginationControls } from './PaginationControls'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx b/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx index 5608aa6bd4e..d41464812fb 100644 --- a/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx +++ b/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { HideShowText } from './HideShowText'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: HideShowText, diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx index d0a7c46d464..cbf0f15bed1 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { PasswordInput } from './PasswordInput'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: PasswordInput, diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.tsx index c08d9831204..01063e094d6 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.tsx @@ -1,5 +1,6 @@ import { Stack } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import zxcvbn from 'zxcvbn'; import { StrengthIndicator } from '../PasswordInput/StrengthIndicator'; diff --git a/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx b/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx index bd41bb63e86..0ec65b46ca8 100644 --- a/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx +++ b/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { StrengthIndicator } from './StrengthIndicator'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: StrengthIndicator, diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.stories.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.stories.tsx index ab0f0762c37..81205c4b741 100644 --- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.stories.tsx +++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.stories.tsx @@ -1,12 +1,12 @@ -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { paymentMethodFactory } from 'src/factories'; import { PaymentMethodRow } from './PaymentMethodRow'; import type { CardType } from '@linode/api-v4'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/Placeholder/Placeholder.stories.tsx b/packages/manager/src/components/Placeholder/Placeholder.stories.tsx index 2c841495df8..dfad1e54517 100644 --- a/packages/manager/src/components/Placeholder/Placeholder.stories.tsx +++ b/packages/manager/src/components/Placeholder/Placeholder.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Placeholder } from './Placeholder'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/Placeholder/Placeholder.tsx b/packages/manager/src/components/Placeholder/Placeholder.tsx index e804a96a81e..24a57ee8045 100644 --- a/packages/manager/src/components/Placeholder/Placeholder.tsx +++ b/packages/manager/src/components/Placeholder/Placeholder.tsx @@ -1,6 +1,7 @@ import { Button, fadeIn, H1Header, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import ComputeIcon from 'src/assets/icons/entityIcons/compute.svg'; diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx index a7a5ee043b4..cd0be38fa20 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable testing-library/prefer-screen-queries */ import { linodeFactory } from '@linode/utilities'; import React from 'react'; @@ -11,6 +10,7 @@ const queryMocks = vi.hoisted(() => ({ useNotificationsQuery: vi.fn().mockReturnValue({}), useAllAccountMaintenanceQuery: vi.fn().mockReturnValue({}), useLinodeQuery: vi.fn().mockReturnValue({}), + useLocation: vi.fn(), })); vi.mock('@linode/queries', async () => { @@ -21,8 +21,17 @@ vi.mock('@linode/queries', async () => { }; }); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: queryMocks.useLocation, + }; +}); + beforeEach(() => { vi.stubEnv('TZ', 'UTC'); + queryMocks.useLocation.mockReturnValue({ pathname: '/linodes' }); }); describe('LinodePlatformMaintenanceBanner', () => { @@ -124,4 +133,86 @@ describe('LinodePlatformMaintenanceBanner', () => { ) ).toBeVisible(); }); + + it('renders linode label as a link when not on linode detail page', () => { + const mockPlatformMaintenance = accountMaintenanceFactory.buildList(1, { + type: 'reboot', + entity: { type: 'linode', id: 123 }, + reason: 'Your Linode needs a critical security update', + when: '2020-01-01T00:00:00', + start_time: '2020-01-01T00:00:00', + }); + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockPlatformMaintenance, + }); + + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: 123, + label: 'test-linode', + }), + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + // Mock location to be on a different page + queryMocks.useLocation.mockReturnValue({ pathname: '/linodes' }); + + const { getByRole } = renderWithTheme( + + ); + + const link = getByRole('link', { name: 'test-linode' }); + expect(link).toBeVisible(); + expect(link).toHaveAttribute('href', '/linodes/123'); + }); + + it('renders linode label as plain text when on linode detail page', () => { + const mockPlatformMaintenance = accountMaintenanceFactory.buildList(1, { + type: 'reboot', + entity: { type: 'linode', id: 123 }, + reason: 'Your Linode needs a critical security update', + when: '2020-01-01T00:00:00', + start_time: '2020-01-01T00:00:00', + }); + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockPlatformMaintenance, + }); + + queryMocks.useLinodeQuery.mockReturnValue({ + data: linodeFactory.build({ + id: 123, + label: 'test-linode', + }), + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + // Mock location to be on the linode detail page + queryMocks.useLocation.mockReturnValue({ pathname: '/linodes/123' }); + + const { container, queryByRole } = renderWithTheme( + + ); + + // Should show the label as plain text within the Typography component + expect(container.textContent).toContain('test-linode'); + + // Should not have a link + expect( + queryByRole('link', { name: 'test-linode' }) + ).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx index e0dd55553d6..0d317de4d97 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx @@ -2,6 +2,7 @@ import { useLinodeQuery } from '@linode/queries'; import { Notice } from '@linode/ui'; import { Box, Button, Stack, Typography } from '@linode/ui'; import React from 'react'; +import { useLocation } from 'react-router-dom'; import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer'; import { usePlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; @@ -15,6 +16,7 @@ export const LinodePlatformMaintenanceBanner = (props: { linodeId: Linode['id']; }) => { const { linodeId } = props; + const location = useLocation(); const { linodesWithPlatformMaintenance, platformMaintenanceByLinode } = usePlatformMaintenance(); @@ -46,6 +48,8 @@ export const LinodePlatformMaintenanceBanner = (props: { const startTime = getMaintenanceStartTime(earliestMaintenance); + const hideLinodeLink = location.pathname === `/linodes/${linodeId}`; + return ( <> @@ -53,9 +57,13 @@ export const LinodePlatformMaintenanceBanner = (props: { Linode{' '} - - {linode?.label ?? linodeId} - {' '} + {hideLinodeLink ? ( + `${linode?.label ?? linodeId}` + ) : ( + + {linode?.label ?? linodeId} + + )}{' '} needs to be rebooted for critical platform maintenance.{' '} {startTime && ( <> diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx index 250b3cc821f..e692f09bbeb 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable testing-library/prefer-screen-queries */ import React from 'react'; import { accountMaintenanceFactory, notificationFactory } from 'src/factories'; diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx index c622274c21f..2ee52c46423 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx @@ -1,6 +1,5 @@ import { Notice, Typography } from '@linode/ui'; import React from 'react'; -import { useLocation } from 'react-router-dom'; import { usePlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; @@ -13,13 +12,15 @@ import { Link } from '../Link'; * them separately from the standard MaintenanceBanner. */ -export const PlatformMaintenanceBanner = () => { +export const PlatformMaintenanceBanner = ({ + pathname, +}: { + pathname?: string; +}) => { const { accountHasPlatformMaintenance, linodesWithPlatformMaintenance } = usePlatformMaintenance(); - const location = useLocation(); - const hideAccountMaintenanceLink = - location.pathname === '/account/maintenance'; + const hideAccountMaintenanceLink = pathname === '/account/maintenance'; if (!accountHasPlatformMaintenance) return null; @@ -37,7 +38,7 @@ export const PlatformMaintenanceBanner = () => { {!hideAccountMaintenanceLink && ( <> {' '} - See which Linodes are scheduled for reboot on the{' '} + See which Linodes are scheduled for reboot on the{' '} Account Maintenance page. )} diff --git a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx index 2b04bf8b28d..5cf8daf1e38 100644 --- a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx +++ b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx @@ -1,4 +1,5 @@ import { useMutatePreferences, usePreferences } from '@linode/queries'; +import type { JSX } from 'react'; import type { ManagerPreferences } from '@linode/utilities'; diff --git a/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx b/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx index 0c31d79cbe3..712f0a93875 100644 --- a/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx +++ b/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx @@ -1,15 +1,15 @@ import { Box, IconButton } from '@linode/ui'; import { Hidden } from '@linode/ui'; import MenuIcon from '@mui/icons-material/Menu'; -import { useArgs } from '@storybook/preview-api'; import * as React from 'react'; +import { useArgs } from 'storybook/preview-api'; import { TopMenuTooltip } from 'src/features/TopMenu/TopMenuTooltip'; import { SideMenu } from './SideMenu'; import type { SideMenuProps } from './SideMenu'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => { diff --git a/packages/manager/src/components/PrimaryNav/utils.ts b/packages/manager/src/components/PrimaryNav/utils.ts index 25c0d370c6a..1c5a6b58d6f 100644 --- a/packages/manager/src/components/PrimaryNav/utils.ts +++ b/packages/manager/src/components/PrimaryNav/utils.ts @@ -28,7 +28,7 @@ export const linkIsActive = ( * It is used to determine if the side menu should be sticky. */ export const useIsPageScrollable = ( - contentRef: React.RefObject + contentRef: React.RefObject ): { isPageScrollable: boolean } => { const [isPageScrollable, setIsPageScrollable] = React.useState(false); diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx index b29904bc140..c8387f52df6 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx @@ -9,7 +9,7 @@ import { RegionMultiSelect } from './RegionMultiSelect'; import type { RegionMultiSelectProps } from './RegionSelect.types'; import type { Region } from '@linode/api-v4'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const sortRegionOptions = (a: Region, b: Region) => { return sortByString(a.label, b.label, 'asc'); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx index 8b05ef83569..e15be4ccb9b 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx @@ -1,12 +1,12 @@ import { Box } from '@linode/ui'; import { regions } from '@linode/utilities'; -import { useArgs } from '@storybook/preview-api'; import React from 'react'; +import { useArgs } from 'storybook/preview-api'; import { RegionSelect } from './RegionSelect'; import type { RegionSelectProps } from './RegionSelect.types'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => { diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 5484b9a1672..a4347adc667 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -1,5 +1,5 @@ import { useAllAccountAvailabilitiesQuery } from '@linode/queries'; -import { Autocomplete } from '@linode/ui'; +import { Autocomplete, InputAdornment } from '@linode/ui'; import PublicIcon from '@mui/icons-material/Public'; import { createFilterOptions } from '@mui/material/Autocomplete'; import * as React from 'react'; @@ -148,22 +148,26 @@ export const RegionSelect = < textFieldProps={{ ...props.textFieldProps, InputProps: { - endAdornment: - isGeckoLAEnabled && selectedRegion && `(${selectedRegion?.id})`, + endAdornment: isGeckoLAEnabled && selectedRegion && ( + + ({selectedRegion?.id}) + + ), required, - startAdornment: - selectedRegion && - (selectedRegion.id === 'global' ? ( - - ) : ( - - )), + startAdornment: selectedRegion && ( + + {selectedRegion.id === 'global' ? ( + + ) : ( + + )} + + ), }, tooltipText, }} diff --git a/packages/manager/src/components/RegionSelect/constants.ts b/packages/manager/src/components/RegionSelect/constants.ts index f49e37ddcae..83d2601bcc8 100644 --- a/packages/manager/src/components/RegionSelect/constants.ts +++ b/packages/manager/src/components/RegionSelect/constants.ts @@ -18,4 +18,5 @@ export const regionSelectGlobalOption: Region = { resolvers: { ipv4: '', ipv6: '' }, site_type: 'core', status: 'ok', + monitors: { alerts: [], metrics: [] }, }; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx index f4ecd63b7ee..3a69262207b 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { RemovableSelectionsList } from './RemovableSelectionsList'; import type { RemovableItem } from './RemovableSelectionsList'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index 5b3e912f54c..6dc88b4c126 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -1,5 +1,6 @@ import { Box, CloseIcon, IconButton } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { SelectedOptionsHeader, diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx index 27e55e5fc0c..9bfd72865dc 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx @@ -2,6 +2,7 @@ import { useFirewallsQuery } from '@linode/queries'; import { Autocomplete, Box, Paper, Stack, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { CreateFirewallDrawer } from 'src/features/Firewalls/FirewallLanding/CreateFirewallDrawer'; import { useFlags } from 'src/hooks/useFlags'; diff --git a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.stories.tsx b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.stories.tsx index c9b04b782b0..c1dd65c976f 100644 --- a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.stories.tsx +++ b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.stories.tsx @@ -7,7 +7,7 @@ import { TableHead } from '../TableHead'; import { TableRow } from '../TableRow'; import { SelectableTableRow } from './SelectableTableRow'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; @@ -27,7 +27,7 @@ const cells = [ const meta: Meta = { component: SelectableTableRow, decorators: [ - (Story: StoryFn) => ( + (Story) => (
    diff --git a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx index d12ab3e9667..c1b861cd798 100644 --- a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx +++ b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx @@ -1,6 +1,7 @@ import { Checkbox } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; diff --git a/packages/manager/src/components/SelectionCard/CardBase.tsx b/packages/manager/src/components/SelectionCard/CardBase.tsx index 0e9ed10c4c9..63aded8881f 100644 --- a/packages/manager/src/components/SelectionCard/CardBase.tsx +++ b/packages/manager/src/components/SelectionCard/CardBase.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { useFlags } from 'src/hooks/useFlags'; diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.stories.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.stories.tsx index 8ca2b5e5bfe..cde1bdb085f 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.stories.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.stories.tsx @@ -8,7 +8,7 @@ import * as React from 'react'; import { SelectionCard } from './SelectionCard'; import type { SelectionCardProps } from './SelectionCard'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const iconOptions = { Alarm: () => , diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.tsx index 6798ada6f8f..cc9985b5f56 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.tsx @@ -2,6 +2,7 @@ import { Tooltip } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { CardBase } from './CardBase'; diff --git a/packages/manager/src/components/ShowMore/ShowMore.stories.tsx b/packages/manager/src/components/ShowMore/ShowMore.stories.tsx index 5961a679e4b..d43126d9cbc 100644 --- a/packages/manager/src/components/ShowMore/ShowMore.stories.tsx +++ b/packages/manager/src/components/ShowMore/ShowMore.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ShowMore } from 'src/components/ShowMore/ShowMore'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const mockArray = [...Array(5)].map((_, i) => String.fromCharCode(97 + i)); diff --git a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.tsx b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.tsx index d67d7e9c573..a3a632f370a 100644 --- a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.tsx +++ b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ShowMoreExpansion } from './ShowMoreExpansion'; import type { ShowMoreExpansionProps } from './ShowMoreExpansion'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx index c0165d58447..9a0bd026022 100644 --- a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx +++ b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx @@ -2,6 +2,7 @@ import { Button } from '@linode/ui'; import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; import Collapse from '@mui/material/Collapse'; import * as React from 'react'; +import type { JSX } from 'react'; import { makeStyles } from 'tss-react/mui'; import type { ButtonProps } from '@linode/ui'; diff --git a/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx b/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx index b6c97cc3ba5..ac7bba9c485 100644 --- a/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx +++ b/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx @@ -6,7 +6,7 @@ import { Snackbar } from 'src/components/Snackbar/Snackbar'; import { eventFactory } from 'src/factories'; import { getEventMessage } from 'src/features/Events/utils'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import type { VariantType } from 'notistack'; /** diff --git a/packages/manager/src/components/StackScript/StackScript.tsx b/packages/manager/src/components/StackScript/StackScript.tsx index badfca41d15..c2e20398410 100644 --- a/packages/manager/src/components/StackScript/StackScript.tsx +++ b/packages/manager/src/components/StackScript/StackScript.tsx @@ -14,6 +14,7 @@ import { } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { useHistory } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/components/StatusIcon/StatusIcon.stories.tsx b/packages/manager/src/components/StatusIcon/StatusIcon.stories.tsx index 2921bd328b1..e95d8fe7a56 100644 --- a/packages/manager/src/components/StatusIcon/StatusIcon.stories.tsx +++ b/packages/manager/src/components/StatusIcon/StatusIcon.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { StatusIcon } from './StatusIcon'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: StatusIcon, diff --git a/packages/manager/src/components/SuspenseLoader.stories.tsx b/packages/manager/src/components/SuspenseLoader.stories.tsx index 28b0d25ac4f..63abdffd757 100644 --- a/packages/manager/src/components/SuspenseLoader.stories.tsx +++ b/packages/manager/src/components/SuspenseLoader.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { SuspenseLoader } from './SuspenseLoader'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: SuspenseLoader, diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index 296c00c6394..3060be39440 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -1,7 +1,7 @@ import { Box, Notice, Paper, Tooltip, Typography } from '@linode/ui'; import HelpOutline from '@mui/icons-material/HelpOutline'; import { styled } from '@mui/material/styles'; -import React, { useEffect, useState } from 'react'; +import React, { type JSX, useEffect, useState } from 'react'; import { Tab } from 'src/components/Tabs/Tab'; import { TabList } from 'src/components/Tabs/TabList'; diff --git a/packages/manager/src/components/Table/Table.stories.tsx b/packages/manager/src/components/Table/Table.stories.tsx index f73ca6ba50b..71c22567627 100644 --- a/packages/manager/src/components/Table/Table.stories.tsx +++ b/packages/manager/src/components/Table/Table.stories.tsx @@ -8,7 +8,7 @@ import { TableRow } from 'src/components/TableRow'; import { Table } from './Table'; import type { TableProps } from './Table'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/components/Table/Table.tsx b/packages/manager/src/components/Table/Table.tsx index 1157cf83c23..e3207fa6aff 100644 --- a/packages/manager/src/components/Table/Table.tsx +++ b/packages/manager/src/components/Table/Table.tsx @@ -1,6 +1,9 @@ import { usePreferences } from '@linode/queries'; import { default as _Table } from '@mui/material/Table'; import * as React from 'react'; +import { useStyles } from 'tss-react'; + +import { getIsTableStripingEnabled } from 'src/features/Profile/Settings/TableStriping.utils'; import { StyledTableWrapper } from './Table.styles'; @@ -71,6 +74,7 @@ export interface TableProps extends _TableProps { * - **Disclaimer:** The UX team is in the process of assessing the usability of all of the above */ export const Table = (props: TableProps) => { + const { cx } = useStyles(); const { className, colCount, @@ -84,11 +88,14 @@ export const Table = (props: TableProps) => { tableClass = '', ...rest } = props; - const { data: preferences } = usePreferences(); - const isTableStripingEnabled = Boolean( - preferences?.isTableStripingEnabled && striped + + const { data: tableStripingPreference } = usePreferences( + (preferences) => preferences?.isTableStripingEnabled ); + const isTableStripingEnabled = + getIsTableStripingEnabled(tableStripingPreference) && striped; + return ( { spacingTop={spacingTop} > <_Table - className={`${tableClass} ${ - isTableStripingEnabled && !nested ? 'MuiTable-zebra' : '' - }`} + className={cx(tableClass, { + ['MuiTable-zebra']: isTableStripingEnabled && !nested, + })} {...rest} aria-colcount={colCount} aria-rowcount={rowCount} diff --git a/packages/manager/src/components/TableContentWrapper/TableContentWrapper.tsx b/packages/manager/src/components/TableContentWrapper/TableContentWrapper.tsx index 3cebe3041f7..5a0b9eba2b6 100644 --- a/packages/manager/src/components/TableContentWrapper/TableContentWrapper.tsx +++ b/packages/manager/src/components/TableContentWrapper/TableContentWrapper.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; diff --git a/packages/manager/src/components/TableFooter.stories.tsx b/packages/manager/src/components/TableFooter.stories.tsx index 4f43aa61f34..02a22a7b1da 100644 --- a/packages/manager/src/components/TableFooter.stories.tsx +++ b/packages/manager/src/components/TableFooter.stories.tsx @@ -9,7 +9,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableFooter } from './TableFooter'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { args: { diff --git a/packages/manager/src/components/TableRowEmpty/TableRowEmpty.stories.tsx b/packages/manager/src/components/TableRowEmpty/TableRowEmpty.stories.tsx index 9fc1730c1c7..d77efba6a4d 100644 --- a/packages/manager/src/components/TableRowEmpty/TableRowEmpty.stories.tsx +++ b/packages/manager/src/components/TableRowEmpty/TableRowEmpty.stories.tsx @@ -8,7 +8,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import type { TableRowEmptyProps } from './TableRowEmpty'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => { diff --git a/packages/manager/src/components/TableRowEmpty/TableRowEmpty.tsx b/packages/manager/src/components/TableRowEmpty/TableRowEmpty.tsx index 33b0aeb2070..3a59e64d174 100644 --- a/packages/manager/src/components/TableRowEmpty/TableRowEmpty.tsx +++ b/packages/manager/src/components/TableRowEmpty/TableRowEmpty.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; diff --git a/packages/manager/src/components/TableRowError/TableRowError.stories.tsx b/packages/manager/src/components/TableRowError/TableRowError.stories.tsx index fb78cf2d3ca..24fcde808b0 100644 --- a/packages/manager/src/components/TableRowError/TableRowError.stories.tsx +++ b/packages/manager/src/components/TableRowError/TableRowError.stories.tsx @@ -8,7 +8,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import type { TableRowErrorProps } from './TableRowError'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => { diff --git a/packages/manager/src/components/TableRowError/TableRowError.tsx b/packages/manager/src/components/TableRowError/TableRowError.tsx index bb1fdab8cb1..af8646d41a1 100644 --- a/packages/manager/src/components/TableRowError/TableRowError.tsx +++ b/packages/manager/src/components/TableRowError/TableRowError.tsx @@ -1,5 +1,6 @@ import { ErrorState } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; diff --git a/packages/manager/src/components/TableRowLoading/TableRowLoading.stories.tsx b/packages/manager/src/components/TableRowLoading/TableRowLoading.stories.tsx index 4d0e65caa84..2c30aa65700 100644 --- a/packages/manager/src/components/TableRowLoading/TableRowLoading.stories.tsx +++ b/packages/manager/src/components/TableRowLoading/TableRowLoading.stories.tsx @@ -8,7 +8,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import type { TableRowLoadingProps } from './TableRowLoading'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => { diff --git a/packages/manager/src/components/Tabs/Tabs.stories.tsx b/packages/manager/src/components/Tabs/Tabs.stories.tsx index 12b127768fb..f84c0e08450 100644 --- a/packages/manager/src/components/Tabs/Tabs.stories.tsx +++ b/packages/manager/src/components/Tabs/Tabs.stories.tsx @@ -8,7 +8,7 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { TabLinkList } from './TabLinkList'; import type { TabsProps } from '@reach/tabs'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const _tabs = [ { diff --git a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap index 33c1935026c..70e6c57486d 100644 --- a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap +++ b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap @@ -5,7 +5,7 @@ exports[`TabList component > renders TabList correctly 1`] = `
    = { render: (args: TagProps) => , diff --git a/packages/manager/src/components/TagCell.stories.tsx b/packages/manager/src/components/TagCell.stories.tsx index c42f8d4d910..65e74b84845 100644 --- a/packages/manager/src/components/TagCell.stories.tsx +++ b/packages/manager/src/components/TagCell.stories.tsx @@ -1,11 +1,11 @@ import { Box } from '@linode/ui'; -import { useArgs } from '@storybook/preview-api'; import React from 'react'; +import { useArgs } from 'storybook/preview-api'; import { TagCell } from './TagCell/TagCell'; import type { TagCellProps } from './TagCell/TagCell'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const _tags: string[] = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; diff --git a/packages/manager/src/components/Tags/Tags.stories.tsx b/packages/manager/src/components/Tags/Tags.stories.tsx index 27731490de1..b4093a7b6ba 100644 --- a/packages/manager/src/components/Tags/Tags.stories.tsx +++ b/packages/manager/src/components/Tags/Tags.stories.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Tags } from './Tags'; import type { TagsProps } from './Tags'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx index d548481ff18..123f6beaae4 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx @@ -1,11 +1,11 @@ import { Box } from '@linode/ui'; -import { useArgs } from '@storybook/preview-api'; import React from 'react'; +import { useArgs } from 'storybook/preview-api'; import { TagsInput } from './TagsInput'; import type { TagOption, TagsInputProps } from './TagsInput'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { args: { diff --git a/packages/manager/src/components/TanstackLink.stories.tsx b/packages/manager/src/components/TanstackLink.stories.tsx index badc132c03f..01885663f9a 100644 --- a/packages/manager/src/components/TanstackLink.stories.tsx +++ b/packages/manager/src/components/TanstackLink.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { TanstackLink } from './TanstackLinks'; import type { TanstackLinkComponentProps } from './TanstackLinks'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const AsButtonPrimary: StoryObj = { render: () => ( diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.stories.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.stories.tsx index c89cc737c92..4e7cab37824 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.stories.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.stories.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { TextTooltip } from './TextTooltip'; import type { TextTooltipProps } from './TextTooltip'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; /** Default TextTooltip */ export const Default: StoryObj = { diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index 415501a22aa..560950f95e1 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -1,6 +1,7 @@ import { Tooltip, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import type { TypographyProps } from '@linode/ui'; import type { SxProps, Theme } from '@mui/material'; diff --git a/packages/manager/src/components/Tile/Tile.stories.tsx b/packages/manager/src/components/Tile/Tile.stories.tsx index 9a25df30f68..7fd92626972 100644 --- a/packages/manager/src/components/Tile/Tile.stories.tsx +++ b/packages/manager/src/components/Tile/Tile.stories.tsx @@ -4,7 +4,7 @@ import Chat from 'src/assets/icons/chat.svg'; import { Tile } from 'src/components/Tile/Tile'; import type { TileProps } from './Tile'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/components/Tile/Tile.tsx b/packages/manager/src/components/Tile/Tile.tsx index 200a07bde1d..d061406d804 100644 --- a/packages/manager/src/components/Tile/Tile.tsx +++ b/packages/manager/src/components/Tile/Tile.tsx @@ -1,6 +1,7 @@ import { Notice, Typography } from '@linode/ui'; import Button from '@mui/material/Button'; import * as React from 'react'; +import type { JSX } from 'react'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx index 7c4776725ab..7ff7ca9704c 100644 --- a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx +++ b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx @@ -1,6 +1,7 @@ import { usePreferences } from '@linode/queries'; import { Checkbox, FormControlLabel, TextField, Typography } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { FormGroup } from 'src/components/FormGroup'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.stories.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.stories.tsx index 4752430b085..1b78abe44c5 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.stories.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.stories.tsx @@ -1,9 +1,9 @@ -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { TypeToConfirmDialog } from './TypeToConfirmDialog'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx index 35b7a42e3c2..d278b878583 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ImageUploader } from './ImageUploader'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; /** * This component enables users to attach and upload custom Images. diff --git a/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.stories.tsx b/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.stories.tsx index 906dad323af..ce9284ba42d 100644 --- a/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.stories.tsx +++ b/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.stories.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ObjectUploader } from './ObjectUploader'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; /** * This component enables users to attach and upload files to be stored in Object Storage. diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.stories.tsx b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.stories.tsx index dbfc6be04e4..9a01c7664ff 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.stories.tsx +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { VerticalLinearStepper } from './VerticalLinearStepper'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: VerticalLinearStepper, diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx index dc44a212800..500299db94f 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx @@ -9,7 +9,7 @@ import { } from '@mui/material'; import Box from '@mui/material/Box'; import useMediaQuery from '@mui/material/useMediaQuery'; -import React, { useState } from 'react'; +import React, { type JSX, useState } from 'react'; import { CustomStepIcon, diff --git a/packages/manager/src/components/intro.mdx b/packages/manager/src/components/intro.mdx index 3b1983702a3..12c290a55a0 100644 --- a/packages/manager/src/components/intro.mdx +++ b/packages/manager/src/components/intro.mdx @@ -1,4 +1,4 @@ -import { Meta } from '@storybook/blocks'; +import { Meta } from '@storybook/addon-docs/blocks'; diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 52ef81452e4..11612a33de2 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -26,6 +26,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'apl', label: 'Akamai App Platform' }, { flag: 'aplGeneralAvailability', label: 'Akamai App Platform GA' }, { flag: 'blockStorageEncryption', label: 'Block Storage Encryption (BSE)' }, + { flag: 'cloudNat', label: 'Cloud NAT' }, { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, { flag: 'gecko2', label: 'Gecko' }, { flag: 'limitsEvolution', label: 'Limits Evolution' }, diff --git a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx index 9d9e21bade9..d617bf4f69d 100644 --- a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx +++ b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx @@ -139,7 +139,7 @@ export const ServiceWorkerTool = () => { JSON.stringify(currentNotificationsData) !== JSON.stringify(customNotificationsData); - const hasCustomUserAccountPermissionsChanges = + const hasCustomUserAccountPermissionsChanges = JSON.stringify(currentUserAccountPermissionsData) !== JSON.stringify(customUserAccountPermissionsData); const hasCustomUserEntityPermissionsChanges = diff --git a/packages/manager/src/dev-tools/components/ExtraPresetList.tsx b/packages/manager/src/dev-tools/components/ExtraPresetList.tsx index 83612e40f58..6da9f373e9d 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetList.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetList.tsx @@ -9,6 +9,7 @@ import { CSS } from '@dnd-kit/utilities'; import { Dialog, Stack } from '@linode/ui'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import * as React from 'react'; +import type { JSX } from 'react'; import type { DragEndEvent } from '@dnd-kit/core'; import type { MockPresetExtra } from 'src/mocks/types'; diff --git a/packages/manager/src/factories/accountMaintenance.ts b/packages/manager/src/factories/accountMaintenance.ts index 1a2073cbe4a..2924c869193 100644 --- a/packages/manager/src/factories/accountMaintenance.ts +++ b/packages/manager/src/factories/accountMaintenance.ts @@ -22,7 +22,7 @@ export const accountMaintenanceFactory = ]) ), maintenance_policy_set: Factory.each(() => - pickRandom(['migrate', 'power on/off']) + pickRandom(['linode/migrate', 'linode/power_off_on']) ), reason: Factory.each(() => pickRandom([ diff --git a/packages/manager/src/factories/accountRoles.ts b/packages/manager/src/factories/accountRoles.ts index 78125d13042..4f4b1744a80 100644 --- a/packages/manager/src/factories/accountRoles.ts +++ b/packages/manager/src/factories/accountRoles.ts @@ -45,7 +45,7 @@ const createResourceRoles = ( creator.length > 0 ? { description: `Access to create a ${resourceType} instance`, - name: `${resourceType}_creator`, + name: `account_${resourceType}_creator`, permissions: creator, } : null, @@ -72,51 +72,65 @@ export const accountRolesFactory = Factory.Sync.makeFactory({ 'list_account_agreements', 'list_account_logins', 'list_available_services', - 'list_child_accounts', + 'list_default_firewalls', + 'list_enrolled_beta_programs', + 'list_service_transfers', + 'list_user_grants', + 'view_account', + 'view_account_login', + 'view_account_settings', + 'view_enrolled_beta_program', + 'view_network_usage', + 'view_region_available_service', + 'view_service_transfer', + 'view_user', + 'view_user_preferences', + 'list_billing_invoices', + 'list_billing_payments', + 'list_invoice_items', + 'list_payment_methods', + 'view_billing_invoice', + 'view_billing_payment', + 'view_payment_method', 'list_events', - 'list_linode_backups', - 'list_linode_config_profile_interfaces', - 'list_linode_config_profiles', - 'list_linode_disks', + 'mark_event_seen', + 'view_event', + 'list_maintenances', + 'list_notifications', + 'list_oauth_clients', + 'view_oauth_client', + 'view_oauth_client_thumbnail', + 'list_profile_apps', + 'list_profile_devices', + 'list_profile_grants', + 'list_profile_logins', + 'list_profile_pats', + 'list_profile_security_questions', + 'list_profile_ssh_keys', + 'view_profile', + 'view_profile_app', + 'view_profile_device', + 'view_profile_login', + 'view_profile_pat', + 'view_profile_ssh_key', + 'list_firewall_devices', + 'list_firewall_rule_versions', + 'list_firewall_rules', + 'view_firewall', + 'view_firewall_device', + 'view_firewall_rule_version', 'list_linode_firewalls', - 'list_linode_kernels', 'list_linode_nodebalancers', - 'list_linode_types', 'list_linode_volumes', - 'list_nodebalancer_config_nodes', - 'list_nodebalancer_configs', - 'list_nodebalancer_firewalls', - 'list_notifications', - 'list_oauth_clients', - 'list_service_transfers', - 'list_users', - 'list_vpc_ip_addresses', - 'list_vpc_subnets', - 'view_account_settings', - 'view_account', - 'view_image', + 'view_linode', 'view_linode_backup', - 'view_linode_config_profile_interface', 'view_linode_config_profile', + 'view_linode_config_profile_interface', '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_user', - 'view_volume', - 'view_vpc_subnet', - 'view_vpc', ], }, { @@ -124,184 +138,96 @@ export const accountRolesFactory = Factory.Sync.makeFactory({ 'Access to perform any supported action on all resources in the account', name: 'account_admin', permissions: [ + 'accept_service_transfer', '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_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', - 'create_nodebalancer', - 'create_oauth_client', + 'answer_profile_security_questions', + 'cancel_account', + 'cancel_service_transfer', + 'create_profile_pat', + 'create_profile_ssh_key', + 'create_profile_tfa_secret', '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', - 'delete_nodebalancer_config_node', - 'delete_nodebalancer_config', - 'delete_nodebalancer', + 'delete_profile_pat', + 'delete_profile_phone_number', + 'delete_profile_ssh_key', 'delete_user', - 'delete_volume', - 'delete_vpc_subnet', - 'delete_vpc', - 'detach_volume', - 'enable_linode_backups', + 'disable_profile_tfa', 'enable_managed', + 'enable_profile_tfa', 'enroll_beta_program', + 'is_account_admin', 'list_account_agreements', 'list_account_logins', - 'list_all_vpc_ipaddresses', 'list_available_services', - 'list_child_accounts', + 'list_default_firewalls', 'list_enrolled_beta_programs', - 'list_events', - 'list_firewall_devices', - 'list_firewalls', - 'list_images', - '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_service_transfers', - 'list_users', - 'list_volumes', - 'list_vpc_ip_addresses', - 'list_vpc_subnets', - 'list_vpcs', - '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', - 'share_ips', - 'share_ipv4', - 'shutdown_linode', - 'update_account_settings', + 'list_user_grants', + 'revoke_profile_app', + 'revoke_profile_device', + 'send_profile_phone_number_verification_code', '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_nodebalancer_config_node', - 'update_nodebalancer_config', - 'update_nodebalancer', + 'update_account_settings', + 'update_profile', + 'update_profile_pat', + 'update_profile_ssh_key', 'update_user', - 'update_volume', - 'update_vpc_subnet', - 'update_vpc', - 'upgrade_linode', - 'upload_image', - 'view_account_settings', + 'update_user_grants', + 'update_user_preferences', + 'verify_profile_phone_number', 'view_account', - 'view_firewall_device', - 'view_firewall', - 'view_image', - '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_account_login', + 'view_account_settings', + 'view_enrolled_beta_program', 'view_network_usage', - 'view_nodebalancer_config_node', - 'view_nodebalancer_config', - 'view_nodebalancer_statistics', - 'view_nodebalancer', + 'view_region_available_service', + 'view_service_transfer', 'view_user', - 'view_volume', - 'view_vpc_subnet', - 'view_vpc', + 'view_user_preferences', + 'create_payment_method', + 'create_promo_code', + 'delete_payment_method', + 'make_billing_payment', + 'set_default_payment_method', + 'list_billing_invoices', + 'list_billing_payments', + 'list_invoice_items', + 'list_payment_methods', + 'view_billing_invoice', + 'view_billing_payment', + 'view_payment_method', ], }, - { - description: - 'Access to perform any supported action on all linode instances in the account', - name: 'account_retail_owner', - permissions: ['cancel_account'], - }, { description: 'Access to view bills, payments in the account', - name: 'billing_viewer', + name: 'account_billing_viewer', permissions: [ + 'list_billing_invoices', + 'list_billing_payments', 'list_invoice_items', - 'list_invoices', 'list_payment_methods', - 'list_payments', - 'view_invoice', + 'view_billing_invoice', + 'view_billing_payment', 'view_payment_method', - 'view_payment', ], }, { description: 'Access to view bills, and make payments in the account', - name: 'billing_admin', + name: 'account_billing_admin', permissions: [ 'create_payment_method', 'create_promo_code', 'delete_payment_method', + 'make_billing_payment', + 'set_default_payment_method', + 'list_billing_invoices', + 'list_billing_payments', 'list_invoice_items', - 'list_invoices', 'list_payment_methods', - 'list_payments', - 'make_payment', - 'set_default_payment_method', - 'view_invoice', + 'view_billing_invoice', + 'view_billing_payment', 'view_payment_method', - 'view_payment', ], }, ], diff --git a/packages/manager/src/factories/accountSettings.ts b/packages/manager/src/factories/accountSettings.ts index b5d2d3973c4..8fd3727cba9 100644 --- a/packages/manager/src/factories/accountSettings.ts +++ b/packages/manager/src/factories/accountSettings.ts @@ -7,7 +7,7 @@ export const accountSettingsFactory = Factory.Sync.makeFactory( backups_enabled: false, interfaces_for_new_linodes: 'legacy_config_only', longview_subscription: null, - maintenance_policy_id: 1, + maintenance_policy: 'linode/migrate', managed: false, network_helper: false, object_storage: 'active', diff --git a/packages/manager/src/factories/cloudnats.ts b/packages/manager/src/factories/cloudnats.ts new file mode 100644 index 00000000000..fad7462d363 --- /dev/null +++ b/packages/manager/src/factories/cloudnats.ts @@ -0,0 +1,26 @@ +import { Factory } from '@linode/utilities'; + +import type { + CloudNAT, + CreateCloudNATPayload, + UpdateCloudNATPayload, +} from '@linode/api-v4/lib/networking/types'; + +export const cloudNATFactory = Factory.Sync.makeFactory({ + id: Factory.each((id) => id), + label: Factory.each((id) => `cloud-nat-mock-${id}`), + region: 'us-east', + addresses: Factory.each((id) => [{ address: `203.0.113.${id}` }]), + port_prefix_default_len: 1024, +}); + +export const createCloudNATPayloadFactory = + Factory.Sync.makeFactory({ + label: Factory.each((id) => `cloud-nat-mock-${id}`), + region: 'us-east', + }); + +export const updateCloudNATPayloadFactory = + Factory.Sync.makeFactory({ + label: Factory.each((id) => `updated-cloud-nat-mock-${id}`), + }); diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index 3896dd9015e..40ecbd5c277 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -110,4 +110,5 @@ export const alertFactory = Factory.Sync.makeFactory({ type: 'system', updated: new Date().toISOString(), updated_by: 'system', + scope: 'entity', }); diff --git a/packages/manager/src/factories/cloudpulse/services.ts b/packages/manager/src/factories/cloudpulse/services.ts index d7dca7f1540..0128ff411d6 100644 --- a/packages/manager/src/factories/cloudpulse/services.ts +++ b/packages/manager/src/factories/cloudpulse/services.ts @@ -1,15 +1,17 @@ +import { type Service } from '@linode/api-v4'; import { Factory } from '@linode/utilities'; -import type { ServiceTypes } from '@linode/api-v4'; +import type { ServiceAlert } from '@linode/api-v4'; -export const serviceTypesFactory = Factory.Sync.makeFactory({ +export const serviceAlertFactory = Factory.Sync.makeFactory({ + polling_interval_seconds: [1, 5, 10, 15], + evaluation_periods_seconds: [5, 10, 15, 20], + scope: ['entity', 'region', 'account'], +}); + +export const serviceTypesFactory = Factory.Sync.makeFactory({ label: Factory.each((i) => `Factory ServiceType-${i}`), service_type: Factory.each((i) => `Factory ServiceType-${i}`), - is_beta: false, regions: '*', - alert: { - polling_interval_seconds: [1, 5, 10, 15], - evaluation_periods_seconds: [5, 10, 15, 20], - scope: ['entity', 'region', 'account'], - }, + alert: serviceAlertFactory.build(), }); diff --git a/packages/manager/src/factories/devices.ts b/packages/manager/src/factories/devices.ts new file mode 100644 index 00000000000..b6aa847835b --- /dev/null +++ b/packages/manager/src/factories/devices.ts @@ -0,0 +1,12 @@ +import { Factory } from '@linode/utilities'; + +import type { TrustedDevice } from '@linode/api-v4/lib/profile/types'; + +export const trustedDeviceFactory = Factory.Sync.makeFactory({ + created: '2020-01-01T12:00:00', + expiry: '2026-01-01T12:00:00', + id: Factory.each((i) => i), + last_authenticated: '2020-01-01T12:00:00', + last_remote_addr: '127.0.0.1', + user_agent: Factory.each((i) => `test-user-agent-${i}`), +}); diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index 5c2804b1a63..419ecc21c6f 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -49,6 +49,7 @@ export const firewallFactory = Factory.Sync.makeFactory({ label: 'my-linode', type: 'linode' as FirewallDeviceEntityType, url: '/test', + parent_entity: null, }, ], id: Factory.each((id) => id), @@ -65,6 +66,7 @@ export const firewallEntityfactory = label: 'my-linode', type: 'linode' as FirewallDeviceEntityType, url: '/test', + parent_entity: null, }); export const firewallDeviceFactory = Factory.Sync.makeFactory({ @@ -74,6 +76,7 @@ export const firewallDeviceFactory = Factory.Sync.makeFactory({ label: 'entity', type: 'linode' as FirewallDeviceEntityType, url: '/linodes/1', + parent_entity: null, }, id: Factory.each((i) => i), updated: '2020-01-01', diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index 5b3b30d2ed0..a6556176da2 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -7,6 +7,7 @@ export * from './accountPayment'; export * from './accountSettings'; export * from './accountUsers'; export * from './billing'; +export * from './cloudnats'; export * from './cloudpulse/alerts'; export * from './cloudpulse/channels'; export * from './cloudpulse/services'; diff --git a/packages/manager/src/factories/maintenancePolicy.ts b/packages/manager/src/factories/maintenancePolicy.ts new file mode 100644 index 00000000000..ba33b639f3d --- /dev/null +++ b/packages/manager/src/factories/maintenancePolicy.ts @@ -0,0 +1,19 @@ +import { Factory } from '@linode/utilities'; + +import type { MaintenancePolicy } from '@linode/api-v4'; + +export const maintenancePolicyFactory = + Factory.Sync.makeFactory({ + slug: Factory.each((id) => + id === 1 ? 'linode/migrate' : 'linode/power_off_on' + ), + label: Factory.each((id) => + id === 1 ? 'Migrate' : 'Power Off / Power On' + ), + description: 'This is a maintenance policy description.', + is_default: false, + notification_period_sec: 86400, + type: Factory.each((id) => + id === 1 ? 'linode_migrate' : 'linode_power_off_on' + ), + }); diff --git a/packages/manager/src/factories/userRoles.ts b/packages/manager/src/factories/userRoles.ts index 56311d28cc4..1306a178361 100644 --- a/packages/manager/src/factories/userRoles.ts +++ b/packages/manager/src/factories/userRoles.ts @@ -2,18 +2,18 @@ import { Factory } from '@linode/utilities'; import type { EntityAccess, - EntityAccessRole, + EntityRoleType, EntityType, IamUserRoles, } from '@linode/api-v4'; -const possibleRoles: EntityAccessRole[] = [ +const possibleRoles: EntityRoleType[] = [ 'firewall_admin', - 'firewall_creator', + 'firewall_contributor', + 'firewall_viewer', + 'linode_admin', 'linode_contributor', - 'linode_creator', 'linode_viewer', - 'update_firewall', ]; export const possibleTypes: EntityType[] = [ @@ -55,8 +55,8 @@ const entityAccessList = [ export const userRolesFactory = Factory.Sync.makeFactory({ account_access: [ 'account_linode_admin', - 'linode_creator', - 'firewall_creator', + 'account_linode_creator', + 'account_firewall_creator', 'account_admin', 'account_viewer', ], diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 06741336649..5dd2a4c1821 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -70,6 +70,11 @@ interface LkeEnterpriseFlag extends BaseFeatureFlag { la: boolean; } +interface CloudNatFlag extends BetaFeatureFlag { + ga: boolean; + la: boolean; +} + export interface CloudPulseResourceTypeMapFlag { dimensionKey: string; maxResourceSelections?: number; @@ -99,12 +104,6 @@ interface AclpAlerting { recentActivity: boolean; } -interface AclpBetaServices { - alerts: boolean; - dbaas: boolean; - metrics: boolean; -} - interface LimitsEvolution { enabled: boolean; requestForIncreaseDisabledForAll: boolean; @@ -126,6 +125,7 @@ export interface Flags { aplGeneralAvailability: boolean; blockStorageEncryption: boolean; cloudManagerDesignUpdatesBanner: DesignUpdatesBannerFlag; + cloudNat: CloudNatFlag; databaseAdvancedConfig: boolean; databaseBeta: boolean; databaseResize: boolean; @@ -303,3 +303,10 @@ export interface AclpAlertServiceTypeConfig { serviceType: AlertServiceType; // This can be extended to have supportedRegions, supportedFilters and other tags } + +export interface AclpBetaServices { + [serviceType: string]: { + alerts: boolean; + metrics: boolean; + }; +} diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 2530a961e3e..be547db5f52 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -1,5 +1,5 @@ import { useAccount, useProfile } from '@linode/queries'; -import { useMatch, useNavigate } from '@tanstack/react-router'; +import { useLocation, useMatch, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -52,6 +52,7 @@ const MaintenanceLanding = React.lazy( export const AccountLanding = () => { const navigate = useNavigate(); + const location = useLocation(); const match = useMatch({ strict: false, }); @@ -173,8 +174,8 @@ export const AccountLanding = () => { return ( - - + + diff --git a/packages/manager/src/features/Account/GlobalSettings.tsx b/packages/manager/src/features/Account/GlobalSettings.tsx index b2b30602ddc..fb65d666557 100644 --- a/packages/manager/src/features/Account/GlobalSettings.tsx +++ b/packages/manager/src/features/Account/GlobalSettings.tsx @@ -16,9 +16,11 @@ import AutoBackups from './AutoBackups'; import CloseAccountSetting from './CloseAccountSetting'; import { DefaultFirewalls } from './DefaultFirewalls'; import { EnableManaged } from './EnableManaged'; +import { MaintenancePolicy } from './MaintenancePolicy'; import { NetworkHelper } from './NetworkHelper'; import { NetworkInterfaceType } from './NetworkInterfaceType'; import { ObjectStorageSettings } from './ObjectStorageSettings'; +import { useVMHostMaintenanceEnabled } from './utils'; import type { APIError } from '@linode/api-v4'; @@ -32,6 +34,8 @@ const GlobalSettings = () => { } = useAccountSettings(); const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + const { isVMHostMaintenanceEnabled } = useVMHostMaintenanceEnabled(); + const { data: linodes } = useAllLinodesQuery(); const hasLinodesWithoutBackups = @@ -85,6 +89,7 @@ const GlobalSettings = () => {
    + {isVMHostMaintenanceEnabled && } {isLinodeInterfacesEnabled && } {isLinodeInterfacesEnabled && } { {flags.vmHostMaintenance?.enabled ? ( <> - + ) : ( diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx index 28f3d8b1f6a..d51c5035fcf 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx @@ -28,7 +28,7 @@ describe('Maintenance Table Row', () => { it('should render the maintenance event', async () => { const { getByText } = await renderWithThemeAndRouter( wrapWithTableBody( - + ) ); getByText(maintenance.entity.label); @@ -38,7 +38,7 @@ describe('Maintenance Table Row', () => { it('should render a relative time', async () => { await renderWithThemeAndRouter( wrapWithTableBody( - + ) ); const { getByText } = within(screen.getByTestId('relative-date')); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx index cbc1dccf377..3582a309c7f 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx @@ -29,7 +29,7 @@ import { IN_PROGRESS_MAINTENANCE_FILTER, maintenanceDateColumnMap, PENDING_MAINTENANCE_FILTER, - SCHEDULED_MAINTENANCE_FILTER, + UPCOMING_MAINTENANCE_FILTER, } from './utilities'; import type { AccountMaintenance, Filter } from '@linode/api-v4'; @@ -58,15 +58,15 @@ const useStyles = makeStyles()(() => ({ export type MaintenanceTableType = | 'completed' | 'in progress' - | 'pending' // TODO VM & Host Maintenance: Remove pending type after GA - | 'scheduled'; + | 'pending' + | 'upcoming'; interface Props { type: MaintenanceTableType; } export const MaintenanceTable = ({ type }: Props) => { - const csvRef = React.useRef(); + const csvRef = React.useRef(undefined); const { classes } = useStyles(); const formattedDate = useFormattedDate(); const flags = useFlags(); @@ -95,7 +95,7 @@ export const MaintenanceTable = ({ type }: Props) => { const filters: Record = { completed: COMPLETED_MAINTENANCE_FILTER, 'in progress': IN_PROGRESS_MAINTENANCE_FILTER, - scheduled: SCHEDULED_MAINTENANCE_FILTER, + upcoming: UPCOMING_MAINTENANCE_FILTER, pending: PENDING_MAINTENANCE_FILTER, }; @@ -208,7 +208,7 @@ export const MaintenanceTable = ({ type }: Props) => { > Label - {(type === 'scheduled' || type === 'completed') && ( + {(type === 'upcoming' || type === 'completed') && ( { {(!flags.vmHostMaintenance?.enabled || - type === 'scheduled' || + type === 'upcoming' || type === 'completed') && ( { {flags.vmHostMaintenance?.enabled && ( <> - {(tableType === 'scheduled' || tableType === 'completed') && ( + {(tableType === 'upcoming' || tableType === 'completed') && ( {parseAPIDate(when).toRelative()} @@ -146,7 +146,7 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { {getFormattedStatus(type)} {(!flags.vmHostMaintenance?.enabled || - tableType === 'scheduled' || + tableType === 'upcoming' || tableType === 'completed') && ( diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index 93330262c5a..cf2fa3bd09d 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -5,10 +5,10 @@ export const COMPLETED_MAINTENANCE_FILTER = Object.freeze({ }); export const IN_PROGRESS_MAINTENANCE_FILTER = Object.freeze({ - status: { '+or': ['in-progress', 'started'] }, + status: { '+or': ['in_progress', 'started'] }, }); -export const SCHEDULED_MAINTENANCE_FILTER = Object.freeze({ +export const UPCOMING_MAINTENANCE_FILTER = Object.freeze({ status: { '+or': ['pending', 'scheduled'] }, }); @@ -16,6 +16,10 @@ export const PENDING_MAINTENANCE_FILTER = Object.freeze({ status: { '+or': ['pending', 'started', 'scheduled'] }, }); +export const PENDING_AND_IN_PROGRESS_MAINTENANCE_FILTER = Object.freeze({ + status: { '+or': ['pending', 'started', 'scheduled', 'in_progress'] }, +}); + export const PLATFORM_MAINTENANCE_TYPE = 'security_reboot_maintenance_scheduled'; @@ -30,6 +34,6 @@ export const maintenanceDateColumnMap: Record< > = { completed: ['complete_time', 'End Date'], 'in progress': ['start_time', 'Start Date'], - scheduled: ['start_time', 'Start Date'], + upcoming: ['start_time', 'Start Date'], pending: ['when', 'Date'], }; diff --git a/packages/manager/src/features/Account/MaintenancePolicy.test.tsx b/packages/manager/src/features/Account/MaintenancePolicy.test.tsx new file mode 100644 index 00000000000..80838dd9761 --- /dev/null +++ b/packages/manager/src/features/Account/MaintenancePolicy.test.tsx @@ -0,0 +1,53 @@ +import { waitFor } from '@testing-library/react'; +import * as React from 'react'; + +import { accountSettingsFactory } from 'src/factories'; +import { maintenancePolicyFactory } from 'src/factories/maintenancePolicy'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { MaintenancePolicy } from './MaintenancePolicy'; + +describe('MaintenancePolicy', () => { + it('renders the MaintenancePolicy section', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Host Maintenance Policy')).toBeVisible(); + expect(getByText('Maintenance Policy')).toBeVisible(); + expect(getByText('Save Maintenance Policy')).toBeVisible(); + }); + + it("populates select with the account's default maintenance_policy value", async () => { + const policies = [ + maintenancePolicyFactory.build({ + label: 'Power Off / Power On', + slug: 'linode/power_off_on', + }), + maintenancePolicyFactory.build({ + label: 'Migrate', + slug: 'linode/migrate', + }), + ]; + const accountSettings = accountSettingsFactory.build({ + maintenance_policy: 'linode/power_off_on', + }); + + server.use( + http.get('*/maintenance/policies', () => { + return HttpResponse.json(makeResourcePage(policies)); + }), + http.get('*/account/settings', () => { + return HttpResponse.json(accountSettings); + }) + ); + + const { getByLabelText } = renderWithTheme(); + + await waitFor(() => { + expect(getByLabelText('Maintenance Policy')).toHaveDisplayValue( + 'Power Off / Power On' + ); + }); + }); +}); diff --git a/packages/manager/src/features/Account/MaintenancePolicy.tsx b/packages/manager/src/features/Account/MaintenancePolicy.tsx new file mode 100644 index 00000000000..ff84de694eb --- /dev/null +++ b/packages/manager/src/features/Account/MaintenancePolicy.tsx @@ -0,0 +1,92 @@ +import { useAccountSettings, useMutateAccountSettings } from '@linode/queries'; +import { BetaChip, Box, Button, Paper, Stack, Typography } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { Link } from 'src/components/Link'; +import { MaintenancePolicySelect } from 'src/components/MaintenancePolicySelect/MaintenancePolicySelect'; +import { useFlags } from 'src/hooks/useFlags'; + +import type { AccountSettings } from '@linode/api-v4'; + +type MaintenancePolicyValues = Pick; + +export const MaintenancePolicy = () => { + const { enqueueSnackbar } = useSnackbar(); + const { data: accountSettings } = useAccountSettings(); + + const { mutateAsync: updateAccountSettings } = useMutateAccountSettings(); + + const flags = useFlags(); + + const values: MaintenancePolicyValues = { + maintenance_policy: accountSettings?.maintenance_policy ?? 'linode/migrate', + }; + + const { + control, + formState: { isDirty, isSubmitting }, + handleSubmit, + setError, + } = useForm({ + defaultValues: values, + values, + }); + + const onSubmit = async (values: MaintenancePolicyValues) => { + try { + await updateAccountSettings(values); + enqueueSnackbar('Host Maintenance Policy settings updated.', { + variant: 'success', + }); + } catch (error) { + setError('maintenance_policy', { message: error[0].reason }); + } + }; + + return ( + + + Host Maintenance Policy {flags.vmHostMaintenance?.beta && } + +
    + + + Select the preferred default host maintenance policy for newly + deployed Linodes. During host maintenance events (such as host + upgrades), this policy setting determines the type of migration that + is performed. This preference can be changed when creating new + Linodes or modifying existing Linodes.{' '} + + Learn more + + . + + ( + field.onChange(policy.slug)} + value={field.value} + /> + )} + /> + + + + + +
    + ); +}; diff --git a/packages/manager/src/features/Account/NetworkInterfaceType.test.tsx b/packages/manager/src/features/Account/NetworkInterfaceType.test.tsx index e9adec80cf4..2b2b0511bef 100644 --- a/packages/manager/src/features/Account/NetworkInterfaceType.test.tsx +++ b/packages/manager/src/features/Account/NetworkInterfaceType.test.tsx @@ -21,7 +21,7 @@ describe('NetworkInterfaces', () => { }); server.use( - http.get('*/v4/account/settings', () => + http.get('*/v4*/account/settings', () => HttpResponse.json(accountSettings) ) ); diff --git a/packages/manager/src/features/Account/ObjectStorageSettings.test.tsx b/packages/manager/src/features/Account/ObjectStorageSettings.test.tsx index 989f47adfec..eb9c4dc9e70 100644 --- a/packages/manager/src/features/Account/ObjectStorageSettings.test.tsx +++ b/packages/manager/src/features/Account/ObjectStorageSettings.test.tsx @@ -84,7 +84,7 @@ describe('ObjectStorageSettings', () => { }); server.use( - http.get('*/v4/account/settings', () => { + http.get('*/v4*/account/settings', () => { return HttpResponse.json( accountSettingsFactory.build({ object_storage: 'active' }) ); diff --git a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx index f183ef9c01d..3b268ce95d7 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx @@ -1,6 +1,6 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { useCreateSupportTicketMutation, useProfile } from '@linode/queries'; -import { Select } from '@linode/ui'; +import { InputAdornment, Select } from '@linode/ui'; import { Accordion, ActionsPanel, @@ -142,21 +142,22 @@ export const QuotasIncreaseForm = (props: QuotasIncreaseFormProps) => { slotProps={{ input: { endAdornment: ( - ({ - color: theme.tokens.alias.Content.Text, - font: theme.font.bold, - fontSize: theme.tokens.font.FontSize.Xxxs, - mx: 1, - textTransform: 'uppercase', - userSelect: 'none', - whiteSpace: 'nowrap', - })} - > - {convertedResourceMetrics?.metric ?? - quota.resource_metric} - + + ({ + color: theme.tokens.alias.Content.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + textTransform: 'uppercase', + userSelect: 'none', + whiteSpace: 'nowrap', + })} + > + {convertedResourceMetrics?.metric ?? + quota.resource_metric} + + ), }, }} diff --git a/packages/manager/src/features/Account/utils.ts b/packages/manager/src/features/Account/utils.ts index 482022952b4..239bf8cf281 100644 --- a/packages/manager/src/features/Account/utils.ts +++ b/packages/manager/src/features/Account/utils.ts @@ -93,6 +93,23 @@ export const useIsTaxIdEnabled = (): { return { isTaxIdEnabled }; }; +/** + * Hook to determine if the VM Host Maintenance feature should be visible to the user. + * Based on the feature flag. + */ +export const useVMHostMaintenanceEnabled = () => { + const flags = useFlags(); + + if (!flags) { + return { isVMHostMaintenanceEnabled: false }; + } + + const isVMHostMaintenanceEnabled = Boolean(flags.vmHostMaintenance?.enabled); + const isVMHostMaintenanceInBeta = Boolean(flags.vmHostMaintenance?.beta); + + return { isVMHostMaintenanceEnabled, isVMHostMaintenanceInBeta }; +}; + /** * Formats one or more actions into a readable string * @param action - A single action or array of actions diff --git a/packages/manager/src/features/Backups/BackupDrawer.test.tsx b/packages/manager/src/features/Backups/BackupDrawer.test.tsx index be3b2c3e010..f79afbb94b2 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.test.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.test.tsx @@ -150,7 +150,6 @@ describe('BackupDrawer', () => { ); // Confirm that Linodes without backups are listed in table. - /* eslint-disable no-await-in-loop */ for (const mockLinode of mockLinodesWithoutBackups) { expect(await findByText(mockLinode.label)).toBeVisible(); } diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx index a48830919e5..1a17336c0c9 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx @@ -4,6 +4,7 @@ import { useScript } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; +import type { JSX } from 'react'; import { makeStyles } from 'tss-react/mui'; import GooglePayIcon from 'src/assets/icons/payment/gPayButton.svg'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx index d2e0f998c08..5f04fd1252b 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx @@ -10,6 +10,7 @@ import { } from '@paypal/react-paypal-js'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; +import type { JSX } from 'react'; import { makeStyles } from 'tss-react/mui'; import { reportException } from 'src/exceptionReporting'; @@ -128,7 +129,7 @@ export const PayPalButton = (props: Props) => { * Needed to pass dynamic amount to PayPal without re-render * https://github.com/paypal/react-paypal-js/issues/161 */ - const stateRef = React.useRef(); + const stateRef = React.useRef(undefined); const [transaction, setTransaction] = React.useState({ amount: usd, 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 6a538ab6215..7c24ca9b628 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -51,7 +51,7 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { const { data: notifications, refetch } = useNotificationsQuery(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { classes } = useStyles(); - const emailRef = React.useRef(); + const emailRef = React.useRef(undefined); const { data: profile } = useProfile(); const [billingAgreementChecked, setBillingAgreementChecked] = React.useState(false); diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/GooglePayChip.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/GooglePayChip.tsx index d084beadb1a..3451c31879f 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/GooglePayChip.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/GooglePayChip.tsx @@ -3,6 +3,7 @@ import { CircleProgress } from '@linode/ui'; import { useScript } from '@linode/utilities'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; +import type { JSX } from 'react'; import { makeStyles } from 'tss-react/mui'; import GooglePayIcon from 'src/assets/icons/payment/googlePay.svg'; diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx index dd0ef68f59f..9f9597843c7 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx @@ -7,7 +7,7 @@ import { usePayPalScriptReducer, } from '@paypal/react-paypal-js'; import { useSnackbar } from 'notistack'; -import React, { useEffect } from 'react'; +import React, { type JSX, useEffect } from 'react'; import { reportException } from 'src/exceptionReporting'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalErrorBoundary.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalErrorBoundary.tsx index 7ed99986599..6c5b8def173 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalErrorBoundary.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalErrorBoundary.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { reportException } from 'src/exceptionReporting'; diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx index 84c44be79cf..4ab59d75065 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx @@ -31,7 +31,7 @@ export const InvoiceDetail = () => { }); const theme = useTheme(); - const csvRef = React.useRef(); + const csvRef = React.useRef(undefined); const { data: account } = useAccount(); const { data: regions } = useRegionsQuery(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx index dcf39b9f860..17e4eaa71a9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx @@ -3,6 +3,7 @@ import { CircleProgress, Typography } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; import { formatDate } from 'src/utilities/formatDate'; @@ -36,6 +37,7 @@ export const AlertDetailOverview = React.memo((props: OverviewProps) => { } = alertDetails; const { data: serviceTypeList, isFetching } = useCloudPulseServiceTypes(true); + const { aclpBetaServices } = useFlags(); if (isFetching) { return ; @@ -63,6 +65,7 @@ export const AlertDetailOverview = React.memo((props: OverviewProps) => { { status, value, valueGridColumns = 8, + showBetaChip, } = props; const theme = useTheme(); @@ -60,7 +68,9 @@ export const AlertDetailRow = React.memo((props: AlertDetailRowProps) => { status={status} /> )} - {value} + + {value} {showBetaChip && } + ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx index b761bdbd8d6..d1f0be574da 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx @@ -6,7 +6,10 @@ import { alertFactory } from 'src/factories'; import { formatDate } from 'src/utilities/formatDate'; import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; -import { UPDATE_ALERT_SUCCESS_MESSAGE } from '../constants'; +import { + DELETE_ALERT_SUCCESS_MESSAGE, + UPDATE_ALERT_SUCCESS_MESSAGE, +} from '../constants'; import { AlertsListTable } from './AlertListTable'; const queryMocks = vi.hoisted(() => ({ @@ -214,5 +217,60 @@ describe('Alert List Table test', () => { expect(screen.getByText('tag1')).toBeVisible(); expect(screen.getByText('tag2')).toBeVisible(); }); - // TODO: Add tests for the delete alert functionality once API's are available + + it('should show success snackbar when deleting alert succeeds', async () => { + const alert = alertFactory.build({ type: 'user' }); + renderWithThemeAndRouter( + + ); + + const actionMenu = screen.getByLabelText( + `Action menu for Alert ${alert.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(screen.getByText('Delete')); + + expect(screen.getByText(`Delete ${alert.label}?`)).toBeVisible(); + const textInput = screen.getByTestId('textfield-input'); + await userEvent.type(textInput, alert.label); + await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByText(DELETE_ALERT_SUCCESS_MESSAGE)).toBeVisible(); + }); + + it('should show the proper api error message in error snackbar when deleting alert fails with a reason', async () => { + queryMocks.useDeleteAlertDefinitionMutation.mockReturnValue({ + mutateAsync: vi + .fn() + .mockRejectedValue([{ reason: 'Deleting alert failed.' }]), + }); + + const alert = alertFactory.build({ type: 'user' }); + renderWithThemeAndRouter( + + ); + + const actionMenu = screen.getByLabelText( + `Action menu for Alert ${alert.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(screen.getByText('Delete')); + + expect(screen.getByText(`Delete ${alert.label}?`)).toBeVisible(); + const textInput = screen.getByTestId('textfield-input'); + await userEvent.type(textInput, alert.label); + await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByText('Deleting alert failed.')).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx index f2a885a52b9..f03d49a373d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx @@ -27,7 +27,10 @@ import { import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog'; -import { UPDATE_ALERT_SUCCESS_MESSAGE } from '../constants'; +import { + DELETE_ALERT_SUCCESS_MESSAGE, + UPDATE_ALERT_SUCCESS_MESSAGE, +} from '../constants'; import { AlertsTable } from './AlertsTable'; import { AlertListingTableLabelMap } from './constants'; import { GroupedAlertsTable } from './GroupedAlertsTable'; @@ -129,6 +132,7 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { alertId: alert.id, serviceType: alert.service_type, status: toggleStatus, + scope: alert.scope, }) .then(() => { // Handle success @@ -164,7 +168,9 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { deleteAlertDefinition(payload) .then(() => { - enqueueSnackbar('Alert deleted', { variant: 'success' }); + enqueueSnackbar(DELETE_ALERT_SUCCESS_MESSAGE, { + variant: 'success', + }); }) .catch((deleteError: APIError[]) => { const errorResponse = getAPIErrorOrDefault( diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx index 2629659ef2e..2335df8418a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx @@ -1,9 +1,12 @@ import { Autocomplete, + BetaChip, Box, Button, Notice, + SelectedIcon, Stack, + StyledListItem, Typography, } from '@linode/ui'; import { useNavigate } from '@tanstack/react-router'; @@ -13,6 +16,7 @@ import AlertsIcon from 'src/assets/icons/entityIcons/alerts.svg'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { SupportLink } from 'src/components/SupportLink'; +import { useFlags } from 'src/hooks/useFlags'; import { useAllAlertDefinitionsQuery } from 'src/queries/cloudpulse/alerts'; import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; @@ -52,7 +56,7 @@ export const AlertListing = () => { error: serviceTypesError, isLoading: serviceTypesLoading, } = useCloudPulseServiceTypes(true); - + const { aclpBetaServices } = useFlags(); const userAlerts = alerts?.filter(({ type }) => type === 'user') ?? []; const isAlertLimitReached = userAlerts.length >= maxAllowedAlerts; @@ -237,6 +241,20 @@ export const AlertListing = () => { }} options={getServicesList} placeholder={serviceFilters.length > 0 ? '' : 'Select a Service'} + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + const ListItem = + key === 'Select All ' || key === 'Deselect All ' + ? StyledListItem + : 'li'; + return ( + + {option.label}{' '} + {aclpBetaServices?.[option.value]?.alerts && } + + + ); + }} sx={{ width: searchAndSelectSx, }} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx index 1fb08ddc4d9..9ead55baa8c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx @@ -1,11 +1,12 @@ import { useProfile } from '@linode/queries'; -import { Box } from '@linode/ui'; +import { BetaChip, Box } from '@linode/ui'; import * as React from 'react'; 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 { useFlags } from 'src/hooks/useFlags'; import { formatDate } from 'src/utilities/formatDate'; import { alertStatuses, alertStatusToIconStatusMap } from '../constants'; @@ -44,6 +45,8 @@ export const AlertTableRow = (props: Props) => { updated_by, } = alert; + const { aclpBetaServices } = useFlags(); + return ( @@ -64,7 +67,8 @@ export const AlertTableRow = (props: Props) => { - {services.find((service) => service.value === service_type)?.label} + {services.find((service) => service.value === service_type)?.label}{' '} + {aclpBetaServices?.[service_type]?.alerts && } {created_by} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/GroupedAlertsTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/GroupedAlertsTable.tsx index 499ed831e6c..5d42c8b9e8b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/GroupedAlertsTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/GroupedAlertsTable.tsx @@ -55,7 +55,7 @@ export const GroupedAlertsTable = ({ const theme = useTheme(); // Create a Map to store refs for each tag const tagRefs = React.useRef< - Map> + Map> >(new Map(groupedAlerts.map(([tag]) => [tag, React.createRef()]))); const scrollToTagWithAnimation = React.useCallback( diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx index 8441a05bed0..d3c5481cccb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -21,7 +21,12 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog'; import { AlertInformationActionRow } from './AlertInformationActionRow'; -import type { Alert, APIError, EntityAlertUpdatePayload } from '@linode/api-v4'; +import type { + Alert, + APIError, + CloudPulseAlertsPayload, + EntityAlertUpdatePayload, +} from '@linode/api-v4'; export interface AlertInformationActionTableProps { /** @@ -36,19 +41,28 @@ export interface AlertInformationActionTableProps { /** * Id of the selected entity + * Only use in edit flow */ - entityId: string; + entityId?: string; /** * Name of the selected entity + * Only use in edit flow */ - entityName: string; + entityName?: string; /** * Error received from API */ error?: APIError[] | null; + /** + * Called when an alert is toggled on or off. + * Only use in create flow. + * @param payload enabled alerts ids + */ + onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; + /** * Column name by which columns will be ordered by default */ @@ -67,10 +81,37 @@ export interface TableColumnHeader { label: string; } +export interface AlertRowPropsOptions { + /** + * Enabled alerts payload + */ + enabledAlerts: CloudPulseAlertsPayload; + + /** + * Id of the entity + * Only use in edit flow. + */ + entityId?: string; + + /** + * Callback function to handle alert toggle + * Only use in create flow. + */ + onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; +} + export const AlertInformationActionTable = ( props: AlertInformationActionTableProps ) => { - const { alerts, columns, entityId, entityName, error, orderByColumn } = props; + const { + alerts, + columns, + entityId, + entityName, + error, + orderByColumn, + onToggleAlert, + } = props; const _error = error ? getAPIErrorOrDefault(error, 'Error while fetching the alerts') @@ -79,16 +120,43 @@ export const AlertInformationActionTable = ( const [selectedAlert, setSelectedAlert] = React.useState({} as Alert); const [isDialogOpen, setIsDialogOpen] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); + const [enabledAlerts, setEnabledAlerts] = + React.useState({ + system: [], + user: [], + }); const { mutateAsync: addEntity } = useAddEntityToAlert(); const { mutateAsync: removeEntity } = useRemoveEntityFromAlert(); + const getAlertRowProps = (alert: Alert, options: AlertRowPropsOptions) => { + const { entityId, enabledAlerts, onToggleAlert } = options; + + // Ensure that at least one of entityId or onToggleAlert is provided + if (!(entityId || onToggleAlert)) { + return null; + } + + const isEditMode = !!entityId; + + const handleToggle = isEditMode + ? handleToggleEditFlow + : handleToggleCreateFlow; + const status = isEditMode + ? alert.entity_ids.includes(entityId) + : enabledAlerts[alert.type].includes(alert.id); + + return { handleToggle, status }; + }; + const handleCancel = () => { setIsDialogOpen(false); }; const handleConfirm = React.useCallback( (alert: Alert, currentStatus: boolean) => { + if (entityId === undefined) return; + const payload: EntityAlertUpdatePayload = { alert, entityId, @@ -117,12 +185,31 @@ export const AlertInformationActionTable = ( }, [addEntity, enqueueSnackbar, entityId, entityName, removeEntity] ); - const handleToggle = (alert: Alert) => { + + const handleToggleEditFlow = (alert: Alert) => { setIsDialogOpen(true); setSelectedAlert(alert); }; - const isEnabled = selectedAlert.entity_ids?.includes(entityId) ?? false; + const handleToggleCreateFlow = (alert: Alert) => { + if (!onToggleAlert) return; + + setEnabledAlerts((prev: CloudPulseAlertsPayload) => { + const newPayload = { ...prev }; + const index = newPayload[alert.type].indexOf(alert.id); + // If the alert is already in the payload, remove it, otherwise add it + if (index !== -1) { + newPayload[alert.type].splice(index, 1); + } else { + newPayload[alert.type].push(alert.id); + } + + onToggleAlert(newPayload); + return newPayload; + }); + }; + + const isEnabled = selectedAlert.entity_ids?.includes(entityId ?? '') ?? false; return ( <> @@ -170,14 +257,24 @@ export const AlertInformationActionTable = ( length={paginatedAndOrderedAlerts.length} loading={false} /> - {paginatedAndOrderedAlerts?.map((alert) => ( - - ))} + {paginatedAndOrderedAlerts?.map((alert) => { + const rowProps = getAlertRowProps(alert, { + enabledAlerts, + entityId, + onToggleAlert, + }); + + if (!rowProps) return null; + + return ( + + ); + })}
    diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx index 8ab9de54e80..68b7f1c65cc 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx @@ -23,18 +23,28 @@ import { } from '../Utils/utils'; import { AlertInformationActionTable } from './AlertInformationActionTable'; -import type { AlertDefinitionType } from '@linode/api-v4'; +import type { + AlertDefinitionType, + CloudPulseAlertsPayload, +} from '@linode/api-v4'; interface AlertReusableComponentProps { /** * Id for the selected entity */ - entityId: string; + entityId?: string; /** * Name of the selected entity */ - entityName: string; + entityName?: string; + + /** + * Called when an alert is toggled on or off. + * Only use in create flow. + * @param payload enabled alerts ids + */ + onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; /** * Service type of selected entity @@ -43,7 +53,7 @@ interface AlertReusableComponentProps { } export const AlertReusableComponent = (props: AlertReusableComponentProps) => { - const { entityId, entityName, serviceType } = props; + const { entityId, entityName, onToggleAlert, serviceType } = props; const { data: alerts, error, @@ -70,6 +80,7 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { if (isLoading) { return ; } + return ( @@ -125,6 +136,7 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { entityId={entityId} entityName={entityName} error={error} + onToggleAlert={onToggleAlert} orderByColumn="Alert Name" /> diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index fb4d93cf633..b8ce5ad2214 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -62,6 +62,7 @@ const initialValues: CreateAlertDefinitionForm = { severity: null, tags: [''], trigger_conditions: triggerConditionInitialValues, + scope: 'entity', }; const overrides = [ 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 2b30ccb746d..82081005479 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -212,7 +212,7 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { ) } /> - + 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 b638c61691c..87bb45c8360 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -200,7 +200,7 @@ export const Metric = (props: MetricCriteriaProps) => { }} options={aggOptions} placeholder="Select an Aggregation Type" - sx={{ paddingTop: { sm: 1, xs: 0 } }} + sx={{ paddingTop: { sm: 0.5, xs: 0 } }} value={ aggOptions.find((option) => option.value === field.value) ?? null @@ -234,7 +234,7 @@ export const Metric = (props: MetricCriteriaProps) => { }} options={metricOperatorOptions} placeholder="Select an Operator" - sx={{ paddingTop: { sm: 1, xs: 0 } }} + sx={{ paddingTop: { sm: 0.5, xs: 0 } }} value={ field.value !== null ? metricOperatorOptions.find( @@ -254,7 +254,7 @@ export const Metric = (props: MetricCriteriaProps) => { render={({ field, fieldState }) => ( { alignItems: 'flex-end', display: 'flex', height: '56px', - marginTop: { lg: '5px', md: '5px', sm: '5px' }, + marginTop: { lg: '1px', md: '1px', sm: '1px' }, }} variant="body1" > 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 de97d4bc536..495be972fc6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx @@ -85,7 +85,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { ); }} options={getEvaluationPeriodOptions()} - placeholder="Select an Evaluation period" + placeholder="Select an Evaluation Period" 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.', @@ -149,7 +149,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { xs={12} > { } sx={{ height: '34px', - marginTop: { sm: '16px', xs: '0px' }, + marginTop: { sm: '12px', xs: '0px' }, width: '100px', }} type="number" @@ -191,7 +191,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { /> ({ + useCloudPulseServiceByServiceType: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/services.ts', async (importOriginal) => ({ + ...(await importOriginal()), + useCloudPulseServiceByServiceType: + queryMock.useCloudPulseServiceByServiceType, +})); + +describe('AlertEntityGroupingSelect', () => { + it('should render the component', () => { + queryMock.useCloudPulseServiceByServiceType.mockReturnValue({ + isLoading: false, + data: serviceTypesFactory.build(), + }); + renderWithThemeAndHookFormContext({ + component: , + }); + + expect(screen.getByTestId('entity-grouping')).toBeInTheDocument(); + expect(screen.getByLabelText('Scope')).toBeInTheDocument(); + }); + + it('Select option from drop down', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click(screen.getByRole('option', { name: 'Account' })); + + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Account'); + }); + + it('should disable options that are not available for the service type', async () => { + queryMock.useCloudPulseServiceByServiceType.mockReturnValue({ + isLoading: false, + data: serviceTypesFactory.build({ + alert: serviceAlertFactory.build({ + scope: ['entity'], + }), + }), + }); + + renderWithThemeAndHookFormContext({ + component: , + }); + + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Entity'); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + + expect(screen.getByRole('option', { name: 'Account' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx new file mode 100644 index 00000000000..5938b7ebdda --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx @@ -0,0 +1,133 @@ +import { Autocomplete, Box, ListItem, SelectedIcon } from '@linode/ui'; +import React from 'react'; +import { + Controller, + type FieldPathByValue, + useFormContext, +} from 'react-hook-form'; + +import { useCloudPulseServiceByServiceType } from 'src/queries/cloudpulse/services'; + +import { + ALERT_SCOPE_TOOLTIP_TEXT, + entityGroupingOptions, +} from '../../constants'; + +import type { AlertFormMode } from '../../constants'; +import type { CreateAlertDefinitionForm } from '../types'; +import type { AlertDefinitionScope, AlertServiceType } from '@linode/api-v4'; +interface ScopeOption { + disabled: boolean; + label: string; + value: AlertDefinitionScope; +} +interface AlertEntityScopeSelectProps { + formMode?: AlertFormMode; + name: FieldPathByValue< + CreateAlertDefinitionForm, + AlertDefinitionScope | null | undefined + >; + serviceType: AlertServiceType | null; +} +export const AlertEntityScopeSelect = (props: AlertEntityScopeSelectProps) => { + const { name, serviceType, formMode = 'create' } = props; + const { isLoading, data } = useCloudPulseServiceByServiceType( + serviceType ?? '', + serviceType !== null + ); + const { control, setValue } = useFormContext(); + + const options: ScopeOption[] = React.useMemo(() => { + const scopes = data?.alert?.scope ?? []; + return scopes.length === 0 + ? [] + : entityGroupingOptions.map((option) => ({ + ...option, + disabled: !scopes.includes(option.value), + })); + }, [data?.alert?.scope]); + + const getSelectedOption = ( + value: null | string, + options: ScopeOption[] + ): null | ScopeOption => { + if (options.length === 0) { + return null; + } + + let selectedOption = + options.find((option) => option.value === value) ?? null; + + if (!selectedOption || selectedOption.disabled) { + selectedOption = options.find((o) => !o.disabled) ?? null; + } + + return selectedOption; + }; + + React.useEffect(() => { + if (formMode === 'create') { + if (options.length === 0) { + setValue(name, null); + return; + } + const selectedOption = getSelectedOption(null, options); + + // Update the form value only if we're in create mode + setValue(name, selectedOption?.value ?? null); + } + }, [formMode, name, options, setValue]); + + return ( + { + return ( + option.label} + label="Scope" + loading={isLoading} + onBlur={field.onBlur} + onChange={(_, selectedValue) => { + const value = selectedValue?.value; + field.onChange(value); + + /* + TODO: This will be uncommented in future PRs when regions support is added. + + setValue('regions', value === 'region' ? [] : undefined); + setValue('entity_ids', value === 'entity' ? [] : undefined); + */ + }} + options={options} + placeholder="Select a scope" + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( + + {option.label} + + + ); + }} + size="medium" + textFieldProps={{ + labelTooltipText: ALERT_SCOPE_TOOLTIP_TEXT, + }} + value={getSelectedOption(field?.value ?? null, options)} + /> + ); + }} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx index 693ed752c5b..3f65cef9da4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx @@ -1,8 +1,15 @@ -import { Autocomplete } from '@linode/ui'; +import { + Autocomplete, + BetaChip, + Box, + ListItem, + SelectedIcon, +} from '@linode/ui'; import * as React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import type { FieldPathByValue } from 'react-hook-form'; +import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; import type { Item } from '../../constants'; @@ -35,7 +42,7 @@ export const CloudPulseServiceSelect = ( isLoading: serviceTypesLoading, } = useCloudPulseServiceTypes(true); const { control } = useFormContext(); - + const { aclpBetaServices } = useFlags(); const getServicesList = React.useMemo((): Item< string, AlertServiceType @@ -81,6 +88,16 @@ export const CloudPulseServiceSelect = ( }} options={getServicesList} placeholder="Select a Service" + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( + + {option.label}{' '} + {aclpBetaServices?.[option.value]?.alerts && } + + + ); + }} sx={{ marginTop: '5px' }} value={ getServicesList.find((option) => option.value === field.value) ?? diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index e304abd1788..128d838f1d8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -1,4 +1,5 @@ import type { + AlertDefinitionScope, AlertServiceType, AlertSeverityType, ChannelType, @@ -20,6 +21,7 @@ export interface CreateAlertDefinitionForm rule_criteria: { rules: MetricCriteriaForm[]; }; + scope?: AlertDefinitionScope | null; serviceType: AlertServiceType | null; severity: AlertSeverityType | null; trigger_conditions: TriggerConditionForm; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index d65dd539ed5..8f110986b29 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -7,6 +7,7 @@ import type { TriggerConditionForm, } from './types'; import type { + AlertDefinitionScope, AlertServiceType, AlertSeverityType, CreateAlertDefinitionPayload, @@ -50,7 +51,8 @@ export const filterEditFormValues = ( formValues: CreateAlertDefinitionForm, serviceType: AlertServiceType, severity: AlertSeverityType, - alertId: number + alertId: number, + scope: AlertDefinitionScope ): EditAlertPayloadWithService => { const values = omitProps(formValues, [ 'serviceType', @@ -69,6 +71,7 @@ export const filterEditFormValues = ( serviceType, severity: formValues.severity ?? severity, trigger_conditions: filterTriggerConditionFormValues(triggerConditions), + scope, }; }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx index f73dbb4001c..770f4d261bd 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx @@ -86,7 +86,8 @@ export const EditAlertDefinition = (props: EditAlertProps) => { values, serviceType, alertDetails.severity, - alertId + alertId, + alertDetails.scope ); try { await editAlert(editPayload); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx index 6ac187211d7..f6c113a3b18 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx @@ -57,6 +57,7 @@ export const EditAlertResources = (props: EditAlertProps) => { alertId, entity_ids: selectedResources, serviceType, + scope: alertDetails.scope, }) .then(() => { setShowConfirmation(false); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts index b7bb47bc5f2..625408c0512 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts @@ -44,9 +44,7 @@ export const getAlertTypeToActionsList = ( title: getTitleForStatusChange(alertStatus), }, { - disabled: - /* Hardcoding it to be disabled for now as the API's are not ready yet, once they're available will remove the true. */ - alertStatus === 'in progress' || alertStatus === 'failed' || true, + disabled: alertStatus === 'in progress' || alertStatus === 'failed', onClick: handleDelete, title: 'Delete', }, diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index a4ba3c4a89c..b8bfc49a648 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -66,6 +66,7 @@ it('should correctly convert an alert definition values to the required format', severity, tags, trigger_conditions, + scope, } = alert; const expected: EditAlertPayloadWithService = { alertId: id, @@ -84,6 +85,7 @@ it('should correctly convert an alert definition values to the required format', severity, tags, trigger_conditions, + scope, }; expect(convertAlertDefinitionValues(alert, serviceType)).toEqual(expected); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 184794a4403..f09c304a3fa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -256,10 +256,12 @@ export const convertAlertDefinitionValues = ( severity, tags, trigger_conditions, + scope, }: Alert, serviceType: AlertServiceType ): EditAlertPayloadWithService => { return { + scope, alertId: id, channel_ids: alert_channels.map((channel) => channel.id), description: description || undefined, diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index ffa9f23e43b..ee9a4cc3b61 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -2,6 +2,7 @@ import type { FieldPath } from 'react-hook-form'; import type { CreateAlertDefinitionForm } from './CreateAlert/types'; import type { + AlertDefinitionScope, AlertSeverityType, AlertStatusType, ChannelType, @@ -123,6 +124,12 @@ export const pollingIntervalOptions = { ], }; +export const entityGroupingOptions: Item[] = [ + { label: 'Account', value: 'account' }, + { label: 'Region', value: 'region' }, + { label: 'Entity', value: 'entity' }, +]; + export const severityMap: Record = { 0: 'Severe', 1: 'Medium', @@ -202,3 +209,10 @@ export const CREATE_ALERT_SUCCESS_MESSAGE = export const UPDATE_ALERT_SUCCESS_MESSAGE = 'Alert successfully updated. It may take a few minutes for your changes to take effect.'; + +export const ALERT_SCOPE_TOOLTIP_TEXT = + 'The set of entities to which the alert applies: account-wide, specific regions, or individual entities.'; + +export type AlertFormMode = 'create' | 'edit' | 'view'; + +export const DELETE_ALERT_SUCCESS_MESSAGE = 'Alert successfully deleted.'; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx index 0fb683e1f13..75b0a314f59 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent } from '@testing-library/react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { dashboardFactory } from 'src/factories'; @@ -33,6 +34,7 @@ vi.mock('src/queries/cloudpulse/dashboards', async () => { }); const mockDashboard = dashboardFactory.build(); +const message = 'Select a dashboard and apply filters to visualize metrics.'; queryMocks.useCloudPulseDashboardsQuery.mockReturnValue({ data: { data: mockDashboard, @@ -48,33 +50,34 @@ vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ }); describe('CloudPulseDashboardFilterBuilder component tests', () => { it('should render error placeholder if dashboard not selected', () => { - const screen = renderWithTheme(); - - expect(screen.getByText('metrics')).toBeInTheDocument(); + renderWithTheme(); + const text = screen.getByText('metrics'); + expect(text).toBeInTheDocument(); expect(screen.getByPlaceholderText(selectDashboardLabel)).toHaveAttribute( 'value', '' ); - expect( - screen.getByText('Select a dashboard and filters to visualize metrics.') - ).toBeDefined(); + const messageComponent = screen.getByText(message); + expect(messageComponent).toBeDefined(); }); - it('should render error placeholder if some dashboard is selected and filter config is not present', () => { - const screen = renderWithTheme(); + it('should render error placeholder if some dashboard is selected and filter config is not present', async () => { + renderWithTheme(); - fireEvent.change(screen.getByPlaceholderText(selectDashboardLabel), { - target: { value: 'a' }, - }); + await userEvent.type( + screen.getByPlaceholderText(selectDashboardLabel), + 'a' + ); - expect( - screen.getByRole('option', { name: dashboardLabel }) - ).toBeInTheDocument(); + const option = screen.getByRole('option', { + name: dashboardLabel, + }); + expect(option).toBeInTheDocument(); }); - it('should render error placeholder if some dashboard is select and filters are not selected', () => { + it('should render error placeholder if some dashboard is select and filters are not selected', async () => { queryMocks.useCloudPulseDashboardsQuery.mockReturnValue({ data: { data: dashboardFactory.buildList(1, { @@ -86,26 +89,26 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { isLoading: false, }); - const screen = renderWithTheme(); + renderWithTheme(); - fireEvent.change(screen.getByPlaceholderText(selectDashboardLabel), { - target: { value: 'a' }, - }); + await userEvent.type( + screen.getByPlaceholderText(selectDashboardLabel), + 'a' + ); - expect( - screen.getByRole('option', { name: dashboardLabel }) - ).toBeInTheDocument(); + const option = screen.getByRole('option', { + name: dashboardLabel, + }); + expect(option).toBeInTheDocument(); - fireEvent.click(screen.getByRole('option', { name: dashboardLabel })); + await userEvent.click(screen.getByRole('option', { name: dashboardLabel })); expect(screen.getByPlaceholderText(selectDashboardLabel)).toHaveAttribute( // check if dashboard is selected already 'value', dashboardLabel ); - - expect( - screen.getByText('Select a dashboard and filters to visualize metrics.') - ).toBeDefined(); + const messageComponent = screen.getByText(message); + expect(messageComponent).toBeDefined(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx index 50cd88ad7c0..05b3620c3d4 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx @@ -16,7 +16,7 @@ export const CloudPulseDashboardRenderer = React.memo( const { dashboard, filterValue, timeDuration } = props; const selectDashboardAndFilterMessage = - 'Select a dashboard and filters to visualize metrics.'; + 'Select a dashboard and apply filters to visualize metrics.'; const getMetricsCall = React.useMemo( () => getMetricsCallCustomFilters(filterValue, dashboard?.service_type), diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index 9eeb3ded5bf..116e7177e7c 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -31,3 +31,8 @@ export const RELATIVE_TIME_DURATION = 'relative_time_duration'; export const RESOURCE_ID = 'resource_id'; export const WIDGETS = 'widgets'; + +export const NO_REGION_MESSAGE: Record = { + dbaas: 'No database clusters configured in any regions.', + linode: 'No linodes configured in any regions.', +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 0d44b5915b4..396ea994814 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -8,7 +8,7 @@ import type { APIError, Dashboard, ResourcePage, - ServiceTypes, + Service, ServiceTypesList, TimeDuration, } from '@linode/api-v4'; @@ -128,7 +128,7 @@ export const formattedServiceTypes = ( if (rawServiceTypes === undefined || rawServiceTypes.data.length === 0) { return []; } - return rawServiceTypes.data.map((obj: ServiceTypes) => obj.service_type); + return rawServiceTypes.data.map((obj: Service) => obj.service_type); }; /** diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index efaf47be03b..2955cfb9062 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -1,6 +1,7 @@ -import { Autocomplete, Box, Typography } from '@linode/ui'; +import { Autocomplete, BetaChip, Box, Typography } from '@linode/ui'; import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseDashboardsQuery } from 'src/queries/cloudpulse/dashboards'; import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; @@ -47,6 +48,7 @@ export const CloudPulseDashboardSelect = React.memo( isLoading: serviceTypesLoading, } = useCloudPulseServiceTypes(true); + const { aclpBetaServices } = useFlags(); const serviceTypes: string[] = formattedServiceTypes(serviceTypesList); const serviceTypeMap: Map = new Map( serviceTypesList?.data.map((item) => [item.service_type, item.label]) @@ -124,7 +126,8 @@ export const CloudPulseDashboardSelect = React.memo( renderGroup={(params) => ( - {serviceTypeMap.get(params.group) || params.group} + {serviceTypeMap.get(params.group) || params.group}{' '} + {aclpBetaServices?.[params.group]?.metrics && } {params.children} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 2bb38e1be50..6ce69d60c5c 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -6,6 +6,7 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { NO_REGION_MESSAGE } from '../Utils/constants'; import { deepEqual } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; @@ -145,6 +146,10 @@ export const CloudPulseRegionSelect = React.memo( label={label || 'Region'} loading={isLoading || isResourcesLoading} noMarginTop + noOptionsText={ + NO_REGION_MESSAGE[selectedDashboard?.service_type ?? ''] ?? + 'No Regions Available.' + } onChange={(_, region) => { setSelectedRegion(region?.id ?? ''); handleRegionChange( diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index f13656e96f1..199b175c046 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -288,11 +288,9 @@ describe('CloudPulseResourcesSelect component tests', () => { // Select the first 10 resources for (let i = 0; i < 10; i++) { - // eslint-disable-next-line no-await-in-loop const option = await screen.findByRole('option', { name: mockLinodes[i].label, }); - // eslint-disable-next-line no-await-in-loop await user.click(option); } diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx index 54c1a159a60..4385caa656d 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, Box } from '@linode/ui'; +import { Autocomplete, Box, InputAdornment } from '@linode/ui'; import Grid from '@mui/material/Grid'; import React from 'react'; @@ -77,11 +77,11 @@ export const DatabaseEngineSelect = (props: Props) => { textFieldProps={{ InputProps: { startAdornment: ( - - {selectedEngine?.flag} - + + + {selectedEngine?.flag} + + ), }, }} diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx index 24ec3b1226f..abaf0600d9b 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx @@ -65,7 +65,7 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { (subnet) => subnet.id === privateNetworkValues.subnet_id ); - const prevRegionId = React.useRef(); + const prevRegionId = React.useRef(undefined); const regionHasVPCs = Boolean(vpcs && vpcs.length > 0); const disableVPCSelectors = !!vpcsError || !regionSupportsVPCs || !regionHasVPCs; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx index 9a83da7008a..f12cc4f5176 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx @@ -1,5 +1,6 @@ import { ActionsPanel, Button, Notice, Typography } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { makeStyles } from 'tss-react/mui'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx index 378335558c6..e7377bb17e1 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx @@ -249,7 +249,7 @@ export const DomainRecords = (props: Props) => { {state.types.map((type, eachTypeIdx) => { - const ref: React.RefObject = React.createRef(); + const ref: React.RefObject = React.createRef(); return (
    diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateDrawerTypes.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateDrawerTypes.tsx index 2738c4a33c8..6c4c1360ca3 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateDrawerTypes.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateDrawerTypes.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { Controller } from 'react-hook-form'; import type { Control } from 'react-hook-form'; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx index 303c2a34b64..dc6a3a1b502 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx @@ -1,6 +1,6 @@ import { Button } from '@linode/ui'; import { truncateEnd } from '@linode/utilities'; -import React from 'react'; +import React, { type JSX } from 'react'; import { DomainRecordActionMenu } from './DomainRecordActionMenu'; import { getNSRecords, getTimeColumn, typeEq } from './DomainRecordsUtils'; diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferTable.tsx index 73490e5b5d1..27483ff8003 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferTable.tsx @@ -1,5 +1,6 @@ import { Checkbox } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; diff --git a/packages/manager/src/features/ErrorBoundary/ErrorBoundaryFallback.test.tsx b/packages/manager/src/features/ErrorBoundary/ErrorBoundaryFallback.test.tsx index dd528b9be54..8cae6318a30 100644 --- a/packages/manager/src/features/ErrorBoundary/ErrorBoundaryFallback.test.tsx +++ b/packages/manager/src/features/ErrorBoundary/ErrorBoundaryFallback.test.tsx @@ -34,7 +34,7 @@ describe('ErrorBoundaryFallback', () => { screen.getByText('Clearing cache and cookies in a browser'); screen.getByText('Akamai Compute Support'); - expect(consoleSpy).toHaveBeenCalledTimes(3); + expect(consoleSpy).toHaveBeenCalledTimes(1); const refreshButton = screen.getByText('Refresh application'); const reloadButton = screen.getByText('Reload page'); diff --git a/packages/manager/src/features/Events/EventsMessages.stories.tsx b/packages/manager/src/features/Events/EventsMessages.stories.tsx index 0cedf290799..8213410ca3f 100644 --- a/packages/manager/src/features/Events/EventsMessages.stories.tsx +++ b/packages/manager/src/features/Events/EventsMessages.stories.tsx @@ -11,7 +11,7 @@ import { eventMessages } from 'src/features/Events/factory'; import type { EventMessage } from './types'; import type { Event } from '@linode/api-v4/lib/account'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const event: Event = eventFactory.build({ action: 'linode_boot', diff --git a/packages/manager/src/features/Events/FormattedEventMessage.tsx b/packages/manager/src/features/Events/FormattedEventMessage.tsx index 21c6c3d2d79..688b57314f7 100644 --- a/packages/manager/src/features/Events/FormattedEventMessage.tsx +++ b/packages/manager/src/features/Events/FormattedEventMessage.tsx @@ -1,5 +1,6 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { SupportLink } from 'src/components/SupportLink'; diff --git a/packages/manager/src/features/Events/asyncToasts.tsx b/packages/manager/src/features/Events/asyncToasts.tsx index 41e5133d13c..09f07857b92 100644 --- a/packages/manager/src/features/Events/asyncToasts.tsx +++ b/packages/manager/src/features/Events/asyncToasts.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { getEventMessage } from './utils'; import type { Event, EventAction } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Events/factory.tsx b/packages/manager/src/features/Events/factory.tsx index 22f3bec4643..c14d1d705ec 100644 --- a/packages/manager/src/features/Events/factory.tsx +++ b/packages/manager/src/features/Events/factory.tsx @@ -1,5 +1,6 @@ import { Typography } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import * as factories from './factories'; diff --git a/packages/manager/src/features/Events/types.ts b/packages/manager/src/features/Events/types.ts index 528b5212e4d..a43c761d069 100644 --- a/packages/manager/src/features/Events/types.ts +++ b/packages/manager/src/features/Events/types.ts @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import type { Event, EventAction, EventStatus } from '@linode/api-v4'; type PrefixByUnderscore = T extends `${infer s}_${string}` ? s : never; diff --git a/packages/manager/src/features/Events/utils.tsx b/packages/manager/src/features/Events/utils.tsx index 4995d66c699..4c6c2961752 100644 --- a/packages/manager/src/features/Events/utils.tsx +++ b/packages/manager/src/features/Events/utils.tsx @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import { formatDuration } from '@linode/utilities'; import { Duration } from 'luxon'; +import type { JSX } from 'react'; import { ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS } from 'src/features/Events/constants'; import { isInProgressEvent } from 'src/queries/events/event.helpers'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx index c1977439cbb..6e7039fcb4d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx @@ -58,6 +58,33 @@ describe('FirewallDeviceRow', () => { expect(getByText('Remove')).toBeVisible(); }); + it('shows the Linode label for an interface device', () => { + const device = firewallDeviceFactory.build({ + entity: { + id: 10, + label: null, + type: 'linode_interface' as FirewallDeviceEntityType, + url: '/linodes/11/interfaces/10', + parent_entity: { + id: 11, + label: 'test-linode-label', + type: 'linode' as FirewallDeviceEntityType, + url: '/linodes/11', + parent_entity: null, + }, + }, + }); + + const { getByText } = renderWithTheme( + , + { + flags: { linodeInterfaces: { enabled: false } }, + } + ); + + expect(getByText('test-linode-label')).toBeVisible(); + }); + it('does not show the network interface type for nodebalancer devices', () => { const nodeBalancerEntity = firewallDeviceFactory.build({ entity: { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx index 2c64747771d..cfee62c9155 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; -import { Skeleton } from 'src/components/Skeleton'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; +import { getDeviceLinkAndLabel } from '../../FirewallLanding/FirewallRow'; import { FirewallDeviceActionMenu } from './FirewallDeviceActionMenu'; import type { FirewallDeviceActionMenuProps } from './FirewallDeviceActionMenu'; @@ -16,31 +16,20 @@ interface FirewallDeviceRowProps extends FirewallDeviceActionMenuProps { export const FirewallDeviceRow = React.memo((props: FirewallDeviceRowProps) => { const { device, isLinodeRelatedDevice } = props; - const { id, label, type, url } = device.entity; + const { id, type } = device.entity; const isInterfaceDevice = type === 'linode_interface'; - // for Linode Interfaces, the url comes in as '/v4/linode/instances/:linodeId/interfaces/:interfaceId - // we need the Linode ID to create a link - const entityId = isInterfaceDevice ? Number(url.split('/')[4]) : id; const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); - const link = isInterfaceDevice - ? `/linodes/${entityId}/networking/interfaces/${id}` - : `/${type}s/${id}/${type === 'linode' ? 'networking' : 'summary'}`; + const { entityLabel, entityLink } = getDeviceLinkAndLabel(device.entity); return ( - {/* The only time a firewall device's label comes in as null is for Linode Interface devices. This label won't stay null - we do some - processing to give the interface device its associated Linode's label. However, processing may take time, so we show a loading indicator first */} - {isInterfaceDevice && !label ? ( - - ) : ( - - {label} - - )} + + {entityLabel} + {isLinodeInterfacesEnabled && isLinodeRelatedDevice && ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx index 505ce89a5e3..d50da4f0edc 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx @@ -1,7 +1,4 @@ -import { - useAllFirewallDevicesQuery, - useAllLinodesQuery, -} from '@linode/queries'; +import { useAllFirewallDevicesQuery } from '@linode/queries'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -17,7 +14,6 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; -import { getLinodeIdFromInterfaceDevice } from '../../shared'; import { formattedTypes } from './constants'; import { FirewallDeviceRow } from './FirewallDeviceRow'; @@ -46,38 +42,21 @@ export const FirewallDeviceTable = React.memo( const devices = allDevices?.filter((device) => type === 'linode' && isLinodeInterfacesEnabled - ? device.entity.type !== 'nodebalancer' // include entities with type 'interface' in Linode table + ? device.entity.type !== 'nodebalancer' // include entities with type 'linode_interface' in Linode table : device.entity.type === type ) || []; - const linodeInterfaceDevices = - type === 'linode' - ? allDevices?.filter( - (device) => device.entity.type === 'linode_interface' - ) - : []; - - // only fire this query if we have linode interface devices. We fetch the Linodes those devices are attached to - // so that we can add a label to the devices for sorting and display purposes - const { data: linodesWithInterfaces } = useAllLinodesQuery( - {}, - {}, - isLinodeInterfacesEnabled && - linodeInterfaceDevices && - linodeInterfaceDevices.length > 0 - ); - - const updatedDevices = devices.map((device) => { - if (device.entity.type === 'linode_interface') { - const linodeId = getLinodeIdFromInterfaceDevice(device.entity); - const associatedLinode = linodesWithInterfaces?.find( - (linode) => linode.id === linodeId - ); + const devicesWithEntityLabels = devices.map((device) => { + // Linode Interface devices don't have a label, so we need to use their parent entity's label for sorting purposes + if ( + device.entity.type === 'linode_interface' && + device.entity.parent_entity + ) { return { ...device, entity: { ...device.entity, - label: associatedLinode?.label ?? null, + label: device.entity.parent_entity.label, }, }; } else { @@ -102,7 +81,7 @@ export const FirewallDeviceTable = React.memo( orderBy, sortedData: sortedDevices, } = useOrderV2({ - data: updatedDevices, + data: devicesWithEntityLabels, initialRoute: { defaultOrder: { order: 'asc', diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 2006daad769..d80f0e9ddf7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -85,6 +85,15 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { }); } + if (deviceType === 'linode_interface' && device.entity.parent_entity) { + queryClient.invalidateQueries({ + queryKey: linodeQueries + .linode(device.entity.parent_entity.id) + ._ctx.interfaces._ctx.interface(device.entity.id)._ctx.firewalls + .queryKey, + }); + } + onClose(); }; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts index 91ab1465fcb..2e07ec070e1 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts @@ -29,7 +29,7 @@ import { compose, last, omit } from 'ramda'; import type { FirewallRuleError } from './shared'; import type { FirewallRuleType } from '@linode/api-v4/lib/firewalls'; -import type { Draft } from 'immer'; +import type { Draft, Immutable } from 'immer'; export type RuleStatus = | 'MODIFIED' @@ -218,7 +218,7 @@ export const initRuleEditorState = ( ); }; -export const editorStateToRules = (state: RuleEditorState) => { +export const editorStateToRules = (state: Immutable) => { // Cast the results of the Immer state to a mutable data structure. return castDraft( state.map((revisionList) => @@ -275,7 +275,9 @@ export const prepareRules = compose( editorStateToRules ); -export const hasModified = (editorState: RuleEditorState): boolean => { +export const hasModified = ( + editorState: Immutable +): boolean => { const rules = editorStateToRules(editorState); return rules.some( (thisRule, idx) => diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index 6104edd220f..5e8bd07315b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -37,10 +37,7 @@ import { FIREWALL_DEFAULT_ENTITY_TO_READABLE_NAME, getFirewallDefaultEntities, } from '../components/FirewallSelectOption.utils'; -import { - checkIfUserCanModifyFirewall, - getLinodeIdFromInterfaceDevice, -} from '../shared'; +import { checkIfUserCanModifyFirewall } from '../shared'; const FirewallRulesLanding = React.lazy(() => import('./Rules/FirewallRulesLanding').then((module) => ({ @@ -95,9 +92,10 @@ export const FirewallDetail = () => { acc.nodebalancerCount += 1; } else if ( isLinodeInterfacesEnabled && - device.entity.type === 'linode_interface' + device.entity.type === 'linode_interface' && + device.entity.parent_entity ) { - const linodeId = getLinodeIdFromInterfaceDevice(device.entity); + const linodeId = device.entity.parent_entity.id; if (!acc.seenLinodeIdsForInterfaces.has(linodeId)) { acc.linodeCount += 1; } diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx index 017ab8edd95..f41265b44ef 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx @@ -21,6 +21,8 @@ import { getRuleString, } from './FirewallRow'; +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + const queryMocks = vi.hoisted(() => ({ useAccount: vi.fn().mockReturnValue({}), useFirewallSettingsQuery: vi.fn().mockReturnValue({}), @@ -110,19 +112,39 @@ describe('FirewallRow', () => { const device = firewallDeviceFactory.build(); const links = getDeviceLinks({ entities: [device.entity], - isLoading: false, - linodesWithInterfaceDevices: undefined, }); const { getByText } = renderWithTheme(links); expect(getByText(device.entity.label ?? '')); }); + it('should show the Linode label for a link for an interface device', () => { + const device = firewallDeviceFactory.build({ + entity: { + id: 10, + label: null, + type: 'linode_interface' as FirewallDeviceEntityType, + url: '/linodes/11/interfaces/10', + parent_entity: { + id: 11, + label: 'test-linode-label', + type: 'linode' as FirewallDeviceEntityType, + url: '/linodes/11', + parent_entity: null, + }, + }, + }); + + const links = getDeviceLinks({ + entities: [device.entity], + }); + const { getByText } = renderWithTheme(links); + expect(getByText('test-linode-label')).toBeVisible(); + }); + it('should render up to three comma-separated links', () => { const devices = firewallDeviceFactory.buildList(3); const links = getDeviceLinks({ entities: devices.map((device) => device.entity), - isLoading: false, - linodesWithInterfaceDevices: undefined, }); const { queryAllByTestId } = renderWithTheme(links); expect(queryAllByTestId('firewall-row-link')).toHaveLength(3); @@ -132,8 +154,6 @@ describe('FirewallRow', () => { const devices = firewallDeviceFactory.buildList(13); const links = getDeviceLinks({ entities: devices.map((device) => device.entity), - isLoading: false, - linodesWithInterfaceDevices: undefined, }); const { getByText, queryAllByTestId } = renderWithTheme(links); expect(queryAllByTestId('firewall-row-link')).toHaveLength(3); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx index 4684a15913c..efdd36419c0 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx @@ -1,11 +1,9 @@ -import { useAllLinodesQuery } from '@linode/queries'; import { Box } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import React from 'react'; import { Link } from 'src/components/Link'; -import { Skeleton } from 'src/components/Skeleton'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -13,16 +11,10 @@ import { useDefaultFirewallChipInformation } from 'src/hooks/useDefaultFirewallC import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { DefaultFirewallChip } from '../components/DefaultFirewallChip'; -import { getLinodeIdFromInterfaceDevice } from '../shared'; import { FirewallActionMenu } from './FirewallActionMenu'; import type { ActionHandlers } from './FirewallActionMenu'; -import type { - Filter, - Firewall, - FirewallDeviceEntity, - Linode, -} from '@linode/api-v4'; +import type { Firewall, FirewallDeviceEntity } from '@linode/api-v4'; export interface FirewallRowProps extends Firewall, ActionHandlers {} @@ -34,25 +26,6 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => { const { defaultNumEntities, isDefault, tooltipText } = useDefaultFirewallChipInformation(id); - const neededLinodeIdsForInterfaceDevices = entities - .slice(0, 3) // only take the first three entities since we only show those entity links - .filter((entity) => entity.type === 'linode_interface') - .map((entity) => { - return { id: getLinodeIdFromInterfaceDevice(entity) }; - }); - - const filterForInterfaceDeviceLinodes: Filter = { - ['+or']: neededLinodeIdsForInterfaceDevices, - }; - - // only fire this query if we have linode interface devices. We fetch the Linodes those devices are attached to - // so that we can add a label to the devices for sorting and display purposes - const { data: linodesWithInterfaceDevices, isLoading } = useAllLinodesQuery( - {}, - filterForInterfaceDeviceLinodes, - isLinodeInterfacesEnabled && neededLinodeIdsForInterfaceDevices.length > 0 - ); - const count = getCountOfRules(rules); return ( @@ -87,8 +60,6 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => { {getDevicesCellString({ entities, isLinodeInterfacesEnabled, - isLoading, - linodesWithInterfaceDevices, })} @@ -139,16 +110,9 @@ export const getCountOfRules = (rules: Firewall['rules']): [number, number] => { interface DeviceLinkInputs { entities: FirewallDeviceEntity[]; isLinodeInterfacesEnabled: boolean; - isLoading: boolean; - linodesWithInterfaceDevices: Linode[] | undefined; } const getDevicesCellString = (inputs: DeviceLinkInputs) => { - const { - entities, - isLinodeInterfacesEnabled, - isLoading, - linodesWithInterfaceDevices, - } = inputs; + const { entities, isLinodeInterfacesEnabled } = inputs; const filteredEntities = isLinodeInterfacesEnabled ? entities : entities.filter((entity) => entity.type !== 'linode_interface'); @@ -159,39 +123,19 @@ const getDevicesCellString = (inputs: DeviceLinkInputs) => { return getDeviceLinks({ entities: filteredEntities, - isLoading, - linodesWithInterfaceDevices, }); }; export const getDeviceLinks = ( inputs: Omit ) => { - const { entities, isLoading, linodesWithInterfaceDevices } = inputs; + const { entities } = inputs; const firstThree = entities.slice(0, 3); - if (isLoading) { - return ; - } - return ( <> {firstThree.map((entity, idx) => { - // TODO @Linode Interfaces - switch to parent entity when endpoints are updated - const isInterfaceDevice = entity.type === 'linode_interface'; - let entityLabel = entity.label; - let entityLink = `/${entity.type}s/${entity.id}/${ - entity.type === 'linode' ? 'networking' : 'summary' - }`; - - if (isInterfaceDevice) { - const parentEntityId = getLinodeIdFromInterfaceDevice(entity); - entityLabel = - linodesWithInterfaceDevices?.find( - (linode) => linode.id === parentEntityId - )?.label ?? entity.label; - entityLink = `/linodes/${parentEntityId}/networking/interfaces/${entity.id}`; - } + const { entityLabel, entityLink } = getDeviceLinkAndLabel(entity); return ( @@ -210,3 +154,17 @@ export const getDeviceLinks = ( ); }; + +export const getDeviceLinkAndLabel = (entity: FirewallDeviceEntity) => { + const { id, label, parent_entity, type } = entity; + const isInterfaceDevice = type === 'linode_interface'; + + const entityLabel = + isInterfaceDevice && parent_entity ? parent_entity.label : label; + const entityLink = + isInterfaceDevice && parent_entity + ? `/linodes/${parent_entity.id}/networking/interfaces/${id}` + : `/${type}s/${id}/${type === 'linode' ? 'networking' : 'summary'}`; + + return { entityLabel, entityLink }; +}; diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx index 3e0ae7cf68d..438c099345c 100644 --- a/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx +++ b/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx @@ -1,5 +1,5 @@ import { useAllFirewallsQuery } from '@linode/queries'; -import { Autocomplete } from '@linode/ui'; +import { Autocomplete, InputAdornment } from '@linode/ui'; import React, { useMemo } from 'react'; import { useDefaultFirewallChipInformation } from 'src/hooks/useDefaultFirewallChipInformation'; @@ -79,10 +79,12 @@ export const FirewallSelect = ( textFieldProps={{ InputProps: { endAdornment: isDefault && !hideDefaultChips && ( - + + + ), }, }} diff --git a/packages/manager/src/features/Firewalls/shared.test.ts b/packages/manager/src/features/Firewalls/shared.test.ts index 092edb562c5..463cbdfaa62 100644 --- a/packages/manager/src/features/Firewalls/shared.test.ts +++ b/packages/manager/src/features/Firewalls/shared.test.ts @@ -2,14 +2,10 @@ import { allIPv4, allIPv6, generateAddressesLabel, - getLinodeIdFromInterfaceDevice, predefinedFirewallFromRule, } from './shared'; -import type { - FirewallDeviceEntityType, - FirewallRuleType, -} from '@linode/api-v4/lib/firewalls/types'; +import type { FirewallRuleType } from '@linode/api-v4/lib/firewalls/types'; const addresses = { ipv4: [allIPv4], @@ -148,22 +144,3 @@ describe('generateAddressLabel', () => { ); }); }); - -describe('getLinodeIdFromInterfaceDevice', () => { - it('returns the ID', () => { - const entity = { - id: 123, - label: null, - type: 'interface' as FirewallDeviceEntityType, - url: '/v4/linode/instances/123/interfaces/123', - }; - - expect(getLinodeIdFromInterfaceDevice(entity)).toEqual(123); - expect( - getLinodeIdFromInterfaceDevice({ - ...entity, - url: '/v4/linode/instances/456/interfaces/123', - }) - ).toEqual(456); - }); -}); diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts index a9d94a947a5..beeaa834c50 100644 --- a/packages/manager/src/features/Firewalls/shared.ts +++ b/packages/manager/src/features/Firewalls/shared.ts @@ -2,7 +2,7 @@ import { truncateAndJoinList } from '@linode/utilities'; import { capitalize } from '@linode/utilities'; import type { PORT_PRESETS } from './FirewallDetail/Rules/shared'; -import type { FirewallDeviceEntity, Grants, Profile } from '@linode/api-v4'; +import type { Grants, Profile } from '@linode/api-v4'; import type { Firewall, FirewallRuleProtocol, @@ -268,17 +268,3 @@ export const getFirewallDescription = (firewall: Firewall) => { ]; return description.join(', '); }; - -// TODO @Linode Interfaces - probably get rid of this once the API changes to FirewallDevice come in -/** - * Utility function to extract the Linode ID from firewall interface device entities. For Interface devices, - * the URL is "/v4/linode/instances/123/interfaces/123" - * - * Assumptions: the entity device being passed into this function always has type "interface". The URL is - * always in the above format. - */ -export const getLinodeIdFromInterfaceDevice = ( - entity: FirewallDeviceEntity -): number => { - return Number(entity.url.split('/')[4]); -}; diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index 3da20666274..52d09bacfc2 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -68,9 +68,7 @@ export const EmailBounceNotificationSection = React.memo(() => { )} {userEmailBounceNotification && profileEmailRef && ( - history.push('/profile/display', { focusEmail: true }) - } + changeEmail={() => history.push('/profile/display?focusEmail=true')} confirmEmail={confirmProfileEmail} text={ diff --git a/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.tsx b/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.tsx index 524a0e5d9fa..21611756f7d 100644 --- a/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/RegionStatusBanner.tsx @@ -1,6 +1,7 @@ import { useRegionsQuery } from '@linode/queries'; import { Notice, Typography } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.test.tsx b/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.test.tsx index 5b0eadf9cd3..3e1a4a215c4 100644 --- a/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.test.tsx +++ b/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.test.tsx @@ -71,9 +71,8 @@ describe('VerificationDetailsBanner', () => { fireEvent.click(getByTestId('confirmButton')); // Ensure that history.push is called with the correct arguments - expect(mockHistory.push).toHaveBeenCalledWith('/profile/auth', { - focusSecurityQuestions: true, - focusTel: false, - }); + expect(mockHistory.push).toHaveBeenCalledWith( + '/profile/auth?focusSecurityQuestions=true&focusTel=false' + ); }); }); diff --git a/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx b/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx index f0749b2720d..11fffca0719 100644 --- a/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx @@ -41,7 +41,12 @@ export const VerificationDetailsBanner = ({ diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 9cc0776ccca..9bbf6fb060a 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -5,10 +5,10 @@ import * as React from 'react'; import { useCalculateHiddenItems } from '../../hooks/useCalculateHiddenItems'; import type { CombinedEntity, ExtendedRoleView } from '../../Shared/types'; -import type { AccountAccessRole, EntityAccessRole } from '@linode/api-v4'; +import type { AccountRoleType, EntityRoleType } from '@linode/api-v4'; interface Props { - onButtonClick: (roleName: AccountAccessRole | EntityAccessRole) => void; + onButtonClick: (roleName: AccountRoleType | EntityRoleType) => void; onRemoveAssignment: (entity: CombinedEntity, role: ExtendedRoleView) => void; role: ExtendedRoleView; } @@ -53,7 +53,9 @@ export const AssignedEntities = ({ (entity: CombinedEntity, index: number) => (
    (itemRefs.current[index] = el)} + ref={(el: HTMLDivElement) => { + itemRefs.current[index] = el; + }} style={{ display: 'inline-block', marginRight: 8 }} > + + )} + + ))} + + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Profile/LishSettings/LishAuthMode.tsx b/packages/manager/src/features/Profile/LishSettings/LishAuthMode.tsx new file mode 100644 index 00000000000..50606999434 --- /dev/null +++ b/packages/manager/src/features/Profile/LishSettings/LishAuthMode.tsx @@ -0,0 +1,115 @@ +import { useMutateProfile, useProfile } from '@linode/queries'; +import { + Autocomplete, + Button, + Notice, + Paper, + Stack, + Typography, +} from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +export const LishAuthMode = () => { + const { data: profile } = useProfile(); + const { mutateAsync: updateProfile } = useMutateProfile(); + + const { enqueueSnackbar } = useSnackbar(); + + const thirdPartyEnabled = profile?.authentication_type !== 'password'; + + const tooltipText = thirdPartyEnabled + ? 'Password is disabled because Third-Party Authentication has been enabled.' + : ''; + + const modeOptions = [ + { + label: 'Allow both password and key authentication', + value: 'password_keys', + }, + { + label: 'Allow key authentication only', + value: 'keys_only', + }, + { + label: 'Disable Lish', + value: 'disabled', + }, + ] as const; + + const values = { + lish_auth_method: profile?.lish_auth_method ?? 'password_keys', + }; + + const form = useForm({ + values, + defaultValues: values, + }); + + const onSubmit = async (value: typeof values) => { + try { + await updateProfile(value); + + enqueueSnackbar('LISH authentication mode updated successfully.', { + variant: 'success', + }); + } catch (errors) { + for (const error of errors) { + form.setError(error.field ?? 'root', { message: error.reason }); + } + } + }; + + return ( + +
    + + LISH Authentication Mode + {form.formState.errors.root?.message && ( + + )} + + This controls what authentication methods are allowed to connect to + the Lish console servers. + + ( + + option.value === 'password_keys' && + profile?.authentication_type !== 'password' + } + label="Authentication Mode" + noMarginTop + onChange={(e, option) => field.onChange(option.value)} + options={modeOptions} + textFieldProps={{ + inputRef: field.ref, + tooltipText, + }} + value={modeOptions.find( + (option) => option.value === field.value + )} + /> + )} + /> + + + + +
    +
    + ); +}; diff --git a/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx b/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx index 9831fb54845..82c14706276 100644 --- a/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx +++ b/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx @@ -1,229 +1,17 @@ -import { useMutateProfile, useProfile } from '@linode/queries'; -import { - ActionsPanel, - Autocomplete, - Box, - Button, - FormControl, - Notice, - Paper, - TextField, - Typography, -} from '@linode/ui'; -import { scrollErrorIntoView } from '@linode/utilities'; -import { useTheme } from '@mui/material/styles'; -import { createLazyRoute } from '@tanstack/react-router'; -import { equals, lensPath, remove, set } from 'ramda'; -import * as React from 'react'; +import { Stack } from '@linode/ui'; +import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import type { Profile } from '@linode/api-v4/lib/profile'; -import type { APIError } from '@linode/api-v4/lib/types'; - -export interface LishAuthOption { - label: L; - value: T; -} +import { AuthorizedKeys } from './AuthorizedKeys'; +import { LishAuthMode } from './LishAuthMode'; export const LishSettings = () => { - const theme = useTheme(); - const { data: profile, isLoading } = useProfile(); - const { mutateAsync: updateProfile } = useMutateProfile(); - const [submitting, setSubmitting] = React.useState(false); - const [errors, setErrors] = React.useState([]); - const [success, setSuccess] = React.useState(); - const thirdPartyEnabled = profile?.authentication_type !== 'password'; - - const [lishAuthMethod, setLishAuthMethod] = React.useState< - Profile['lish_auth_method'] | undefined - >(profile?.lish_auth_method || 'password_keys'); - - const [authorizedKeys, setAuthorizedKeys] = React.useState( - profile?.authorized_keys || [] - ); - - const [authorizedKeysCount, setAuthorizedKeysCount] = React.useState( - profile?.authorized_keys ? profile!.authorized_keys.length : 1 - ); - - const tooltipText = thirdPartyEnabled - ? 'Password is disabled because Third-Party Authentication has been enabled.' - : ''; - - const hasErrorFor = getAPIErrorFor( - { - authorized_keys: 'ssh public keys', - lish_auth_method: 'authentication method', - }, - errors - ); - - const generalError = hasErrorFor('none'); - const authMethodError = hasErrorFor('lish_auth_method'); - const authorizedKeysError = hasErrorFor('authorized_keys'); - - const modeOptions = [ - { - disabled: profile?.authentication_type !== 'password', - label: 'Allow both password and key authentication', - value: 'password_keys', - }, - { - label: 'Allow key authentication only', - value: 'keys_only', - }, - { - label: 'Disable Lish', - value: 'disabled', - }, - ]; - - const defaultMode = modeOptions.find((eachMode) => { - if (profile?.authentication_type !== 'password') { - return (eachMode.value as Profile['lish_auth_method']) === 'keys_only'; - } else { - return (eachMode.value as Profile['lish_auth_method']) === lishAuthMethod; - } - }); - - const addSSHPublicKeyField = () => - setAuthorizedKeysCount(authorizedKeysCount + 1); - - const onSubmit = () => { - const keys = authorizedKeys.filter((v) => v !== ''); - - setErrors([]); - setSubmitting(false); - - updateProfile({ - authorized_keys: keys, - lish_auth_method: lishAuthMethod as Profile['lish_auth_method'], - }) - .then((profileData) => { - setSubmitting(false); - setSuccess('LISH authentication settings have been updated.'); - setAuthorizedKeys(profileData.authorized_keys || []); - setAuthorizedKeysCount( - profileData.authorized_keys ? profileData.authorized_keys.length : 1 - ); - }) - .catch((error) => { - setSubmitting(false); - setErrors(getAPIErrorOrDefault(error)); - setSuccess(undefined); - scrollErrorIntoView(); - }); - }; - - const onPublicKeyChange = - (idx: number) => (e: React.ChangeEvent) => { - setAuthorizedKeys(set(lensPath([idx]), e.target.value)); - }; - - const onPublicKeyRemove = (idx: number) => () => { - setAuthorizedKeys(remove(idx, 1, authorizedKeys)); - setAuthorizedKeysCount(authorizedKeysCount - 1); - }; - return ( - <> + - - {success && } - {authorizedKeysError && ( - - )} - {generalError && } - - This controls what authentication methods are allowed to connect to - the Lish console servers. - - {isLoading ? null : ( - <> - - option.disabled === true} - id="mode-select" - label="Authentication Mode" - onChange={( - _, - item: LishAuthOption - ) => setLishAuthMethod(item.value)} - options={modeOptions} - textFieldProps={{ - dataAttrs: { - 'data-qa-mode-select': true, - }, - tooltipText, - }} - value={modeOptions.find( - (option) => option.value === lishAuthMethod - )} - /> - - {Array.from(Array(authorizedKeysCount)).map((value, idx) => ( - - - {(idx === 0 && typeof authorizedKeys[0] !== 'undefined') || - idx > 0 ? ( - - ) : null} - - ))} - - Place your SSH public keys here for use with Lish console access. - - - - )} - - - + + + ); }; - -export const lishSettingsLazyRoute = createLazyRoute('/profile/lish')({ - component: LishSettings, -}); diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.test.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.test.tsx index 75f45c7fa3e..ebe66181750 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.test.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { oauthClientFactory } from 'src/factories/accountOAuth'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import OAuthClients from './OAuthClients'; @@ -18,7 +18,9 @@ describe('Maintenance Table Row', () => { }) ); - const { getByTestId, getByText } = renderWithTheme(); + const { getByTestId, getByText } = await renderWithThemeAndRouter( + + ); await waitForElementToBeRemoved(getByTestId('table-row-loading')); diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx index 05586f759fd..aac8feca417 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx @@ -1,7 +1,6 @@ import { useOAuthClientsQuery } from '@linode/queries'; import { Box, Button } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -15,8 +14,8 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; -import { useOrder } from 'src/hooks/useOrder'; -import { usePagination } from 'src/hooks/usePagination'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { SecretTokenDialog } from '../SecretTokenDialog/SecretTokenDialog'; import { CreateOAuthClientDrawer } from './CreateOAuthClientDrawer'; @@ -28,15 +27,22 @@ import { ResetOAuthClientDialog } from './ResetOAuthClientDialog'; const PREFERENCE_KEY = 'oauth-clients'; const OAuthClients = () => { - const pagination = usePagination(1, PREFERENCE_KEY); - - const { handleOrderChange, order, orderBy } = useOrder( - { - order: 'desc', - orderBy: 'status', + const pagination = usePaginationV2({ + initialPage: 1, + preferenceKey: PREFERENCE_KEY, + currentRoute: '/profile/clients', + }); + + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: 'desc', + orderBy: 'status', + }, + from: '/profile/clients', }, - PREFERENCE_KEY - ); + preferenceKey: PREFERENCE_KEY, + }); const { data, error, isLoading } = useOAuthClientsQuery( { @@ -207,8 +213,4 @@ const OAuthClients = () => { ); }; -export const OAuthClientsLazyRoute = createLazyRoute('/profile/clients')({ - component: OAuthClients, -}); - export default OAuthClients; diff --git a/packages/manager/src/features/Profile/Profile.tsx b/packages/manager/src/features/Profile/Profile.tsx index c7b82b93393..aa245c04982 100644 --- a/packages/manager/src/features/Profile/Profile.tsx +++ b/packages/manager/src/features/Profile/Profile.tsx @@ -1,12 +1,13 @@ -import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; -import { useRouteMatch } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { NavTabs } from 'src/components/NavTabs/NavTabs'; - -import type { NavTab } from 'src/components/NavTabs/NavTabs'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useTabs } from 'src/hooks/useTabs'; const SSHKeys = React.lazy(() => import('./SSHKeys/SSHKeys').then((module) => ({ @@ -46,60 +47,76 @@ const APITokens = React.lazy(() => ); export const Profile = () => { - const { url } = useRouteMatch(); - - const tabs: NavTab[] = [ + const { tabs, handleTabChange, tabIndex } = useTabs([ { - component: DisplaySettings, - routeName: `${url}/display`, + to: `/profile/display`, title: 'Display', }, { - component: AuthenticationSettings, - routeName: `${url}/auth`, + to: `/profile/auth`, title: 'Login & Authentication', }, { - component: SSHKeys, - routeName: `${url}/keys`, + to: `/profile/keys`, title: 'SSH Keys', }, { - component: LishSettings, - routeName: `${url}/lish`, + to: `/profile/lish`, title: 'LISH Console Settings', }, { - component: APITokens, - routeName: `${url}/tokens`, + to: `/profile/tokens`, title: 'API Tokens', }, { - component: OAuthClients, - routeName: `${url}/clients`, + to: `/profile/clients`, title: 'OAuth Apps', }, { - component: Referrals, - routeName: `${url}/referrals`, + to: `/profile/referrals`, title: 'Referrals', }, { - render: , - routeName: `${url}/settings`, + to: `/profile/settings`, title: 'My Settings', }, - ]; + ]); return ( - + + + }> + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; - -export const ProfileLazyRoute = createLazyRoute('/profile')({ - component: Profile, -}); diff --git a/packages/manager/src/features/Profile/Referrals/Referrals.tsx b/packages/manager/src/features/Profile/Referrals/Referrals.tsx index df42cc6e1e5..4c1bafa146b 100644 --- a/packages/manager/src/features/Profile/Referrals/Referrals.tsx +++ b/packages/manager/src/features/Profile/Referrals/Referrals.tsx @@ -1,7 +1,6 @@ import { useProfile } from '@linode/queries'; import { CircleProgress, Notice, Paper, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; -import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import Step1 from 'src/assets/referrals/step-1.svg'; @@ -160,7 +159,3 @@ export const Referrals = () => { ); }; - -export const ReferralsLazyRoute = createLazyRoute('/profile/referrals')({ - component: Referrals, -}); diff --git a/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx b/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx index 50fdcd54305..f4def264b4e 100644 --- a/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx @@ -1,7 +1,6 @@ import { useSSHKeysQuery } from '@linode/queries'; import { Box, Button, Stack, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -142,7 +141,3 @@ export const SSHKeys = () => { ); }; - -export const SSHKeysLazyRoute = createLazyRoute('/profile/keys')({ - component: SSHKeys, -}); diff --git a/packages/manager/src/features/Profile/Settings/MaskSensitiveData.test.tsx b/packages/manager/src/features/Profile/Settings/MaskSensitiveData.test.tsx index bf918d80c09..643c931482c 100644 --- a/packages/manager/src/features/Profile/Settings/MaskSensitiveData.test.tsx +++ b/packages/manager/src/features/Profile/Settings/MaskSensitiveData.test.tsx @@ -25,9 +25,9 @@ describe('MaskSensitiveData', () => { await waitFor(() => { expect(getByRole('checkbox')).toBeEnabled(); + expect(getByRole('checkbox')).toBeChecked(); }); - expect(getByRole('checkbox')).toBeChecked(); expect(getByText('Sensitive data is masked')).toBeVisible(); }); diff --git a/packages/manager/src/features/Profile/Settings/Notifications.test.tsx b/packages/manager/src/features/Profile/Settings/Notifications.test.tsx index 899e984a95a..f71f7e4ad45 100644 --- a/packages/manager/src/features/Profile/Settings/Notifications.test.tsx +++ b/packages/manager/src/features/Profile/Settings/Notifications.test.tsx @@ -25,9 +25,9 @@ describe('Notifications', () => { await waitFor(() => { expect(getByRole('checkbox')).toBeEnabled(); + expect(getByRole('checkbox')).toBeChecked(); }); - expect(getByRole('checkbox')).toBeChecked(); expect( getByText('Email alerts for account activity are enabled') ).toBeVisible(); diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index 1437294e7bd..fe8b9575367 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -1,28 +1,27 @@ import { Stack } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { EnableTableStriping } from './EnableTableStriping'; import { MaskSensitiveData } from './MaskSensitiveData'; import { Notifications } from './Notifications'; import { PreferenceEditor } from './PreferenceEditor'; +import { TableStriping } from './TableStriping'; import { Theme } from './Theme'; import { TypeToConfirm } from './TypeToConfirm'; export const ProfileSettings = () => { - const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); + const { preferenceEditor } = useSearch({ from: '/profile/settings' }); - const queryParams = new URLSearchParams(location.search); - - const isPreferenceEditorOpen = queryParams.has('preferenceEditor'); + const isPreferenceEditorOpen = !!preferenceEditor; const handleClosePreferenceEditor = () => { - queryParams.delete('preferenceEditor'); - history.replace({ search: queryParams.toString() }); + navigate({ + to: '/profile/settings', + search: { preferenceEditor: undefined }, + }); }; return ( @@ -33,7 +32,7 @@ export const ProfileSettings = () => { - + { ); }; - -export const SettingsLazyRoute = createLazyRoute('/profile/settings')({ - component: ProfileSettings, -}); diff --git a/packages/manager/src/features/Profile/Settings/EnableTableStriping.test.tsx b/packages/manager/src/features/Profile/Settings/TableStriping.test.tsx similarity index 69% rename from packages/manager/src/features/Profile/Settings/EnableTableStriping.test.tsx rename to packages/manager/src/features/Profile/Settings/TableStriping.test.tsx index 6748687cdfb..21477516c1c 100644 --- a/packages/manager/src/features/Profile/Settings/EnableTableStriping.test.tsx +++ b/packages/manager/src/features/Profile/Settings/TableStriping.test.tsx @@ -5,13 +5,13 @@ import { preferencesFactory } from 'src/factories'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { EnableTableStriping } from './EnableTableStriping'; +import { TableStriping } from './TableStriping'; describe('EnableTableStriping', () => { it('renders a heading', () => { - const { getByText } = renderWithTheme(); + const { getByText } = renderWithTheme(); - expect(getByText('Enable Table Striping')).toBeVisible(); + expect(getByText('Table Striping')).toBeVisible(); }); it('is enabled if isTableStripingEnabled in user preferences is true', async () => { @@ -23,7 +23,7 @@ describe('EnableTableStriping', () => { ) ); - const { getByRole, getByText } = renderWithTheme(); + const { getByRole, getByText } = renderWithTheme(); await waitFor(() => { expect(getByRole('checkbox')).toBeEnabled(); @@ -42,7 +42,7 @@ describe('EnableTableStriping', () => { ) ); - const { getByRole, getByText } = renderWithTheme(); + const { getByRole, getByText } = renderWithTheme(); await waitFor(() => { expect(getByRole('checkbox')).toBeEnabled(); @@ -52,18 +52,18 @@ describe('EnableTableStriping', () => { expect(getByText('Table striping is disabled')).toBeVisible(); }); - it('is disabled if isTableStripingEnabled in user preferences is undefined/does not exist', async () => { + it('is enabled if isTableStripingEnabled in user preferences is undefined/does not exist', async () => { server.use( http.get('*/v4/profile/preferences', () => HttpResponse.json({})) ); - const { getByRole, getByText } = renderWithTheme(); + const { getByRole, getByText } = renderWithTheme(); await waitFor(() => { expect(getByRole('checkbox')).toBeEnabled(); }); - expect(getByRole('checkbox')).not.toBeChecked(); - expect(getByText('Table striping is disabled')).toBeVisible(); + expect(getByRole('checkbox')).toBeChecked(); + expect(getByText('Table striping is enabled')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/Profile/Settings/EnableTableStriping.tsx b/packages/manager/src/features/Profile/Settings/TableStriping.tsx similarity index 59% rename from packages/manager/src/features/Profile/Settings/EnableTableStriping.tsx rename to packages/manager/src/features/Profile/Settings/TableStriping.tsx index 791d7d61723..8e68b566815 100644 --- a/packages/manager/src/features/Profile/Settings/EnableTableStriping.tsx +++ b/packages/manager/src/features/Profile/Settings/TableStriping.tsx @@ -2,24 +2,21 @@ import { useMutatePreferences, usePreferences } from '@linode/queries'; import { FormControlLabel, Paper, Toggle, Typography } from '@linode/ui'; import React from 'react'; -export const EnableTableStriping = () => { - const { data: isTableStripingEnabled, isLoading } = usePreferences( +import { getIsTableStripingEnabled } from './TableStriping.utils'; + +export const TableStriping = () => { + const { data: tableStripingPreference, isLoading } = usePreferences( (preferences) => preferences?.isTableStripingEnabled ); const { mutateAsync: updatePreferences } = useMutatePreferences(); - React.useEffect(() => { - // Setting the default value to true - if (isTableStripingEnabled === undefined) { - updatePreferences({ isTableStripingEnabled: true }); - } - }, [isTableStripingEnabled, updatePreferences]); + const isEnabled = getIsTableStripingEnabled(tableStripingPreference); return ( - Enable Table Striping + Table Striping Enable table striping for better readability. @@ -27,16 +24,14 @@ export const EnableTableStriping = () => { updatePreferences({ isTableStripingEnabled: checked }) } /> } disabled={isLoading} - label={`Table striping is ${ - isTableStripingEnabled ? 'enabled' : 'disabled' - }`} + label={`Table striping is ${isEnabled ? 'enabled' : 'disabled'}`} /> ); diff --git a/packages/manager/src/features/Profile/Settings/TableStriping.utils.ts b/packages/manager/src/features/Profile/Settings/TableStriping.utils.ts new file mode 100644 index 00000000000..16adeab62b1 --- /dev/null +++ b/packages/manager/src/features/Profile/Settings/TableStriping.utils.ts @@ -0,0 +1,18 @@ +/** + * Given the raw table striping preference, this functions returns whether or not + * striping should be enabled. + * + * If the user's table striping preference is `undefined`, we will enable table striping + * because we want it to be enabled by default. + * + * @param value the table striping preference value + * @returns `true` if the preference is true or `undefined` and `false` if the preference is false + */ +export function getIsTableStripingEnabled(value: boolean | undefined) { + if (value === undefined) { + // If no preference is set, enable table striping by default + return true; + } + + return value; +} diff --git a/packages/manager/src/features/Profile/Settings/TypeToConfirm.test.tsx b/packages/manager/src/features/Profile/Settings/TypeToConfirm.test.tsx index c3e5adf2138..f3eeadd5586 100644 --- a/packages/manager/src/features/Profile/Settings/TypeToConfirm.test.tsx +++ b/packages/manager/src/features/Profile/Settings/TypeToConfirm.test.tsx @@ -23,9 +23,9 @@ describe('TypeToConfirm', () => { await waitFor(() => { expect(getByRole('checkbox')).toBeEnabled(); + expect(getByRole('checkbox')).toBeChecked(); }); - expect(getByRole('checkbox')).toBeChecked(); expect(getByText('Type-to-confirm is enabled')).toBeVisible(); }); @@ -40,9 +40,9 @@ describe('TypeToConfirm', () => { await waitFor(() => { expect(getByRole('checkbox')).toBeEnabled(); + expect(getByRole('checkbox')).toBeChecked(); }); - expect(getByRole('checkbox')).toBeChecked(); expect(getByText('Type-to-confirm is enabled')).toBeVisible(); }); diff --git a/packages/manager/src/features/Profile/Settings/TypeToConfirm.tsx b/packages/manager/src/features/Profile/Settings/TypeToConfirm.tsx index 378153ad493..3ce9a70b2a1 100644 --- a/packages/manager/src/features/Profile/Settings/TypeToConfirm.tsx +++ b/packages/manager/src/features/Profile/Settings/TypeToConfirm.tsx @@ -22,7 +22,7 @@ export const TypeToConfirm = () => { updatePreferences({ type_to_confirm: checked }) } diff --git a/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.tsx b/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.tsx index 6fec0acd050..89f0f1fd309 100644 --- a/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.tsx @@ -36,7 +36,7 @@ export const StackScriptForm = (props: Props) => { errorText={fieldState.error?.message} InputProps={{ startAdornment: ( - {username} / + {username} / ), }} inputRef={field.ref} diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx index a7904be6d59..ca6922d127e 100644 --- a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptLandingTable.tsx @@ -3,7 +3,13 @@ import { useStackScriptsInfiniteQuery, } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; -import { CircleProgress, ErrorState, Stack, TooltipIcon } from '@linode/ui'; +import { + CircleProgress, + ErrorState, + InputAdornment, + Stack, + TooltipIcon, +} from '@linode/ui'; import { Hidden } from '@linode/ui'; import { useMatch, @@ -131,11 +137,13 @@ export const StackScriptLandingTable = (props: Props) => { searchParseError ? { endAdornment: ( - + + + ), } : {} diff --git a/packages/manager/src/features/Support/AttachFileListItem.tsx b/packages/manager/src/features/Support/AttachFileListItem.tsx index 7b671319c05..27be5cf41a0 100644 --- a/packages/manager/src/features/Support/AttachFileListItem.tsx +++ b/packages/manager/src/features/Support/AttachFileListItem.tsx @@ -75,7 +75,7 @@ export const AttachFileListItem = (props: Props) => { ), startAdornment: ( - + ), diff --git a/packages/manager/src/features/Support/Hively.stories.tsx b/packages/manager/src/features/Support/Hively.stories.tsx index 28b38c76aad..6077c3253b3 100644 --- a/packages/manager/src/features/Support/Hively.stories.tsx +++ b/packages/manager/src/features/Support/Hively.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Hively } from './Hively'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { args: { diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index 39f3fce124f..54e3a101741 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -15,6 +15,7 @@ import { reduceAsync, scrollErrorIntoViewV2 } from '@linode/utilities'; import { useLocation as useLocationTanstack } from '@tanstack/react-router'; import { update } from 'ramda'; import * as React from 'react'; +import type { JSX } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; // eslint-disable-next-line no-restricted-imports import { useLocation as useLocationRouterDom } from 'react-router-dom'; diff --git a/packages/manager/src/features/Support/TicketAttachmentRow.tsx b/packages/manager/src/features/Support/TicketAttachmentRow.tsx index 829f9d5bbcf..590f5a7cc7a 100644 --- a/packages/manager/src/features/Support/TicketAttachmentRow.tsx +++ b/packages/manager/src/features/Support/TicketAttachmentRow.tsx @@ -1,5 +1,6 @@ import { Box, Divider, Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; interface Props { attachments: string[]; diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx index 59676b2d43e..c20cd8d58c4 100644 --- a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx +++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx @@ -82,7 +82,7 @@ export const CreateMenu = () => { description: "Control your Linodes' physical placement", display: 'Placement Group', hide: !isPlacementGroupsEnabled, - href: '/placement-groups/create', + href: '/placement-groups?action=create', }, { attr: { 'data-qa-one-click-add-new': true }, diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.stories.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.stories.tsx index 2352b260bdf..2e3fa78eb71 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.stories.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.stories.tsx @@ -7,14 +7,14 @@ import { import { NotificationMenu } from './NotificationMenu'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; const meta: Meta = { component: NotificationMenu, decorators: [ - (Story: StoryFn) => { + (Story) => { const contextValue = useNotificationContext(); const NotificationProvider = _notificationContext.Provider; return ( diff --git a/packages/manager/src/features/TopMenu/TopMenu.stories.tsx b/packages/manager/src/features/TopMenu/TopMenu.stories.tsx index 7aa43cfa2d9..80956c49e0a 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.stories.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.stories.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { TopMenu } from './TopMenu'; import type { TopMenuProps } from './TopMenu'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx b/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx index 178ff9192e9..f8ea950812a 100644 --- a/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx +++ b/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx @@ -1,5 +1,6 @@ import { Tooltip } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import type { Theme } from '@mui/material'; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.stories.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.stories.tsx index c63a76f8404..cda2d9ab6e3 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.stories.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.stories.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { UserMenu } from './UserMenu'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: () => , diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 33d12c52b2c..00bdb185b77 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -98,8 +98,8 @@ export const SubnetAssignLinodesDrawer = ( unassignLinode, unassignLinodesErrors, } = useUnassignLinode(); - const csvRef = React.useRef(); - const newInterface = React.useRef(); + const csvRef = React.useRef(undefined); + const newInterface = React.useRef(undefined); const removedLinodeId = React.useRef(-1); const formattedDate = useFormattedDate(); const theme = useTheme(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 263311d2424..52b4dfc1d13 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -4,6 +4,7 @@ import { Hidden } from '@linode/ui'; import { getFormattedStatus } from '@linode/utilities'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; import * as React from 'react'; +import type { JSX } from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRowFirewallsCell.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRowFirewallsCell.tsx index ebd6ddf4cbf..a850e3ff599 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRowFirewallsCell.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRowFirewallsCell.tsx @@ -3,6 +3,7 @@ import { useLinodeInterfaceFirewallsQuery, } from '@linode/queries'; import * as React from 'react'; +import type { JSX } from 'react'; import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.tsx index 9e34da30049..71d879baae1 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetNodebalancerRow.tsx @@ -45,7 +45,7 @@ export const SubnetNodeBalancerRow = ({ data: NodeBalancerConfig[], loading: boolean, error?: APIError[] - ): JSX.Element | string => { + ): React.JSX.Element | string => { if (loading) { return 'Loading...'; } @@ -74,7 +74,7 @@ export const SubnetNodeBalancerRow = ({ data: Firewall[], loading: boolean, error?: APIError[] - ): JSX.Element | string => { + ): React.JSX.Element | string => { if (loading) { return 'Loading...'; } @@ -90,7 +90,7 @@ export const SubnetNodeBalancerRow = ({ return getFirewallLink(data); }; - const getFirewallLink = (data: Firewall[]): JSX.Element | string => { + const getFirewallLink = (data: Firewall[]): React.JSX.Element | string => { const firewall = data[0]; return ( diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index 04f20541a3e..be8b859eb17 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -78,7 +78,7 @@ export const SubnetUnassignLinodesDrawer = React.memo( const { setUnassignLinodesErrors, unassignLinode, unassignLinodesErrors } = useUnassignLinode(); - const csvRef = React.useRef(); + const csvRef = React.useRef(undefined); const formattedDate = useFormattedDate(); const [selectedLinodes, setSelectedLinodes] = React.useState( diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx index 7bb587db433..c6b1fae1fc4 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx @@ -215,40 +215,44 @@ describe('VPC Subnets table', () => { getByText('Firewalls'); }); - it('should show Nodebalancer table head data when table is expanded', async () => { - const subnet = subnetFactory.build(); - - server.use( - http.get('*/vpcs/:vpcId/subnets', () => { - return HttpResponse.json(makeResourcePage([subnet])); - }), - http.get('*/networking/firewalls/settings', () => { - return HttpResponse.json(firewallSettingsFactory.build()); - }) - ); - - const { getAllByRole, findByText, queryByTestId } = - await renderWithThemeAndRouter( - , - { flags: { nodebalancerVpc: true } } + it( + 'should show Nodebalancer table head data when table is expanded', + async () => { + const subnet = subnetFactory.build(); + + server.use( + http.get('*/vpcs/:vpcId/subnets', () => { + return HttpResponse.json(makeResourcePage([subnet])); + }), + http.get('*/networking/firewalls/settings', () => { + return HttpResponse.json(firewallSettingsFactory.build()); + }) ); - const loadingState = queryByTestId(loadingTestId); - if (loadingState) { - await waitForElementToBeRemoved(loadingState); - } + const { getAllByRole, findByText, queryByTestId } = + await renderWithThemeAndRouter( + , + { flags: { nodebalancerVpc: true } } + ); + + const loadingState = queryByTestId(loadingTestId); + if (loadingState) { + await waitForElementToBeRemoved(loadingState); + } - const expandTableButton = getAllByRole('button')[3]; - await userEvent.click(expandTableButton); + const expandTableButton = getAllByRole('button')[3]; + await userEvent.click(expandTableButton); - await findByText('NodeBalancer'); - await findByText('Backend Status'); - await findByText('VPC IPv4 Range'); - }); + await findByText('NodeBalancer'); + await findByText('Backend Status'); + await findByText('VPC IPv4 Range'); + }, + { timeout: 15_000 } + ); it('should disable Create Subnet button if the VPC is associated with a LKE-E cluster', async () => { server.use( diff --git a/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx b/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx index 2c54f915f62..fee7af0c0eb 100644 --- a/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx +++ b/packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx @@ -1,6 +1,6 @@ import { Box, Notice, Paper, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import React from 'react'; +import React, { type JSX } from 'react'; import type { SxProps, Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx index d9272c7019b..3adf53eda26 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx @@ -2,7 +2,6 @@ import { Notice, Typography } from '@linode/ui'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { StyledNoticeTypography } from 'src/features/components/PlansPanel/PlansAvailabilityNotice.styles'; import { useFlags } from 'src/hooks/useFlags'; import { APLNotice } from './APLNotice'; @@ -17,6 +16,7 @@ import { } from './constants'; import { MetalNotice } from './MetalNotice'; import { PlansAvailabilityNotice } from './PlansAvailabilityNotice'; +import { PlanNoticeTypography } from './PlansAvailabilityNotice.styles'; import { planTabInfoContent } from './utils'; import type { Region } from '@linode/api-v4'; @@ -143,9 +143,9 @@ export const PlanInformation = (props: PlanInformationProps) => { })} variant="warning" > - + These plans have limited deployment availability. - + )} diff --git a/packages/manager/src/features/components/PlansPanel/PlanSelectionTable.tsx b/packages/manager/src/features/components/PlansPanel/PlanSelectionTable.tsx index ee2a60ba701..b616a6dfb9f 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanSelectionTable.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanSelectionTable.tsx @@ -1,5 +1,6 @@ import { TooltipIcon } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { TableBody } from 'src/components/TableBody'; import { TableHead } from 'src/components/TableHead'; diff --git a/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.styles.ts b/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.styles.ts deleted file mode 100644 index d374e0f921d..00000000000 --- a/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.styles.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { List, Typography } from '@linode/ui'; -import { styled } from '@mui/material/styles'; - -import { TextTooltip } from 'src/components/TextTooltip'; - -import type { Theme } from '@mui/material/styles'; - -export const StyledNoticeTypography = styled(Typography, { - label: 'StyledNoticeTypography', -})(({ theme }) => ({ - font: theme.font.bold, -})); - -export const StyledTextTooltip = styled(TextTooltip, { - label: 'StyledTextTooltip', -})(() => ({})); - -export const StyledFormattedRegionList = styled(List, { - label: 'StyledFormattedRegionList', -})(({ theme }) => ({ - '& li': { - padding: `4px 0`, - }, - padding: `${theme.spacing(0.5)} ${theme.spacing()}`, -})); - -StyledTextTooltip.defaultProps = { - minWidth: 225, - placement: 'bottom-start', - sxTypography: { - fontFamily: (theme: Theme) => theme.font.bold, - }, - variant: 'body2', -}; - -StyledNoticeTypography.defaultProps = { - variant: 'body2', -}; diff --git a/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.styles.tsx b/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.styles.tsx new file mode 100644 index 00000000000..b426464cc2b --- /dev/null +++ b/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.styles.tsx @@ -0,0 +1,42 @@ +import { List, Typography } from '@linode/ui'; +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import { TextTooltip } from 'src/components/TextTooltip'; + +import type { TypographyProps } from '@linode/ui'; +import type { Theme } from '@mui/material/styles'; +import type { TextTooltipProps } from 'src/components/TextTooltip/TextTooltip'; + +export const PlanNoticeTypography = (props: TypographyProps) => { + return ( + ({ font: theme.font.bold })} + variant="body2" + {...props} + /> + ); +}; + +export const PlanTextTooltip = (props: TextTooltipProps) => { + return ( + theme.font.bold, + }} + variant="body2" + {...props} + /> + ); +}; + +export const StyledFormattedRegionList = styled(List, { + label: 'StyledFormattedRegionList', +})(({ theme }) => ({ + '& li': { + padding: `4px 0`, + }, + padding: `${theme.spacing(0.5)} ${theme.spacing()}`, +})); diff --git a/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.tsx b/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.tsx index 0883b19862a..ede9fc22dd8 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansAvailabilityNotice.tsx @@ -3,9 +3,9 @@ import { formatPlanTypes, getCapabilityFromPlanType } from '@linode/utilities'; import * as React from 'react'; import { + PlanNoticeTypography, + PlanTextTooltip, StyledFormattedRegionList, - StyledNoticeTypography, - StyledTextTooltip, } from './PlansAvailabilityNotice.styles'; import type { LinodeTypeClass, Region } from '@linode/api-v4'; @@ -81,14 +81,14 @@ const PlansAvailabilityNoticeMessage = ( if (!hasSelectedRegion) { return ( - + {formattedPlanType} Plans are currently available in  - } /> . - + ); } @@ -100,10 +100,10 @@ const PlansAvailabilityNoticeMessage = ( dataTestId={`${planType}-notice-error`} variant="error" > - + {formattedPlanType} Plans are not currently available in this region.  - 0 ? ( @@ -114,7 +114,7 @@ const PlansAvailabilityNoticeMessage = ( } /> . - + ); } diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 0fa93b7c412..7dc137c08bc 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -6,6 +6,7 @@ import { plansNoticesUtils, } from '@linode/utilities'; import * as React from 'react'; +import type { JSX } from 'react'; import { getIsDistributedRegion, diff --git a/packages/manager/src/hooks/useToastNotifications.tsx b/packages/manager/src/hooks/useToastNotifications.tsx index 8198aa0bb9c..115f5c9d6fd 100644 --- a/packages/manager/src/hooks/useToastNotifications.tsx +++ b/packages/manager/src/hooks/useToastNotifications.tsx @@ -1,4 +1,5 @@ import { useSnackbar } from 'notistack'; +import type { JSX } from 'react'; import { toasts } from 'src/features/Events/asyncToasts'; diff --git a/packages/manager/src/layouts/OAuth.test.tsx b/packages/manager/src/layouts/OAuth.test.tsx index 2d68e3a2c89..ac81700170c 100644 --- a/packages/manager/src/layouts/OAuth.test.tsx +++ b/packages/manager/src/layouts/OAuth.test.tsx @@ -1,7 +1,7 @@ import { getQueryParamsFromQueryString } from '@linode/utilities'; import { createMemoryHistory } from 'history'; import * as React from 'react'; -import { act } from 'react-dom/test-utils'; +import { act } from 'react'; import { LOGIN_ROOT } from 'src/constants'; import { OAuthCallbackPage } from 'src/layouts/OAuth'; diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts index b61db63d9b9..915df936c1c 100644 --- a/packages/manager/src/mocks/mockState.ts +++ b/packages/manager/src/mocks/mockState.ts @@ -22,6 +22,7 @@ export const getStateSeederGroups = ( }; export const emptyStore: MockState = { + cloudnats: [], domainRecords: [], domains: [], eventQueue: [], diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts index eb85193aeb5..3f57abe410b 100644 --- a/packages/manager/src/mocks/presets/baseline/crud.ts +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -4,6 +4,7 @@ import { } from 'src/mocks/presets/crud/handlers/events'; import { linodeCrudPreset } from 'src/mocks/presets/crud/linodes'; +import { cloudNATCrudPreset } from '../crud/cloudnats'; import { domainCrudPreset } from '../crud/domains'; import { firewallCrudPreset } from '../crud/firewalls'; import { kubernetesCrudPreset } from '../crud/kubernetes'; @@ -19,6 +20,7 @@ import type { MockPresetBaseline } from 'src/mocks/types'; export const baselineCrudPreset: MockPresetBaseline = { group: { id: 'General' }, handlers: [ + ...cloudNATCrudPreset.handlers, ...domainCrudPreset.handlers, ...firewallCrudPreset.handlers, ...kubernetesCrudPreset.handlers, diff --git a/packages/manager/src/mocks/presets/crud/cloudnats.ts b/packages/manager/src/mocks/presets/crud/cloudnats.ts new file mode 100644 index 00000000000..42e896c3ac2 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/cloudnats.ts @@ -0,0 +1,15 @@ +import { + createCloudNAT, + deleteCloudNAT, + getCloudNATs, + updateCloudNAT, +} from 'src/mocks/presets/crud/handlers/cloudnats'; + +import type { MockPresetCrud } from 'src/mocks/types'; + +export const cloudNATCrudPreset: MockPresetCrud = { + group: { id: 'CloudNATs' }, + handlers: [createCloudNAT, deleteCloudNAT, updateCloudNAT, getCloudNATs], + id: 'cloudnats:crud', + label: 'CloudNATs CRUD', +}; diff --git a/packages/manager/src/mocks/presets/crud/handlers/cloudnats.ts b/packages/manager/src/mocks/presets/crud/handlers/cloudnats.ts new file mode 100644 index 00000000000..b3f87a38372 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/cloudnats.ts @@ -0,0 +1,113 @@ +import { http } from 'msw'; + +import { cloudNATFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from 'src/mocks/utilities/response'; + +import type { CloudNAT } from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { MockState } from 'src/mocks/types'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +export const getCloudNATs = () => [ + http.get( + '*/v4/networking/cloudnats', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const cloudNATs = await mswDB.getAll('cloudnats'); + + return makePaginatedResponse({ + data: cloudNATs || [], + request, + }); + } + ), + + http.get( + '*/v4/networking/cloudnats/:id', + async ({ + params, + }): Promise> => { + const id = Number(params.id); + const cloudNAT = await mswDB.get('cloudnats', id); + + if (!cloudNAT) { + return makeNotFoundResponse(); + } + + return makeResponse(cloudNAT); + } + ), +]; + +export const createCloudNAT = (mockState: MockState) => [ + http.post( + '*/v4/networking/cloudnats', + async ({ + request, + }): Promise> => { + const payload = await request.clone().json(); + + const cloudNAT = cloudNATFactory.build({ + ...payload, + addresses: ['203.0.113.100'], + }); + + await mswDB.add('cloudnats', cloudNAT, mockState); + + return makeResponse(cloudNAT); + } + ), +]; + +export const updateCloudNAT = (mockState: MockState) => [ + http.put( + '*/v4/networking/cloudnats/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const cloudNAT = await mswDB.get('cloudnats', id); + + if (!cloudNAT) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + const updatedCloudNAT = { ...cloudNAT, ...payload }; + + await mswDB.update('cloudnats', id, updatedCloudNAT, mockState); + + return makeResponse(updatedCloudNAT); + } + ), +]; + +export const deleteCloudNAT = (mockState: MockState) => [ + http.delete( + '*/v4/networking/cloudnats/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const cloudNAT = await mswDB.get('cloudnats', id); + + if (!cloudNAT) { + return makeNotFoundResponse(); + } + + await mswDB.delete('cloudnats', id, mockState); + + return makeResponse({}); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts b/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts index 74b33648c48..645141b6974 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts @@ -132,6 +132,7 @@ export const createFirewall = (mockState: MockState) => [ label: `linode-${linodeId}`, type: 'linode' as FirewallDeviceEntityType, url: `/linodes/${linodeId}`, + parent_entity: null, }; firewallEntities.push(entity); @@ -157,6 +158,7 @@ export const createFirewall = (mockState: MockState) => [ label: `nodebalancer-${nbId}`, type: 'nodebalancer' as FirewallDeviceEntityType, url: `/nodebalancers/${nbId}`, + parent_entity: null, }; firewallEntities.push(entity); diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts index aaec70f6436..a8ed0d083af 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts @@ -191,6 +191,7 @@ const addFirewallDevice = async (inputs: { label: entityLabel, type: interfaceType, url: `/linodes/${entityId}`, + parent_entity: null, }; const updatedFirewall = { @@ -236,9 +237,17 @@ export const createLinode = (mockState: MockState) => [ // Ensure linode object does not have `interfaces` property delete payloadCopy['interfaces']; + // TODO: Get region capabilities. We can remove this once it's available in all regions + const region = await mswDB.get('regions', payload.region); + const regionSupportsMaintenancePolicy = + region?.capabilities?.includes('Maintenance Policy') ?? false; + const linode = linodeFactory.build({ created: DateTime.now().toISO(), status: 'provisioning', + ...(regionSupportsMaintenancePolicy + ? { maintenance_policy: payload.maintenance_policy ?? 'linode/migrate' } + : {}), ...payloadCopy, }); diff --git a/packages/manager/src/mocks/presets/crud/handlers/nodebalancers.ts b/packages/manager/src/mocks/presets/crud/handlers/nodebalancers.ts index 6ed50810923..f9ecf041997 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/nodebalancers.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/nodebalancers.ts @@ -252,6 +252,7 @@ export const createNodeBalancer = (mockState: MockState) => [ label: nodeBalancer.label, type: 'nodebalancer' as FirewallDeviceEntityType, url: `/nodebalancer/${nodeBalancer.id}`, + parent_entity: null, }; const updatedFirewall = { ...firewall, diff --git a/packages/manager/src/mocks/presets/crud/seeds/cloudnats.ts b/packages/manager/src/mocks/presets/crud/seeds/cloudnats.ts new file mode 100644 index 00000000000..1fc5d943493 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/cloudnats.ts @@ -0,0 +1,28 @@ +import { getSeedsCountMap } from 'src/dev-tools/utils'; +import { cloudNATFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; + +import type { MockSeeder, MockState } from 'src/mocks/types'; + +export const cloudNATSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'CloudNAT Seeds', + group: { id: 'CloudNATs' }, + id: 'cloudnats:crud', + label: 'CloudNATs', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[cloudNATSeeder.id] ?? 0; + const cloudNATSeeds = cloudNATFactory.buildList(count); + + const updatedMockState = { + ...mockState, + cloudnats: cloudNATSeeds, + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts index d9ac310cbb9..c46a700914a 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/index.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts @@ -1,3 +1,4 @@ +import { cloudNATSeeder } from './cloudnats'; import { domainSeeder } from './domains'; import { firewallSeeder } from './firewalls'; import { kubernetesSeeder } from './kubernetes'; @@ -10,6 +11,7 @@ import { volumesSeeder } from './volumes'; import { vpcSeeder } from './vpcs'; export const dbSeeders = [ + cloudNATSeeder, domainSeeder, firewallSeeder, ipAddressSeeder, diff --git a/packages/manager/src/mocks/presets/extra/regions/coreAndDistributed.ts b/packages/manager/src/mocks/presets/extra/regions/coreAndDistributed.ts index a0e36fc3ab4..862b1cba474 100644 --- a/packages/manager/src/mocks/presets/extra/regions/coreAndDistributed.ts +++ b/packages/manager/src/mocks/presets/extra/regions/coreAndDistributed.ts @@ -8,7 +8,7 @@ import type { MockPresetExtra } from 'src/mocks/types'; const mockCoreAndDistributedRegions = () => { return [ - http.get('*/v4/regions', ({ request }) => { + http.get('*/v4*/regions', ({ request }) => { return makePaginatedResponse({ data: [...productionRegions, ...distributedRegions], request, diff --git a/packages/manager/src/mocks/presets/extra/regions/coreOnly.ts b/packages/manager/src/mocks/presets/extra/regions/coreOnly.ts index 4be5a00bc55..48d0dc50cd5 100644 --- a/packages/manager/src/mocks/presets/extra/regions/coreOnly.ts +++ b/packages/manager/src/mocks/presets/extra/regions/coreOnly.ts @@ -7,7 +7,7 @@ import type { MockPresetExtra } from 'src/mocks/types'; const mockCoreOnlyRegions = () => { return [ - http.get('*/v4/regions', ({ request }) => { + http.get('*/v4*/regions', ({ request }) => { return makePaginatedResponse({ data: productionRegions, request, diff --git a/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts b/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts index 0c78d6470e3..1a3d6f16cfb 100644 --- a/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts +++ b/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts @@ -7,7 +7,7 @@ import type { MockPresetExtra } from 'src/mocks/types'; const mockLegacyRegions = () => { return [ - http.get('*/v4/regions', ({ request }) => { + http.get('*/v4*/regions', ({ request }) => { return makePaginatedResponse({ data: regions, request, diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 7dbeb2e6534..92924a0fa26 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -98,6 +98,7 @@ import { possiblePostgresReplicationTypes, postgresConfigResponse, promoFactory, + serviceAlertFactory, serviceTypesFactory, stackScriptFactory, staticObjects, @@ -123,6 +124,8 @@ const getRandomWholeNumber = (min: number, max: number) => import { accountEntityFactory } from 'src/factories/accountEntities'; import { accountRolesFactory } from 'src/factories/accountRoles'; +import { trustedDeviceFactory } from 'src/factories/devices'; +import { maintenancePolicyFactory } from 'src/factories/maintenancePolicy'; import { userAccountPermissionsFactory } from 'src/factories/userAccountPermissions'; import { userEntityPermissionsFactory } from 'src/factories/userEntityPermissions'; import { userRolesFactory } from 'src/factories/userRoles'; @@ -579,7 +582,7 @@ export const handlers = [ ); }), http.get('*/profile/apps', () => { - const tokens = appTokenFactory.buildList(5); + const tokens = appTokenFactory.buildList(30); return HttpResponse.json(makeResourcePage(tokens)); }), http.post('*/profile/phone-number', async () => { @@ -1478,7 +1481,9 @@ export const handlers = [ }); }), http.get('*/profile/devices', () => { - return HttpResponse.json(makeResourcePage([])); + return HttpResponse.json( + makeResourcePage(trustedDeviceFactory.buildList(30)) + ); }), http.put('*/profile/preferences', async ({ request }) => { const reqBody = await request.json(); @@ -2027,6 +2032,7 @@ export const handlers = [ backups_enabled: true, longview_subscription: 'longview-100', managed: true, + maintenance_policy: 'linode/migrate', network_helper: true, object_storage: 'active', }); @@ -2797,7 +2803,6 @@ export const handlers = [ serviceTypesFactory.build({ label: 'Linodes', service_type: 'linode', - regions: 'us-iad,us-east', }), serviceTypesFactory.build({ label: 'Databases', @@ -2819,6 +2824,7 @@ export const handlers = [ label: 'Linodes', service_type: 'linode', regions: 'us-iad,us-east', + alert: serviceAlertFactory.build({ scope: ['entity'] }), }) : serviceTypesFactory.build({ label: 'Databases', @@ -3180,4 +3186,9 @@ export const handlers = [ ...databases, ...vpc, ...entities, + http.get('*/v4beta/maintenance/policies', () => { + return HttpResponse.json( + makeResourcePage(maintenancePolicyFactory.buildList(2)) + ); + }), ]; diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 760e6ea13f8..5ef155c6b14 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -1,4 +1,5 @@ import type { + CloudNAT, Config, Domain, DomainRecord, @@ -115,6 +116,7 @@ export interface MockPresetExtra extends MockPresetBase { */ export type MockPresetCrudGroup = { id: + | 'CloudNATs' | 'Domains' | 'Firewalls' | 'IP Addresses' @@ -128,6 +130,7 @@ export type MockPresetCrudGroup = { | 'VPCs'; }; export type MockPresetCrudId = + | 'cloudnats:crud' | 'domains:crud' | 'firewalls:crud' | 'ip-addresses:crud' @@ -151,6 +154,7 @@ export type MockHandler = (mockState: MockState) => HttpHandler[]; * Stateful data shared among mocks. */ export interface MockState { + cloudnats: CloudNAT[]; domainRecords: DomainRecord[]; domains: Domain[]; eventQueue: Event[]; diff --git a/packages/manager/src/queries/cloudpulse/services.ts b/packages/manager/src/queries/cloudpulse/services.ts index c8d000e0d76..b85b82ee659 100644 --- a/packages/manager/src/queries/cloudpulse/services.ts +++ b/packages/manager/src/queries/cloudpulse/services.ts @@ -9,7 +9,7 @@ import type { JWETokenPayLoad, MetricDefinition, ResourcePage, - ServiceTypes, + Service, ServiceTypesList, } from '@linode/api-v4'; import type { Params } from '@linode/api-v4'; @@ -50,7 +50,7 @@ export const useCloudPulseServiceByServiceType = ( serviceType: string, enabled: boolean = false ) => { - return useQuery({ + return useQuery({ ...queryFactory.serviceByServiceType(serviceType), enabled, }); diff --git a/packages/manager/src/routes/IAM/IAMLazyRoutes.ts b/packages/manager/src/routes/IAM/IAMLazyRoutes.ts new file mode 100644 index 00000000000..5711c98555c --- /dev/null +++ b/packages/manager/src/routes/IAM/IAMLazyRoutes.ts @@ -0,0 +1,14 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { IdentityAccessLanding } from 'src/features/IAM/IAMLanding'; +import { UserDetailsLanding } from 'src/features/IAM/Users/UserDetailsLanding'; + +export const iamLandingLazyRoute = createLazyRoute('/iam')({ + component: IdentityAccessLanding, +}); + +export const userDetailsLandingLazyRoute = createLazyRoute( + '/iam/users/$username' +)({ + component: UserDetailsLanding, +}); diff --git a/packages/manager/src/routes/IAM/IAMRoute.tsx b/packages/manager/src/routes/IAM/IAMRoute.tsx new file mode 100644 index 00000000000..789df9a2500 --- /dev/null +++ b/packages/manager/src/routes/IAM/IAMRoute.tsx @@ -0,0 +1,24 @@ +import { NotFound } from '@linode/ui'; +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; + +export const IAMRoute = () => { + const { isIAMEnabled } = useIsIAMEnabled(); + + if (!isIAMEnabled) { + return ; + } + + return ( + }> + + + + + ); +}; diff --git a/packages/manager/src/routes/IAM/index.ts b/packages/manager/src/routes/IAM/index.ts new file mode 100644 index 00000000000..18c61ddd854 --- /dev/null +++ b/packages/manager/src/routes/IAM/index.ts @@ -0,0 +1,172 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { IAMRoute } from './IAMRoute'; + +import type { TableSearchParams } from '../types'; + +interface IamEntitiesSearchParams { + selectedRole?: string; +} + +interface IamUsersSearchParams extends TableSearchParams { + query?: string; +} + +const iamRoute = createRoute({ + component: IAMRoute, + getParentRoute: () => rootRoute, + validateSearch: (search: IamUsersSearchParams) => search, + + path: 'iam', +}); + +const iamCatchAllRoute = createRoute({ + getParentRoute: () => iamRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/iam/users' }); + }, +}); + +const iamIndexRoute = createRoute({ + beforeLoad: async () => { + throw redirect({ to: '/iam/users' }); + }, + getParentRoute: () => iamRoute, + path: '/', +}).lazy(() => import('./IAMLazyRoutes').then((m) => m.iamLandingLazyRoute)); + +const iamUsersRoute = createRoute({ + getParentRoute: () => iamRoute, + path: 'users', +}).lazy(() => import('./IAMLazyRoutes').then((m) => m.iamLandingLazyRoute)); + +const iamUsersCatchAllRoute = createRoute({ + getParentRoute: () => iamUsersRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/iam/users' }); + }, +}); + +const iamRolesRoute = createRoute({ + getParentRoute: () => iamRoute, + path: 'roles', +}).lazy(() => import('./IAMLazyRoutes').then((m) => m.iamLandingLazyRoute)); + +const iamRolesCatchAllRoute = createRoute({ + getParentRoute: () => iamRolesRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/iam/roles' }); + }, +}); + +const iamUserNameRoute = createRoute({ + getParentRoute: () => iamRoute, + path: 'users/$username', +}).lazy(() => + import('./IAMLazyRoutes').then((m) => m.userDetailsLandingLazyRoute) +); + +const iamUserNameIndexRoute = createRoute({ + getParentRoute: () => iamUserNameRoute, + path: '/', + beforeLoad: ({ params }) => { + throw redirect({ + to: '/iam/users/$username/details', + params: { username: params.username }, + }); + }, +}).lazy(() => + import('./IAMLazyRoutes').then((m) => m.userDetailsLandingLazyRoute) +); + +const iamUserNameDetailsRoute = createRoute({ + getParentRoute: () => iamUserNameRoute, + path: 'details', +}).lazy(() => + import('./IAMLazyRoutes').then((m) => m.userDetailsLandingLazyRoute) +); + +const iamUserNameRolesRoute = createRoute({ + getParentRoute: () => iamUserNameRoute, + path: 'roles', +}).lazy(() => + import('./IAMLazyRoutes').then((m) => m.userDetailsLandingLazyRoute) +); + +const iamUserNameEntitiesRoute = createRoute({ + getParentRoute: () => iamUserNameRoute, + path: 'entities', + validateSearch: (search: IamEntitiesSearchParams) => search, +}).lazy(() => + import('./IAMLazyRoutes').then((m) => m.userDetailsLandingLazyRoute) +); + +// Catch all route for user details page +const iamUserNameCatchAllRoute = createRoute({ + getParentRoute: () => iamRoute, + path: 'users/$username/$invalidPath', + beforeLoad: ({ params }) => { + if (!['details', 'entities', 'roles'].includes(params.invalidPath)) { + throw redirect({ + to: '/iam/users/$username', + params: { username: params.username }, + }); + } + }, +}); + +const iamUserNameDetailsCatchAllRoute = createRoute({ + getParentRoute: () => iamUserNameRoute, + path: 'details/$invalidPath', + beforeLoad: ({ params }) => { + throw redirect({ + to: '/iam/users/$username/details', + params: { username: params.username }, + }); + }, +}); + +const iamUserNameRolesCatchAllRoute = createRoute({ + getParentRoute: () => iamUserNameRoute, + path: 'roles/$invalidPath', + beforeLoad: ({ params }) => { + throw redirect({ + to: '/iam/users/$username/roles', + params: { username: params.username }, + }); + }, +}); + +const iamUserNameEntitiesCatchAllRoute = createRoute({ + getParentRoute: () => iamUserNameRoute, + path: 'entities/$invalidPath', + beforeLoad: ({ params }) => { + throw redirect({ + to: '/iam/users/$username/entities', + params: { username: params.username }, + }); + }, +}); + +export const iamRouteTree = iamRoute.addChildren([ + iamIndexRoute, + iamRolesRoute, + iamUsersRoute, + iamCatchAllRoute, + iamUsersCatchAllRoute, + iamRolesCatchAllRoute, + iamUserNameRoute.addChildren([ + iamUserNameIndexRoute, + iamUserNameDetailsRoute, + iamUserNameRolesRoute, + iamUserNameEntitiesRoute, + iamUserNameCatchAllRoute, + iamUserNameDetailsCatchAllRoute, + iamUserNameRolesCatchAllRoute, + iamUserNameEntitiesCatchAllRoute, + ]), +]); diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index f7e344b1b53..00f4260860a 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -13,6 +13,7 @@ import { dataStreamRouteTree } from './datastream'; import { domainsRouteTree } from './domains'; import { eventsRouteTree } from './events'; import { firewallsRouteTree } from './firewalls'; +import { iamRouteTree } from './IAM'; import { imagesRouteTree } from './images'; import { kubernetesRouteTree } from './kubernetes'; import { linodesRouteTree } from './linodes'; @@ -50,6 +51,7 @@ export const routeTree = rootRoute.addChildren([ dataStreamRouteTree, domainsRouteTree, eventsRouteTree, + iamRouteTree, firewallsRouteTree, imagesRouteTree, kubernetesRouteTree, @@ -99,6 +101,7 @@ export const migrationRouteTree = migrationRootRoute.addChildren([ dataStreamRouteTree, eventsRouteTree, firewallsRouteTree, + iamRouteTree, imagesRouteTree, kubernetesRouteTree, longviewRouteTree, @@ -106,6 +109,7 @@ export const migrationRouteTree = migrationRootRoute.addChildren([ nodeBalancersRouteTree, objectStorageRouteTree, placementGroupsRouteTree, + profileRouteTree, searchRouteTree, stackScriptsRouteTree, supportRouteTree, diff --git a/packages/manager/src/routes/placementGroups/index.ts b/packages/manager/src/routes/placementGroups/index.ts index f9995ae7203..a989861129b 100644 --- a/packages/manager/src/routes/placementGroups/index.ts +++ b/packages/manager/src/routes/placementGroups/index.ts @@ -1,81 +1,24 @@ -import { createRoute, redirect } from '@tanstack/react-router'; +import { createRoute } from '@tanstack/react-router'; import { rootRoute } from '../root'; -import { PlacementGroupsRoute } from './PlacementGroupsRoute'; import type { TableSearchParams } from '../types'; export interface PlacementGroupsSearchParams extends TableSearchParams { + action?: 'create' | 'delete' | 'edit'; + id?: number; query?: string; } -const placementGroupAction = { - delete: 'delete', - edit: 'edit', -} as const; - -const placementGroupLinodeAction = { - assign: 'assign', - unassign: 'unassign', -} as const; - -export type PlacementGroupAction = - (typeof placementGroupAction)[keyof typeof placementGroupAction]; -export type PlacementGroupLinodesAction = - (typeof placementGroupLinodeAction)[keyof typeof placementGroupLinodeAction]; +export interface PlacementGroupLinodesSearchParams extends TableSearchParams { + action?: 'assign' | 'unassign'; + linodeId?: number; + query?: string; +} -export const placementGroupsRoute = createRoute({ - component: PlacementGroupsRoute, +const placementGroupsLandingRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'placement-groups', - validateSearch: (search: PlacementGroupsSearchParams) => search, -}); - -const placementGroupsIndexRoute = createRoute({ - getParentRoute: () => placementGroupsRoute, - path: '/', - validateSearch: (search: PlacementGroupsSearchParams) => search, -}).lazy(() => - import('./placementGroupsLazyRoutes').then( - (m) => m.placementGroupsLandingLazyRoute - ) -); - -const placementGroupsCreateRoute = createRoute({ - getParentRoute: () => placementGroupsRoute, - path: 'create', -}).lazy(() => - import('./placementGroupsLazyRoutes').then( - (m) => m.placementGroupsLandingLazyRoute - ) -); - -type PlacementGroupActionRouteParams

    = { - action: PlacementGroupAction; - id: P; -}; - -const placementGroupActionRoute = createRoute({ - beforeLoad: async ({ params }) => { - if (!(params.action in placementGroupAction)) { - throw redirect({ - search: () => ({}), - to: '/placement-groups', - }); - } - }, - getParentRoute: () => placementGroupsRoute, - params: { - parse: ({ action, id }: PlacementGroupActionRouteParams) => ({ - action, - id: Number(id), - }), - stringify: ({ action, id }: PlacementGroupActionRouteParams) => ({ - action, - id: String(id), - }), - }, - path: '$action/$id', + path: '/placement-groups', validateSearch: (search: PlacementGroupsSearchParams) => search, }).lazy(() => import('./placementGroupsLazyRoutes').then( @@ -84,69 +27,18 @@ const placementGroupActionRoute = createRoute({ ); const placementGroupsDetailRoute = createRoute({ - getParentRoute: () => placementGroupsRoute, + getParentRoute: () => rootRoute, parseParams: (params) => ({ id: Number(params.id), }), - path: '$id', - validateSearch: (search: PlacementGroupsSearchParams) => search, -}).lazy(() => - import('./placementGroupsLazyRoutes').then( - (m) => m.placementGroupsDetailLazyRoute - ) -); - -type PlacementGroupLinodesActionRouteParams = { - action: PlacementGroupLinodesAction; -}; - -const placementGroupLinodesActionBaseRoute = createRoute({ - beforeLoad: async ({ params }) => { - if (!(params.action in placementGroupLinodeAction)) { - throw redirect({ - params: { - id: params.id, - }, - search: () => ({}), - to: `/placement-groups/$id`, - }); - } - }, - getParentRoute: () => placementGroupsDetailRoute, - params: { - parse: ({ action }: PlacementGroupLinodesActionRouteParams) => ({ - action, - }), - stringify: ({ action }: PlacementGroupLinodesActionRouteParams) => ({ - action, - }), - }, - path: 'linodes/$action', - validateSearch: (search: PlacementGroupsSearchParams) => search, + path: 'placement-groups/$id', + validateSearch: (search: PlacementGroupLinodesSearchParams) => search, }).lazy(() => import('./placementGroupsLazyRoutes').then( (m) => m.placementGroupsDetailLazyRoute ) ); -const placementGroupsUnassignRoute = createRoute({ - getParentRoute: () => placementGroupLinodesActionBaseRoute, - parseParams: (params) => ({ - linodeId: Number(params.linodeId), - }), - path: '$linodeId', -}).lazy(() => - import('./placementGroupsLazyRoutes').then( - (m) => m.placementGroupsDetailLazyRoute - ) +export const placementGroupsRouteTree = placementGroupsLandingRoute.addChildren( + [placementGroupsDetailRoute] ); - -export const placementGroupsRouteTree = placementGroupsRoute.addChildren([ - placementGroupsIndexRoute.addChildren([placementGroupActionRoute]), - placementGroupsCreateRoute, - placementGroupsDetailRoute.addChildren([ - placementGroupLinodesActionBaseRoute.addChildren([ - placementGroupsUnassignRoute, - ]), - ]), -]); diff --git a/packages/manager/src/routes/profile/index.ts b/packages/manager/src/routes/profile/index.ts index bdeb27f50ae..42922203714 100644 --- a/packages/manager/src/routes/profile/index.ts +++ b/packages/manager/src/routes/profile/index.ts @@ -3,83 +3,68 @@ import { createRoute } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { ProfileRoute } from './ProfileRoute'; +interface ProfileDisplaySettingsSearchParams { + contactDrawerOpen?: boolean; + focusEmail?: boolean; +} + +interface ProfileAuthenticationSettingsSearchParams { + focusSecurityQuestions?: boolean; + focusTel?: boolean; +} + +interface ProfileSettingsSearchParams { + preferenceEditor?: boolean; +} + const profileRoute = createRoute({ component: ProfileRoute, getParentRoute: () => rootRoute, path: 'profile', -}).lazy(() => - import('src/features/Profile/Profile').then((m) => m.ProfileLazyRoute) -); +}).lazy(() => import('./profileLazyRoutes').then((m) => m.ProfileLazyRoute)); const profileDisplaySettingsRoute = createRoute({ getParentRoute: () => profileRoute, path: 'display', -}).lazy(() => - import('src/features/Profile/DisplaySettings/DisplaySettings').then( - (m) => m.displaySettingsLazyRoute - ) -); + validateSearch: (search: ProfileDisplaySettingsSearchParams) => search, +}); const profileAuthenticationSettingsRoute = createRoute({ getParentRoute: () => profileRoute, path: 'auth', -}).lazy(() => - import( - 'src/features/Profile/AuthenticationSettings/AuthenticationSettings' - ).then((m) => m.authenticationSettingsLazyRoute) -); + validateSearch: (search: ProfileAuthenticationSettingsSearchParams) => search, +}); const profileSSHKeysRoute = createRoute({ getParentRoute: () => profileRoute, path: 'keys', -}).lazy(() => - import('src/features/Profile/SSHKeys/SSHKeys').then((m) => m.SSHKeysLazyRoute) -); +}); const profileLishSettingsRoute = createRoute({ getParentRoute: () => profileRoute, path: 'lish', -}).lazy(() => - import('src/features/Profile/LishSettings/LishSettings').then( - (m) => m.lishSettingsLazyRoute - ) -); +}); const profileAPITokensRoute = createRoute({ getParentRoute: () => profileRoute, path: 'tokens', -}).lazy(() => - import('src/features/Profile/APITokens/APITokens').then( - (m) => m.APITokensLazyRoute - ) -); +}); const profileOAuthClientsRoute = createRoute({ getParentRoute: () => profileRoute, path: 'clients', -}).lazy(() => - import('src/features/Profile/OAuthClients/OAuthClients').then( - (m) => m.OAuthClientsLazyRoute - ) -); +}); const profileReferralsRoute = createRoute({ getParentRoute: () => profileRoute, path: 'referrals', -}).lazy(() => - import('src/features/Profile/Referrals/Referrals').then( - (m) => m.ReferralsLazyRoute - ) -); +}); const profileSettingsRoute = createRoute({ getParentRoute: () => profileRoute, path: 'settings', -}).lazy(() => - import('src/features/Profile/Settings/Settings').then( - (m) => m.SettingsLazyRoute - ) -); + validateSearch: (search: ProfileSettingsSearchParams) => search, +}); export const profileRouteTree = profileRoute.addChildren([ profileAuthenticationSettingsRoute, diff --git a/packages/manager/src/routes/profile/profileLazyRoutes.ts b/packages/manager/src/routes/profile/profileLazyRoutes.ts new file mode 100644 index 00000000000..e97ff1af4e7 --- /dev/null +++ b/packages/manager/src/routes/profile/profileLazyRoutes.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { Profile } from 'src/features/Profile/Profile'; + +export const ProfileLazyRoute = createLazyRoute('/profile')({ + component: Profile, +}); diff --git a/packages/manager/src/utilities/linodes.ts b/packages/manager/src/utilities/linodes.ts index 1a15856e892..8f87f15f452 100644 --- a/packages/manager/src/utilities/linodes.ts +++ b/packages/manager/src/utilities/linodes.ts @@ -3,14 +3,27 @@ import { isFeatureEnabledV2 } from '@linode/utilities'; import { useFlags } from 'src/hooks/useFlags'; -import type { AccountMaintenance, Linode } from '@linode/api-v4'; +import type { + AccountMaintenance, + Linode, + MaintenancePolicySlug, +} from '@linode/api-v4'; -export interface Maintenance { +export interface LinodeMaintenance { + maintenance_policy_set: MaintenancePolicySlug; + start_time: null | string; + status?: + | 'canceled' + | 'completed' + | 'in-progress' + | 'pending' + | 'scheduled' + | 'started'; when: null | string; } export interface LinodeWithMaintenance extends Linode { - maintenance?: Maintenance | null; + maintenance?: LinodeMaintenance | null; } export const addMaintenanceToLinodes = ( @@ -29,6 +42,9 @@ export const addMaintenanceToLinodes = ( ? { ...thisLinode, maintenance: { + maintenance_policy_set: foundMaintenance.maintenance_policy_set, + start_time: foundMaintenance.start_time, + status: foundMaintenance.status, when: foundMaintenance.when, }, } diff --git a/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx b/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx index 101ec8c3cc6..240ae3371d3 100644 --- a/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx +++ b/packages/manager/src/utilities/noneSingleOrMultipleWithChip.tsx @@ -1,5 +1,6 @@ import { Chip, Tooltip } from '@linode/ui'; import * as React from 'react'; +import type { JSX } from 'react'; import { StyledItemWithPlusChip } from 'src/components/RemovableSelectionsList/RemovableSelectionsList.style'; diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index b008af3e0e8..946c60e7abc 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -300,7 +300,7 @@ export const renderWithTheme = ( */ export const renderWithThemeAndFormik = ( - ui: React.ReactElement, + ui: React.ReactElement, configObj: FormikConfig ) => renderWithTheme({ui}); @@ -318,7 +318,7 @@ const FormContextWrapper = ( }; interface RenderWithThemeAndHookFormOptions { - component: React.ReactElement; + component: React.ReactElement; options?: Options; useFormOptions?: UseFormProps; } diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index d802b72a6ef..29dc4a765d4 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,16 @@ +## [2025-07-01] - v0.8.0 + + +### Added: + +- Created `iam/` directory and migrated relevant query keys and hooks ([#12370](https://github.com/linode/manager/pull/12370)) +- Created `networktransfer/` directory and migrated relevant query keys and hooks ([#12381](https://github.com/linode/manager/pull/12381)) + +### Upcoming Features: + +- Add `getAllMaintenancePolicies` query and `useAccountMaintenancePoliciesQuery` hook to fetch and manage VM Host Maintenance Policy data ([#12334](https://github.com/linode/manager/pull/12334)) +- Add CRUD CloudNAT queries ([#12379](https://github.com/linode/manager/pull/12379)) + ## [2025-06-17] - v0.7.0 diff --git a/packages/queries/package.json b/packages/queries/package.json index f3037566e7e..def193ee177 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.7.0", + "version": "0.8.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", @@ -32,15 +32,15 @@ "@linode/utilities": "workspace:*", "@lukemorales/query-key-factory": "^1.3.4", "@tanstack/react-query": "5.51.24", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { "@linode/tsconfig": "workspace:*", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", - "@types/react": "^18.2.55", - "@types/react-dom": "^18.2.18" + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.6" } } diff --git a/packages/queries/src/account/maintenance.ts b/packages/queries/src/account/maintenance.ts index 57b0d5cd644..be45dfe51b7 100644 --- a/packages/queries/src/account/maintenance.ts +++ b/packages/queries/src/account/maintenance.ts @@ -33,8 +33,9 @@ export const useAccountMaintenanceQuery = (params: Params, filter: Filter) => { }); }; -export const useAccountMaintenancePoliciesQuery = () => { - return useQuery( - accountQueries.maintenance._ctx.policies, - ); +export const useAccountMaintenancePoliciesQuery = (enabled: boolean = true) => { + return useQuery({ + ...accountQueries.maintenance._ctx.policies, + enabled, + }); }; diff --git a/packages/queries/src/account/queries.ts b/packages/queries/src/account/queries.ts index 9c9b20bd2b8..593efd31039 100644 --- a/packages/queries/src/account/queries.ts +++ b/packages/queries/src/account/queries.ts @@ -9,7 +9,6 @@ import { getChildAccounts, getClientToken, getGrants, - getMaintenancePolicies, getNetworkUtilization, getOAuthClients, getUser, @@ -22,6 +21,7 @@ import { getAllAccountInvoices, getAllAccountMaintenance, getAllAccountPayments, + getAllMaintenancePolicies, getAllNotifications, getAllPaymentMethodsRequest, } from './requests'; @@ -89,7 +89,7 @@ export const accountQueries = createQueryKeys('account', { queryKey: [params, filter], }), policies: { - queryFn: getMaintenancePolicies, + queryFn: getAllMaintenancePolicies, queryKey: null, }, }, diff --git a/packages/queries/src/account/requests.ts b/packages/queries/src/account/requests.ts index 16c25b6812f..93413bfb9b1 100644 --- a/packages/queries/src/account/requests.ts +++ b/packages/queries/src/account/requests.ts @@ -2,6 +2,7 @@ import { getAccountAvailabilities, getAccountMaintenance, getInvoices, + getMaintenancePolicies, getNotifications, getPaymentMethods, getPayments, @@ -13,6 +14,7 @@ import type { AccountMaintenance, Filter, Invoice, + MaintenancePolicy, Notification, Params, Payment, @@ -60,3 +62,8 @@ export const getAllAccountAvailabilitiesRequest = () => getAll((params, filters) => getAccountAvailabilities(params, filters), )().then((data) => data.data); + +export const getAllMaintenancePolicies = () => + getAll((params, filters) => + getMaintenancePolicies(params, filters), + )().then((data) => data.data); diff --git a/packages/queries/src/cloudnats/cloudnats.ts b/packages/queries/src/cloudnats/cloudnats.ts new file mode 100644 index 00000000000..3ab2c539b66 --- /dev/null +++ b/packages/queries/src/cloudnats/cloudnats.ts @@ -0,0 +1,100 @@ +import { + createCloudNAT, + deleteCloudNAT, + getCloudNAT, + getCloudNATs, + updateCloudNAT, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { getAllCloudNATsRequest } from './requests'; + +import type { Filter, Params, UpdateCloudNATPayload } from '@linode/api-v4'; + +export const cloudnatQueries = createQueryKeys('cloudnats', { + all: (filter: Filter = {}) => ({ + queryFn: () => getAllCloudNATsRequest(filter), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getCloudNATs(params, filter), + queryKey: [params, filter], + }), + cloudnat: (id: number) => ({ + queryFn: () => getCloudNAT(id), + queryKey: [id], + }), +}); + +export const useAllCloudNATsQuery = (filter: Filter = {}) => + useQuery(cloudnatQueries.all(filter)); + +export const useCloudNATsQuery = ( + params: Params = {}, + filter: Filter = {}, + enabled = true, +) => + useQuery({ + ...cloudnatQueries.paginated(params, filter), + enabled, + }); + +export const useCloudNATQuery = (id: number) => + useQuery({ + ...cloudnatQueries.cloudnat(id), + enabled: Boolean(id), + }); + +export const useCreateCloudNATMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createCloudNAT, + onSuccess: (cloudNAT) => { + queryClient.invalidateQueries({ queryKey: cloudnatQueries.all._def }); + queryClient.invalidateQueries({ + queryKey: cloudnatQueries.paginated._def, + }); + + queryClient.setQueryData( + cloudnatQueries.cloudnat(cloudNAT.id).queryKey, + cloudNAT, + ); + }, + }); +}; + +export const useUpdateCloudNATMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UpdateCloudNATPayload) => updateCloudNAT(id, data), + onSuccess: (updatedCloudNAT) => { + queryClient.invalidateQueries({ queryKey: cloudnatQueries.all._def }); + queryClient.invalidateQueries({ + queryKey: cloudnatQueries.paginated._def, + }); + + queryClient.setQueryData( + cloudnatQueries.cloudnat(id).queryKey, + updatedCloudNAT, + ); + }, + }); +}; + +export const useDeleteCloudNATMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => deleteCloudNAT(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cloudnatQueries.all._def }); + queryClient.invalidateQueries({ + queryKey: cloudnatQueries.paginated._def, + }); + + queryClient.removeQueries({ + queryKey: cloudnatQueries.cloudnat(id).queryKey, + }); + }, + }); +}; diff --git a/packages/queries/src/cloudnats/index.ts b/packages/queries/src/cloudnats/index.ts new file mode 100644 index 00000000000..3ddbffa6bf3 --- /dev/null +++ b/packages/queries/src/cloudnats/index.ts @@ -0,0 +1 @@ +export * from './cloudnats'; diff --git a/packages/queries/src/cloudnats/requests.ts b/packages/queries/src/cloudnats/requests.ts new file mode 100644 index 00000000000..6cbbbfa27d8 --- /dev/null +++ b/packages/queries/src/cloudnats/requests.ts @@ -0,0 +1,10 @@ +import { getCloudNATs } from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; + +import type { CloudNAT } from '@linode/api-v4'; +import type { Filter } from '@linode/api-v4/lib/types'; + +export const getAllCloudNATsRequest = (filter: Filter) => + getAll((params) => getCloudNATs(params, filter))().then( + (data) => data.data, + ); diff --git a/packages/manager/src/queries/iam/iam.ts b/packages/queries/src/iam/iam.ts similarity index 89% rename from packages/manager/src/queries/iam/iam.ts rename to packages/queries/src/iam/iam.ts index df2baefa746..d96f25468db 100644 --- a/packages/manager/src/queries/iam/iam.ts +++ b/packages/queries/src/iam/iam.ts @@ -1,12 +1,12 @@ import { updateUserRoles } from '@linode/api-v4'; -import { queryPresets } from '@linode/queries'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { iamQueries } from './queries'; +import { queryPresets } from '../base'; +import { iamQueries } from './keys'; import type { + AccessType, APIError, - EntityTypePermissions, IamAccountRoles, IamUserRoles, PermissionType, @@ -36,7 +36,7 @@ export const useUserRolesMutation = (username: string) => { onSuccess: (role) => { queryClient.setQueryData( iamQueries.user(username)._ctx.roles.queryKey, - role + role, ); }, }); @@ -50,9 +50,9 @@ export const useUserAccountPermissions = (username?: string) => { }; export const useUserEntityPermissions = ( - entityType: EntityTypePermissions, + entityType: AccessType, entityId: number, - username?: string + username?: string, ) => { return useQuery({ ...iamQueries diff --git a/packages/queries/src/iam/index.ts b/packages/queries/src/iam/index.ts new file mode 100644 index 00000000000..cb812bcecee --- /dev/null +++ b/packages/queries/src/iam/index.ts @@ -0,0 +1,2 @@ +export * from './iam'; +export * from './keys'; diff --git a/packages/manager/src/queries/iam/queries.ts b/packages/queries/src/iam/keys.ts similarity index 81% rename from packages/manager/src/queries/iam/queries.ts rename to packages/queries/src/iam/keys.ts index fa5127731b8..e4d66fd20ac 100644 --- a/packages/manager/src/queries/iam/queries.ts +++ b/packages/queries/src/iam/keys.ts @@ -6,7 +6,7 @@ import { } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import type { EntityTypePermissions } from '@linode/api-v4'; +import type { AccessType } from '@linode/api-v4'; export const iamQueries = createQueryKeys('iam', { user: (username: string) => ({ @@ -19,10 +19,7 @@ export const iamQueries = createQueryKeys('iam', { queryFn: () => getUserAccountPermissions(username), queryKey: null, }, - entityPermissions: ( - entityType: EntityTypePermissions, - entityId: number - ) => ({ + entityPermissions: (entityType: AccessType, entityId: number) => ({ queryFn: () => getUserEntityPermissions(username, entityType, entityId), queryKey: [entityType, entityId], }), diff --git a/packages/queries/src/index.ts b/packages/queries/src/index.ts index bec49371924..b98d521e0b6 100644 --- a/packages/queries/src/index.ts +++ b/packages/queries/src/index.ts @@ -1,12 +1,15 @@ export * from './account'; export * from './base'; export * from './betas'; +export * from './cloudnats'; export * from './domains'; export * from './eventHandlers'; export * from './firewalls'; +export * from './iam'; export * from './images'; export * from './linodes'; export * from './networking'; +export * from './networktransfer'; export * from './nodebalancers'; export * from './placementGroups'; export * from './profile'; diff --git a/packages/queries/src/networktransfer/index.ts b/packages/queries/src/networktransfer/index.ts new file mode 100644 index 00000000000..a9cbb04366d --- /dev/null +++ b/packages/queries/src/networktransfer/index.ts @@ -0,0 +1,2 @@ +export * from './networkTransfer'; +export * from './requests'; diff --git a/packages/manager/src/queries/networkTransfer.ts b/packages/queries/src/networktransfer/networkTransfer.ts similarity index 63% rename from packages/manager/src/queries/networkTransfer.ts rename to packages/queries/src/networktransfer/networkTransfer.ts index 8eefeb434f8..4b9704d8982 100644 --- a/packages/manager/src/queries/networkTransfer.ts +++ b/packages/queries/src/networktransfer/networkTransfer.ts @@ -1,17 +1,12 @@ -import { getNetworkTransferPrices } from '@linode/api-v4'; import { queryPresets } from '@linode/queries'; -import { getAll } from '@linode/utilities'; import { useQuery } from '@tanstack/react-query'; +import { getAllNetworkTransferPrices } from './requests'; + import type { APIError, PriceType } from '@linode/api-v4'; export const queryKey = 'network-transfer'; -const getAllNetworkTransferPrices = () => - getAll((params) => getNetworkTransferPrices(params))().then( - (data) => data.data - ); - export const useNetworkTransferPricesQuery = (enabled = true) => useQuery({ queryFn: getAllNetworkTransferPrices, diff --git a/packages/queries/src/networktransfer/requests.ts b/packages/queries/src/networktransfer/requests.ts new file mode 100644 index 00000000000..83667769c2c --- /dev/null +++ b/packages/queries/src/networktransfer/requests.ts @@ -0,0 +1,9 @@ +import { getNetworkTransferPrices } from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; + +import type { PriceType } from '@linode/api-v4'; + +export const getAllNetworkTransferPrices = () => + getAll((params) => getNetworkTransferPrices(params))().then( + (data) => data.data, + ); diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index 892e261ae5a..05275556d20 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2025-07-01] - v0.4.0 + +### Tech Stories + +- Update to Storybook v9 ([#12416](https://github.com/linode/manager/pull/12416)) + ## [2025-05-06] - v0.3.0 ### Fixed: diff --git a/packages/shared/package.json b/packages/shared/package.json index 3a94fb74f8b..16df0a2adcb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@linode/shared", - "version": "0.3.0", + "version": "0.4.0", "description": "Linode shared feature component library", "main": "src/index.ts", "module": "src/index.ts", @@ -34,19 +34,19 @@ "@linode/queries": "workspace:*", "@linode/ui": "workspace:*", "@linode/utilities": "workspace:*", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { "@linode/tsconfig": "workspace:*", - "@storybook/addon-actions": "^8.6.7", - "@storybook/react": "^8.6.7", + "@storybook/react-vite": "^9.0.12", + "storybook": "^9.0.12", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", "@testing-library/user-event": "^14.5.2", - "@types/react": "^18.2.55", - "@types/react-dom": "^18.2.18", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.6", "vite-plugin-svgr": "^3.2.0" } } diff --git a/packages/shared/src/components/LinodeSelect/LinodeSelect.stories.tsx b/packages/shared/src/components/LinodeSelect/LinodeSelect.stories.tsx index 5b0aa4c78fb..363c5d8f741 100644 --- a/packages/shared/src/components/LinodeSelect/LinodeSelect.stories.tsx +++ b/packages/shared/src/components/LinodeSelect/LinodeSelect.stories.tsx @@ -1,5 +1,5 @@ -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { LinodeSelect } from './LinodeSelect'; @@ -8,7 +8,7 @@ import type { LinodeSingleSelectProps, } from './LinodeSelect'; import type { Linode } from '@linode/api-v4/lib/linodes'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const linodes = [ { id: 1, label: 'Linode 1' }, diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index a15df9a6995..6cb4f1f473a 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,3 +1,26 @@ +## [2025-07-01] - v0.15.0 + + +### Changed: + +- Add `Toggle` design tokens and update styles to match Akamai Design System ([#12303](https://github.com/linode/manager/pull/12303)) + +### Fixed: + +- Use design tokens for accordion headers ([#12334](https://github.com/linode/manager/pull/12334)) + +### Removed: + +- nowrap styles from input ([#12390](https://github.com/linode/manager/pull/12390)) + +### Tech Stories + +- Update to Storybook v9 ([#12416](https://github.com/linode/manager/pull/12416)) + +### Upcoming Features: + +- Add new maintenance policy icons and update TooltipIcon tokens ([#12398](https://github.com/linode/manager/pull/12398)) + ## [2025-06-17] - v0.14.0 diff --git a/packages/ui/package.json b/packages/ui/package.json index 1bd3748355c..b2102dfcaaf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.14.0", + "version": "0.15.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", @@ -24,8 +24,8 @@ "@mui/utils": "^7.1.0", "@mui/x-date-pickers": "^7.27.0", "luxon": "3.4.4", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "tss-react": "^4.8.2" }, "scripts": { @@ -45,16 +45,15 @@ }, "devDependencies": { "@linode/tsconfig": "workspace:*", - "@storybook/addon-actions": "^8.6.7", - "@storybook/preview-api": "^8.6.7", - "@storybook/react": "^8.6.7", + "@storybook/react-vite": "^9.0.12", + "storybook": "^9.0.12", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", "@testing-library/user-event": "^14.5.2", "@types/luxon": "3.4.2", - "@types/react": "^18.2.55", - "@types/react-dom": "^18.2.18", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.6", "vite-plugin-svgr": "^3.2.0" } } diff --git a/packages/ui/src/assets/icons/calendar-schedule.svg b/packages/ui/src/assets/icons/calendar-schedule.svg new file mode 100644 index 00000000000..2a9adf39150 --- /dev/null +++ b/packages/ui/src/assets/icons/calendar-schedule.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/calendar.svg b/packages/ui/src/assets/icons/calendar.svg new file mode 100644 index 00000000000..be88684a655 --- /dev/null +++ b/packages/ui/src/assets/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/checkmark.svg b/packages/ui/src/assets/icons/checkmark.svg new file mode 100644 index 00000000000..709c7f1041e --- /dev/null +++ b/packages/ui/src/assets/icons/checkmark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/index.ts b/packages/ui/src/assets/icons/index.ts index 66a39f63025..f8435916882 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -1,8 +1,11 @@ export { default as AlertIcon } from './alert.svg'; +export { default as CalendarScheduledIcon } from './calendar-schedule.svg'; +export { default as CalendarIcon } from './calendar.svg'; export { default as CheckIcon } from './check.svg'; export { default as CheckboxIcon } from './checkbox.svg'; export { default as CheckboxCheckedIcon } from './checkboxChecked.svg'; export { default as CheckboxIndeterminateIcon } from './checkboxIndeterminate.svg'; +export { default as CheckMarkIcon } from './checkmark.svg'; export { default as ChevronDownIcon } from './chevron-down.svg'; export { default as ChevronUpIcon } from './chevron-up.svg'; export { default as CloseIcon } from './close.svg'; @@ -12,6 +15,7 @@ export { default as ErrorIcon } from './error.svg'; export { default as InfoOutlinedIcon } from './info-outlined.svg'; export { default as InfoIcon } from './info.svg'; export { default as LightBulbIcon } from './lightbulb.svg'; +export { default as LoadFailureIcon } from './load-failure.svg'; export { default as PendingIcon } from './pending.svg'; export { default as PlusSignIcon } from './plusSign.svg'; export { default as RadioIcon } from './radio.svg'; diff --git a/packages/ui/src/assets/icons/load-failure.svg b/packages/ui/src/assets/icons/load-failure.svg new file mode 100644 index 00000000000..ddb7481610c --- /dev/null +++ b/packages/ui/src/assets/icons/load-failure.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/src/components/Accordion/Accordion.stories.tsx b/packages/ui/src/components/Accordion/Accordion.stories.tsx index 39c84f412c3..9888b68d98a 100644 --- a/packages/ui/src/components/Accordion/Accordion.stories.tsx +++ b/packages/ui/src/components/Accordion/Accordion.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Accordion } from './Accordion'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; /** * Pretend this is `react-router-dom`'s Link component. diff --git a/packages/ui/src/components/Accordion/Accordion.tsx b/packages/ui/src/components/Accordion/Accordion.tsx index da36bb5dcb5..0f8bcdd766d 100644 --- a/packages/ui/src/components/Accordion/Accordion.tsx +++ b/packages/ui/src/components/Accordion/Accordion.tsx @@ -3,6 +3,7 @@ import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionSummary from '@mui/material/AccordionSummary'; import Grid from '@mui/material/Grid'; import * as React from 'react'; +import type { JSX } from 'react'; import { makeStyles } from 'tss-react/mui'; import { ChevronDownIcon } from '../../assets'; diff --git a/packages/ui/src/components/ActionsPanel/ActionsPanel.stories.tsx b/packages/ui/src/components/ActionsPanel/ActionsPanel.stories.tsx index e61587d7868..547e0e0a739 100644 --- a/packages/ui/src/components/ActionsPanel/ActionsPanel.stories.tsx +++ b/packages/ui/src/components/ActionsPanel/ActionsPanel.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { ActionsPanel } from './ActionsPanel'; import type { ActionPanelProps } from './ActionsPanel'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: ActionsPanel, diff --git a/packages/ui/src/components/Autocomplete/Autocomplete.stories.tsx b/packages/ui/src/components/Autocomplete/Autocomplete.stories.tsx index 44984c68c6c..66a4663bfdf 100644 --- a/packages/ui/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/packages/ui/src/components/Autocomplete/Autocomplete.stories.tsx @@ -1,7 +1,7 @@ import { CloseIcon } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { action } from '@storybook/addon-actions'; import React, { useState } from 'react'; +import { action } from 'storybook/actions'; import { IconButton } from '../IconButton'; import { List } from '../List'; @@ -11,7 +11,7 @@ import { Autocomplete } from './Autocomplete'; import { SelectedIcon } from './Autocomplete.styles'; import type { EnhancedAutocompleteProps } from './Autocomplete'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const LABEL = 'Select a Linode'; @@ -114,7 +114,7 @@ const meta: Meta> = { }, component: Autocomplete, decorators: [ - (Story: StoryFn) => ( + (Story) => (

    diff --git a/packages/ui/src/components/Autocomplete/Autocomplete.tsx b/packages/ui/src/components/Autocomplete/Autocomplete.tsx index d241a7b7c80..2cbc0cd5b05 100644 --- a/packages/ui/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/ui/src/components/Autocomplete/Autocomplete.tsx @@ -1,5 +1,5 @@ import MuiAutocomplete from '@mui/material/Autocomplete'; -import React from 'react'; +import React, { type JSX } from 'react'; import ChevronDownIcon from '../../assets/icons/chevron-down.svg'; import CloseIcon from '../../assets/icons/close.svg'; diff --git a/packages/ui/src/components/BetaChip/BetaChip.stories.tsx b/packages/ui/src/components/BetaChip/BetaChip.stories.tsx index 047795c144b..15b0f2b1787 100644 --- a/packages/ui/src/components/BetaChip/BetaChip.stories.tsx +++ b/packages/ui/src/components/BetaChip/BetaChip.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { BetaChip } from './BetaChip'; import type { BetaChipProps } from './BetaChip'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/ui/src/components/Box/Box.stories.tsx b/packages/ui/src/components/Box/Box.stories.tsx index bd3c2d899bf..103208e8a6b 100644 --- a/packages/ui/src/components/Box/Box.stories.tsx +++ b/packages/ui/src/components/Box/Box.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Box } from './Box'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: Box, diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index a4e01d86370..9ccf7cae2e1 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -1,10 +1,10 @@ -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { Button } from './Button'; import { StyledLinkButton } from './StyledLinkButton'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; /** * Buttons allow users to take actions, and make choices, with a single tap. diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index fdd0794e61f..492ee477856 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -2,6 +2,7 @@ import HelpOutline from '@mui/icons-material/HelpOutline'; import _Button from '@mui/material/Button'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { omittedProps } from '../../utilities'; import { Tooltip } from '../Tooltip'; diff --git a/packages/ui/src/components/Button/StyledTagButton.stories.tsx b/packages/ui/src/components/Button/StyledTagButton.stories.tsx index adb044a973b..10aa73b9847 100644 --- a/packages/ui/src/components/Button/StyledTagButton.stories.tsx +++ b/packages/ui/src/components/Button/StyledTagButton.stories.tsx @@ -1,9 +1,9 @@ -import { action } from '@storybook/addon-actions'; import React from 'react'; +import { action } from 'storybook/actions'; import { StyledPlusIcon, StyledTagButton } from './StyledTagButton'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { args: { diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx index c430f2aedab..dea913915a4 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx @@ -3,12 +3,12 @@ import React from 'react'; import { Box } from '../Box'; import { Checkbox } from './Checkbox'; -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: Checkbox, decorators: [ - (Story: StoryFn) => ( + (Story) => ( ({ margin: theme.tokens.spacing.S16 })}> diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index c13f45741dd..42344e81d4b 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -1,6 +1,7 @@ import _Checkbox from '@mui/material/Checkbox'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { CheckboxCheckedIcon, @@ -111,7 +112,7 @@ const StyledCheckbox = styled(_Checkbox, { props.readOnly && { svg: { 'g rect:nth-of-type(2)': { - fill: theme.tokens.component.Checkbox.Indeterminated.ReadOnly.Icon, + fill: `${theme.tokens.component.Checkbox.Indeterminated.ReadOnly.Icon} !important`, }, border: `1px solid ${theme.tokens.component.Checkbox.Indeterminated.ReadOnly.Border}`, }, diff --git a/packages/ui/src/components/Chip/Chip.stories.tsx b/packages/ui/src/components/Chip/Chip.stories.tsx index c1016ebf5fb..f7684af5fa2 100644 --- a/packages/ui/src/components/Chip/Chip.stories.tsx +++ b/packages/ui/src/components/Chip/Chip.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Chip } from './Chip'; import type { ChipProps } from './Chip'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/ui/src/components/CircleProgress/CircleProgress.stories.tsx b/packages/ui/src/components/CircleProgress/CircleProgress.stories.tsx index 93938f88cb8..2e88f66b419 100644 --- a/packages/ui/src/components/CircleProgress/CircleProgress.stories.tsx +++ b/packages/ui/src/components/CircleProgress/CircleProgress.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { CircleProgress } from './CircleProgress'; import type { CircleProgressProps } from './CircleProgress'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/ui/src/components/CircleProgress/CircleProgress.tsx b/packages/ui/src/components/CircleProgress/CircleProgress.tsx index b7d7f3486ac..6ee9957d37c 100644 --- a/packages/ui/src/components/CircleProgress/CircleProgress.tsx +++ b/packages/ui/src/components/CircleProgress/CircleProgress.tsx @@ -1,6 +1,7 @@ import _CircularProgress from '@mui/material/CircularProgress'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { omittedProps } from '../../utilities'; import { Box } from '../Box'; diff --git a/packages/ui/src/components/DatePicker/DateField.stories.tsx b/packages/ui/src/components/DatePicker/DateField.stories.tsx index 5c5a22f193f..47d273e53c0 100644 --- a/packages/ui/src/components/DatePicker/DateField.stories.tsx +++ b/packages/ui/src/components/DatePicker/DateField.stories.tsx @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import { DateField } from './DateField'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/ui/src/components/DatePicker/DateField.tsx b/packages/ui/src/components/DatePicker/DateField.tsx index f3ec53b95a5..6300f34ee09 100644 --- a/packages/ui/src/components/DatePicker/DateField.tsx +++ b/packages/ui/src/components/DatePicker/DateField.tsx @@ -16,7 +16,7 @@ interface DateFieldProps extends Omit, 'onChange' | 'value'> { errorText?: string; format?: 'dd-MM-yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'; - inputRef?: React.RefObject; + inputRef?: React.RefObject; label: string; onChange: (date: DateTime | null) => void; onClick?: (e: React.MouseEvent) => void; diff --git a/packages/ui/src/components/DatePicker/DateRangePicker/DateRangePicker.stories.tsx b/packages/ui/src/components/DatePicker/DateRangePicker/DateRangePicker.stories.tsx index b83bb579763..83da285bae0 100644 --- a/packages/ui/src/components/DatePicker/DateRangePicker/DateRangePicker.stories.tsx +++ b/packages/ui/src/components/DatePicker/DateRangePicker/DateRangePicker.stories.tsx @@ -1,6 +1,6 @@ import { DateRangePicker } from '../DateRangePicker'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: DateRangePicker, diff --git a/packages/ui/src/components/DatePicker/DateTimeField.stories.tsx b/packages/ui/src/components/DatePicker/DateTimeField.stories.tsx index 63d816ebf95..e2805be5a5a 100644 --- a/packages/ui/src/components/DatePicker/DateTimeField.stories.tsx +++ b/packages/ui/src/components/DatePicker/DateTimeField.stories.tsx @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import { DateTimeField } from './DateTimeField'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/ui/src/components/DatePicker/DateTimeField.tsx b/packages/ui/src/components/DatePicker/DateTimeField.tsx index b5f650cc0c1..8bbc4d150cf 100644 --- a/packages/ui/src/components/DatePicker/DateTimeField.tsx +++ b/packages/ui/src/components/DatePicker/DateTimeField.tsx @@ -22,7 +22,7 @@ interface DateTimeFieldProps | 'MM/dd/yyyy hh:mm a' | 'yyyy-MM-dd HH:mm' | 'yyyy-MM-dd hh:mm a'; - inputRef?: React.RefObject; + inputRef?: React.RefObject; label: string; onChange: (date: DateTime | null) => void; onClick?: (e: React.MouseEvent) => void; diff --git a/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.stories.tsx index 83b7f003ac2..b73ea2d1bb6 100644 --- a/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.stories.tsx +++ b/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { DateTimeRangePicker } from './DateTimeRangePicker'; import type { DateTimeRangePickerProps } from './DateTimeRangePicker'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/ui/src/components/DatePicker/TimePicker.stories.tsx b/packages/ui/src/components/DatePicker/TimePicker.stories.tsx index 54d03d40386..d2dc3eadcc3 100644 --- a/packages/ui/src/components/DatePicker/TimePicker.stories.tsx +++ b/packages/ui/src/components/DatePicker/TimePicker.stories.tsx @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import { TimePicker } from './TimePicker'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/ui/src/components/DatePicker/TimePicker.tsx b/packages/ui/src/components/DatePicker/TimePicker.tsx index 3c8c7f92735..22f3a75020b 100644 --- a/packages/ui/src/components/DatePicker/TimePicker.tsx +++ b/packages/ui/src/components/DatePicker/TimePicker.tsx @@ -19,7 +19,7 @@ interface TimePickerProps > { errorText?: string; format?: 'HH:mm' | 'hh:mm a'; // 24-hour or 12-hour format - inputRef?: React.RefObject; + inputRef?: React.RefObject; label: string; onChange: (time: DateTime | null) => void; onClick?: (e: React.MouseEvent) => void; diff --git a/packages/ui/src/components/DatePicker/TimeZoneSelect.stories.tsx b/packages/ui/src/components/DatePicker/TimeZoneSelect.stories.tsx index 243af969543..7acadf01085 100644 --- a/packages/ui/src/components/DatePicker/TimeZoneSelect.stories.tsx +++ b/packages/ui/src/components/DatePicker/TimeZoneSelect.stories.tsx @@ -1,6 +1,6 @@ import { TimeZoneSelect } from './TimeZoneSelect'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/ui/src/components/Dialog/Dialog.stories.tsx b/packages/ui/src/components/Dialog/Dialog.stories.tsx index 68430e2b603..15d138b2112 100644 --- a/packages/ui/src/components/Dialog/Dialog.stories.tsx +++ b/packages/ui/src/components/Dialog/Dialog.stories.tsx @@ -1,11 +1,11 @@ import { Button, Typography } from '@linode/ui'; -import { action } from '@storybook/addon-actions'; -import { useArgs } from '@storybook/preview-api'; import React from 'react'; +import { action } from 'storybook/actions'; +import { useArgs } from 'storybook/preview-api'; import { Dialog } from './Dialog'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { argTypes: { diff --git a/packages/ui/src/components/Dialog/Dialog.tsx b/packages/ui/src/components/Dialog/Dialog.tsx index 1a86ff2c9fb..0ca5720f36e 100644 --- a/packages/ui/src/components/Dialog/Dialog.tsx +++ b/packages/ui/src/components/Dialog/Dialog.tsx @@ -2,6 +2,7 @@ import _Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { omittedProps } from '../../utilities'; import { getErrorText } from '../../utilities/error'; diff --git a/packages/ui/src/components/DialogTitle/DialogTitle.tsx b/packages/ui/src/components/DialogTitle/DialogTitle.tsx index d76c9ccb08f..ad69f652e05 100644 --- a/packages/ui/src/components/DialogTitle/DialogTitle.tsx +++ b/packages/ui/src/components/DialogTitle/DialogTitle.tsx @@ -2,6 +2,7 @@ import { CloseIcon, Typography } from '@linode/ui'; import { Box, IconButton } from '@linode/ui'; import _DialogTitle from '@mui/material/DialogTitle'; import * as React from 'react'; +import type { JSX } from 'react'; import type { SxProps, Theme } from '@mui/material'; diff --git a/packages/ui/src/components/Divider/Divider.stories.tsx b/packages/ui/src/components/Divider/Divider.stories.tsx index 3b8c42f8a82..710d246f2d0 100644 --- a/packages/ui/src/components/Divider/Divider.stories.tsx +++ b/packages/ui/src/components/Divider/Divider.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Divider } from './Divider'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: Divider, diff --git a/packages/ui/src/components/Drawer/Drawer.stories.tsx b/packages/ui/src/components/Drawer/Drawer.stories.tsx index 26b940e2461..779a14e0724 100644 --- a/packages/ui/src/components/Drawer/Drawer.stories.tsx +++ b/packages/ui/src/components/Drawer/Drawer.stories.tsx @@ -1,6 +1,6 @@ -import { action } from '@storybook/addon-actions'; -import { useArgs } from '@storybook/preview-api'; import React from 'react'; +import { action } from 'storybook/actions'; +import { useArgs } from 'storybook/preview-api'; import { ActionsPanel } from '../ActionsPanel'; import { Button } from '../Button'; @@ -8,7 +8,7 @@ import { TextField } from '../TextField'; import { Typography } from '../Typography'; import { Drawer } from './Drawer'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: Drawer, diff --git a/packages/ui/src/components/EditableText/EditableText.stories.tsx b/packages/ui/src/components/EditableText/EditableText.stories.tsx index bd797a4e546..cb9b327a8a4 100644 --- a/packages/ui/src/components/EditableText/EditableText.stories.tsx +++ b/packages/ui/src/components/EditableText/EditableText.stories.tsx @@ -1,10 +1,10 @@ -import { action } from '@storybook/addon-actions'; -import { useArgs } from '@storybook/preview-api'; import * as React from 'react'; +import { action } from 'storybook/actions'; +import { useArgs } from 'storybook/preview-api'; import { EditableText } from './EditableText'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; type Story = StoryObj; diff --git a/packages/ui/src/components/ErrorState/ErrorState.stories.tsx b/packages/ui/src/components/ErrorState/ErrorState.stories.tsx index b39d26e4fea..096cb21d6ba 100644 --- a/packages/ui/src/components/ErrorState/ErrorState.stories.tsx +++ b/packages/ui/src/components/ErrorState/ErrorState.stories.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ErrorState } from './ErrorState'; import type { ErrorStateProps } from './ErrorState'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { args: { diff --git a/packages/ui/src/components/ErrorState/ErrorState.tsx b/packages/ui/src/components/ErrorState/ErrorState.tsx index a18f1730f39..5d4d3d85036 100644 --- a/packages/ui/src/components/ErrorState/ErrorState.tsx +++ b/packages/ui/src/components/ErrorState/ErrorState.tsx @@ -2,6 +2,7 @@ import ErrorOutline from '@mui/icons-material/ErrorOutline'; import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; +import type { JSX } from 'react'; import { Button } from '../Button'; import { Typography } from '../Typography'; diff --git a/packages/ui/src/components/FormControl/FormControl.stories.tsx b/packages/ui/src/components/FormControl/FormControl.stories.tsx index 2fdd5f07830..e59cf72b105 100644 --- a/packages/ui/src/components/FormControl/FormControl.stories.tsx +++ b/packages/ui/src/components/FormControl/FormControl.stories.tsx @@ -5,7 +5,7 @@ import { Input } from '../Input'; import { InputLabel } from '../InputLabel'; import { FormControl } from './FormControl'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: FormControl, diff --git a/packages/ui/src/components/FormControlLabel/FormControlLabel.stories.tsx b/packages/ui/src/components/FormControlLabel/FormControlLabel.stories.tsx index 7769ccf176e..8cdd7a6014c 100644 --- a/packages/ui/src/components/FormControlLabel/FormControlLabel.stories.tsx +++ b/packages/ui/src/components/FormControlLabel/FormControlLabel.stories.tsx @@ -5,7 +5,7 @@ import { Radio } from '../Radio'; import { Toggle } from '../Toggle'; import { FormControlLabel } from './FormControlLabel'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: FormControlLabel, diff --git a/packages/ui/src/components/FormHelperText/FormHelperText.stories.tsx b/packages/ui/src/components/FormHelperText/FormHelperText.stories.tsx index bc2ab03f9d0..b915e9d35ee 100644 --- a/packages/ui/src/components/FormHelperText/FormHelperText.stories.tsx +++ b/packages/ui/src/components/FormHelperText/FormHelperText.stories.tsx @@ -5,7 +5,7 @@ import { Input } from '../Input'; import { InputLabel } from '../InputLabel'; import { FormHelperText } from './FormHelperText'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: FormHelperText, diff --git a/packages/ui/src/components/Input/Input.stories.tsx b/packages/ui/src/components/Input/Input.stories.tsx index 4ba62c08827..ed238af7515 100644 --- a/packages/ui/src/components/Input/Input.stories.tsx +++ b/packages/ui/src/components/Input/Input.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Input } from './Input'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: Input, diff --git a/packages/ui/src/components/InputAdornment/InputAdornment.stories.tsx b/packages/ui/src/components/InputAdornment/InputAdornment.stories.tsx index e496e8dcbc3..fb89c5dca23 100644 --- a/packages/ui/src/components/InputAdornment/InputAdornment.stories.tsx +++ b/packages/ui/src/components/InputAdornment/InputAdornment.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Input } from '../Input'; import { InputAdornment } from './InputAdornment'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: InputAdornment, diff --git a/packages/ui/src/components/InputLabel/InputLabel.stories.tsx b/packages/ui/src/components/InputLabel/InputLabel.stories.tsx index 5441a08ba5e..e18b3457e05 100644 --- a/packages/ui/src/components/InputLabel/InputLabel.stories.tsx +++ b/packages/ui/src/components/InputLabel/InputLabel.stories.tsx @@ -4,7 +4,7 @@ import { FormControl } from '../FormControl'; import { Input } from '../Input'; import { InputLabel } from './InputLabel'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: InputLabel, diff --git a/packages/ui/src/components/ListItemOption/ListItemOption.tsx b/packages/ui/src/components/ListItemOption/ListItemOption.tsx index c6410be0ad9..4724ff07f6c 100644 --- a/packages/ui/src/components/ListItemOption/ListItemOption.tsx +++ b/packages/ui/src/components/ListItemOption/ListItemOption.tsx @@ -1,6 +1,6 @@ import { styled } from '@mui/material/styles'; import { visuallyHidden } from '@mui/utils'; -import React from 'react'; +import React, { type JSX } from 'react'; import { SelectedIcon } from '../Autocomplete'; import { Box } from '../Box'; diff --git a/packages/ui/src/components/NewFeatureChip/NewFeatureChip.stories.tsx b/packages/ui/src/components/NewFeatureChip/NewFeatureChip.stories.tsx index 10c6e89a73e..2f47b5f95a5 100644 --- a/packages/ui/src/components/NewFeatureChip/NewFeatureChip.stories.tsx +++ b/packages/ui/src/components/NewFeatureChip/NewFeatureChip.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { NewFeatureChip } from './NewFeatureChip'; import type { NewFeatureChipProps } from './NewFeatureChip'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Default: StoryObj = { render: (args) => , diff --git a/packages/ui/src/components/NotFound/NotFound.stories.tsx b/packages/ui/src/components/NotFound/NotFound.stories.tsx index e7b34ccd5e0..84b28449ffd 100644 --- a/packages/ui/src/components/NotFound/NotFound.stories.tsx +++ b/packages/ui/src/components/NotFound/NotFound.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { NotFound } from './NotFound'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: NotFound, diff --git a/packages/ui/src/components/Notice/Notice.stories.tsx b/packages/ui/src/components/Notice/Notice.stories.tsx index 9859eb7853e..ddbea7f2904 100644 --- a/packages/ui/src/components/Notice/Notice.stories.tsx +++ b/packages/ui/src/components/Notice/Notice.stories.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { Notice } from './Notice'; import type { NoticeProps } from './Notice'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; export const Success: StoryObj = { render: (args) => ( diff --git a/packages/ui/src/components/Paper/Paper.stories.tsx b/packages/ui/src/components/Paper/Paper.stories.tsx index 5e15a8d8e87..24ab8727594 100644 --- a/packages/ui/src/components/Paper/Paper.stories.tsx +++ b/packages/ui/src/components/Paper/Paper.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Paper } from './Paper'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta = { component: Paper, diff --git a/packages/ui/src/components/Radio/Radio.stories.tsx b/packages/ui/src/components/Radio/Radio.stories.tsx index 40ac58c8d0e..2d1cb34e459 100644 --- a/packages/ui/src/components/Radio/Radio.stories.tsx +++ b/packages/ui/src/components/Radio/Radio.stories.tsx @@ -7,7 +7,7 @@ import { RadioGroup } from '../RadioGroup'; import { Radio } from './Radio'; import type { RadioProps } from './Radio'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; interface CustomArgs { checkedState?: 'checked' | 'unchecked'; diff --git a/packages/ui/src/components/Select/Select.stories.tsx b/packages/ui/src/components/Select/Select.stories.tsx index cdc6e44ff24..8d52882c56a 100644 --- a/packages/ui/src/components/Select/Select.stories.tsx +++ b/packages/ui/src/components/Select/Select.stories.tsx @@ -5,7 +5,7 @@ import { Typography } from '../Typography'; import { Select } from './Select'; import type { SelectOption, SelectProps } from './Select'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; const meta: Meta> = { component: Select, diff --git a/packages/ui/src/components/Select/Select.test.tsx b/packages/ui/src/components/Select/Select.test.tsx index c066a0a8265..a0b05009ca5 100644 --- a/packages/ui/src/components/Select/Select.test.tsx +++ b/packages/ui/src/components/Select/Select.test.tsx @@ -32,15 +32,38 @@ describe('Select', () => { expect(getByText('My Select')).toBeInTheDocument(); expect(getByRole('button', { name: 'Open' })).toBeInTheDocument(); - const selectInput = getByRole('combobox'); + // Open up the select + await userEvent.click(select); - options.forEach(async (option) => { - await userEvent.click(selectInput); - await userEvent.type(selectInput, option.label); + // Verify each option is visible + for (const option of options) { + expect(getByText(option.label)).toBeVisible(); + } + }); + + it('can search the options and select one', async () => { + const { getByLabelText, getByRole, queryByText } = renderWithTheme( +