diff --git a/Jenkinsfile-playwright.groovy b/Jenkinsfile-playwright.groovy new file mode 100644 index 00000000000..4a7b5369ac6 --- /dev/null +++ b/Jenkinsfile-playwright.groovy @@ -0,0 +1,3 @@ +library 'cloud-manager-ui-tests' + +runTestsRelease() diff --git a/Jenkinsfile-region-tests.groovy b/Jenkinsfile-region-tests.groovy deleted file mode 100644 index 7f51fc84ad9..00000000000 --- a/Jenkinsfile-region-tests.groovy +++ /dev/null @@ -1,3 +0,0 @@ -library 'ui-builder' - -testManagerRegions() diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index f20aa9cbf00..f290607bda5 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,22 @@ +## [2025-11-18] - v0.153.0 + + +### Added: + +- IAM Parent/Child: delegate permissions ([#13033](https://github.com/linode/manager/pull/13033)) +- Added `Akamai Cloud Pulse Logs ` to the `AccountCapability` type ([#13062](https://github.com/linode/manager/pull/13062)) + +### Changed: + +- IAM: cleanup for nodebalancer permissions ([#13017](https://github.com/linode/manager/pull/13017)) + +### Upcoming Features: + +- Clean up Delivery Stream and Destination interfaces ([#13038](https://github.com/linode/manager/pull/13038)) +- Add new API endpoints, and types for Firewall RS & PL ([#13061](https://github.com/linode/manager/pull/13061)) +- CloudPulse-Metrics: Update `CloudPulseServiceType` and `CapabilityServiceTypeMapping` at types.ts for new service - lke ([#13064](https://github.com/linode/manager/pull/13064)) +- New IAM getUserEntitiesByPermission endpoint ([#13070](https://github.com/linode/manager/pull/13070)) + ## [2025-11-04] - v0.152.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 30b01a473a0..f19a56eeb28 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.152.0", + "version": "0.153.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 02fb1516200..73d008377cf 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -62,6 +62,7 @@ export type BillingSource = 'akamai' | 'linode'; export const accountCapabilities = [ 'Akamai Cloud Load Balancer', 'Akamai Cloud Pulse', + 'Akamai Cloud Pulse Logs', 'Block Storage', 'Block Storage Encryption', 'Cloud Firewall', diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 787cc496c44..4ac6b19d7f8 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -8,6 +8,7 @@ export type CloudPulseServiceType = | 'dbaas' | 'firewall' | 'linode' + | 'lke' | 'nodebalancer' | 'objectstorage'; export type AlertClass = 'dedicated' | 'shared'; @@ -381,6 +382,7 @@ export const capabilityServiceTypeMapping: Record< firewall: 'Cloud Firewall', objectstorage: 'Object Storage', blockstorage: 'Block Storage', + lke: 'Kubernetes', }; /** diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index 4d98fea32d7..cb8691f8211 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -20,13 +20,11 @@ export interface AuditData { } export interface Stream extends AuditData { - destinations: Destination[]; + destinations: DestinationCore[]; details: StreamDetailsType; id: number; label: string; - primary_destination_id: number; status: StreamStatus; - stream_audit_id: number; type: StreamType; version: string; } @@ -46,11 +44,14 @@ export const destinationType = { export type DestinationType = (typeof destinationType)[keyof typeof destinationType]; -export interface Destination extends AuditData { +export interface DestinationCore { details: DestinationDetails; id: number; label: string; type: DestinationType; +} + +export interface Destination extends DestinationCore, AuditData { version: string; } diff --git a/packages/api-v4/src/firewalls/firewalls.ts b/packages/api-v4/src/firewalls/firewalls.ts index cd744bc2c8c..0781be41b39 100644 --- a/packages/api-v4/src/firewalls/firewalls.ts +++ b/packages/api-v4/src/firewalls/firewalls.ts @@ -20,7 +20,9 @@ import type { Firewall, FirewallDevice, FirewallDevicePayload, + FirewallPrefixList, FirewallRules, + FirewallRuleSet, FirewallSettings, FirewallTemplate, FirewallTemplateSlug, @@ -305,3 +307,55 @@ export const getTemplate = (templateSlug: FirewallTemplateSlug) => )}`, ), ); + +/** + * getFirewallRuleSets + * + * Returns a paginated list of all Cloud Firewall Rule Sets. + */ +export const getFirewallRuleSets = (params?: Params, filter?: Filter) => + Request>( + setMethod('GET'), + setParams(params), + setXFilter(filter), + setURL(`${BETA_API_ROOT}/networking/firewalls/rulesets`), + ); + +/** + * getFirewallRuleSet + * + * Get a specific Firewall Rule Set by its ID. + */ +export const getFirewallRuleSet = (ruleSetID: number) => + Request( + setMethod('GET'), + setURL( + `${BETA_API_ROOT}/networking/firewalls/rulesets/${encodeURIComponent(ruleSetID)}`, + ), + ); + +/** + * getFirewallPrefixLists + * + * Returns a paginated list of all Cloud Firewall Prefix Lists. + */ +export const getFirewallPrefixLists = (params?: Params, filter?: Filter) => + Request>( + setMethod('GET'), + setParams(params), + setXFilter(filter), + setURL(`${BETA_API_ROOT}/networking/prefixlists`), + ); + +/** + * getFirewallPrefixList + * + * Get a specific Firewall Prefix List by its ID. + */ +export const getFirewallPrefixList = (prefixListID: number) => + Request( + setMethod('GET'), + setURL( + `${BETA_API_ROOT}/networking/prefixlists/${encodeURIComponent(prefixListID)}`, + ), + ); diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 411c6cf501c..372d1f0d616 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -106,3 +106,30 @@ export interface FirewallSettings { export interface UpdateFirewallSettings { default_firewall_ids: Partial; } + +export interface FirewallRuleSet { + created: string; + deleted: null | string; + description: string; + id: number; + is_service_defined: boolean; + label: string; + rules: FirewallRuleType[]; + type: string; + updated: string; + version: number; +} + +export type FirewallPrefixListVisibility = 'private' | 'public' | 'restricted'; + +export interface FirewallPrefixList { + created: string; + description: string; + id: number; + ipv4?: null | string[]; + ipv6?: null | string[]; + name: string; + updated: string; + version: number; + visibility: FirewallPrefixListVisibility; +} diff --git a/packages/api-v4/src/iam/iam.ts b/packages/api-v4/src/iam/iam.ts index 4bd4e889723..405e2a68ad0 100644 --- a/packages/api-v4/src/iam/iam.ts +++ b/packages/api-v4/src/iam/iam.ts @@ -3,10 +3,12 @@ import Request, { setData, setMethod, setURL } from '../request'; import type { AccessType, + EntityByPermission, IamAccountRoles, IamUserRoles, PermissionType, } from './types'; +import type { EntityType } from 'src/entities/types'; /** * getUserRoles @@ -100,3 +102,26 @@ export const getUserEntityPermissions = ( ), setMethod('GET'), ); + +/** + * getUserEntitiesByPermission + * + * Returns the available entities for a given permission. + */ +export interface GetEntitiesByPermissionParams { + entityType: EntityType; + permission: PermissionType; + username: string | undefined; +} + +export const getUserEntitiesByPermission = ({ + username, + entityType, + permission, +}: GetEntitiesByPermissionParams) => + Request( + setURL( + `${BETA_API_ROOT}/iam/users/${username}/entities/${entityType}?permission=${permission}`, + ), + setMethod('GET'), + ); diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index a1eb2af7f66..4005384e3d9 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -75,6 +75,7 @@ export type AccountAdmin = | 'answer_profile_security_questions' | 'cancel_account' | 'cancel_service_transfer' + | 'create_child_account_token' | 'create_profile_pat' | 'create_profile_ssh_key' | 'create_profile_tfa_secret' @@ -91,17 +92,22 @@ export type AccountAdmin = | 'is_account_admin' | 'list_account_agreements' | 'list_account_logins' + | 'list_all_child_accounts' | 'list_available_services' | 'list_default_firewalls' + | 'list_delegate_users' | 'list_enrolled_beta_programs' | 'list_service_transfers' + | 'list_user_delegate_accounts' | 'list_user_grants' | 'revoke_profile_app' | 'revoke_profile_device' | 'send_profile_phone_number_verification_code' | 'update_account' | 'update_account_settings' + | 'update_default_delegate_access' | 'update_default_firewalls' + | 'update_delegate_users' | 'update_profile' | 'update_profile_pat' | 'update_profile_ssh_key' @@ -112,6 +118,7 @@ export type AccountAdmin = | 'view_account' | 'view_account_login' | 'view_account_settings' + | 'view_child_account' | 'view_enrolled_beta_program' | 'view_network_usage' | 'view_profile_security_question' @@ -363,8 +370,8 @@ export type LinodeViewer = | 'view_linode_stats'; /** Permissions associated with the "nodebalancer_admin" role. */ -// TODO: UIE-9154 - verify mapping for Nodebalancer as this is not migrated yet export type NodeBalancerAdmin = + | 'create_nodebalancer_config_node' | 'delete_nodebalancer' | 'delete_nodebalancer_config' | 'delete_nodebalancer_config_node' @@ -384,12 +391,13 @@ export type NodeBalancerContributor = /** Permissions associated with the "nodebalancer_viewer" role. */ export type NodeBalancerViewer = | 'list_nodebalancer_config_nodes' - | 'list_nodebalancer_configs' | 'list_nodebalancer_firewalls' + | 'list_nodebalancer_vpc_configs' | 'view_nodebalancer' | 'view_nodebalancer_config' | 'view_nodebalancer_config_node' - | 'view_nodebalancer_statistics'; + | 'view_nodebalancer_statistics' + | 'view_nodebalancer_vpc_config'; /** Permissions associated with the "volume_admin" role. */ export type VolumeAdmin = 'delete_volume' | VolumeContributor; @@ -448,6 +456,11 @@ export interface Roles { name: RoleName; permissions: PermissionType[]; } +export interface EntityByPermission { + id: number; + label: string; + type: EntityType; +} export type IamAccessType = keyof IamAccountRoles; diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index d19d5e3d253..dd5ff6f1a90 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,63 @@ 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-11-18] - v1.155.0 + + +### Added: + +- IAM Parent/Child: delegate permissions for child account ([#13033](https://github.com/linode/manager/pull/13033)) +- IAM: add a permission check for delete nodebalancer drawer ([#13043](https://github.com/linode/manager/pull/13043)) +- Implement pagination for Plans table ([#13055](https://github.com/linode/manager/pull/13055)) +- A notice banner to inform the customers about the upcoming maintenance activity in longview and to download and use the new GPG key ([#13080](https://github.com/linode/manager/pull/13080)) + +### Changed: + +- IAM: clean up in mapping for nodebalancer permissions ([#13017](https://github.com/linode/manager/pull/13017)) +- IAM: fix permissiom's check for vpc for assigning/unassigning linodes ([#13050](https://github.com/linode/manager/pull/13050)) +- ACLP Alert: control `edit & delete button` based on status using flag ([#13052](https://github.com/linode/manager/pull/13052)) + +### Fixed: + +- Typo + expose search filters on /iam/roles route ([#13034](https://github.com/linode/manager/pull/13034)) +- Address oAuth issue with IAM OAuthCallback ([#13037](https://github.com/linode/manager/pull/13037)) +- IAM: tags editing was enabled for restricted users ([#13046](https://github.com/linode/manager/pull/13046)) +- IAM Child Account - user not found error handling ([#13047](https://github.com/linode/manager/pull/13047)) +- IAM: restricted users could access delete and detach popups without permissions ([#13049](https://github.com/linode/manager/pull/13049)) +- Race condition with Preferences overrides in PrimaryNav ([#13056](https://github.com/linode/manager/pull/13056)) +- IAM: add tooltips for disabled buttons for nodebalancers, remove notification banner ([#13058](https://github.com/linode/manager/pull/13058)) +- IAM: incorrect permission check disabled Clone Volume for volume_admin role ([#13065](https://github.com/linode/manager/pull/13065)) +- ACLP Metric: update preference logic to clear children filters ([#13066](https://github.com/linode/manager/pull/13066)) +- Permissions fixes for admin users on users pages ([#13074](https://github.com/linode/manager/pull/13074)) + +### Tech Stories: + +- Replace Formik with React Hook Form in DatabaseManageNetworkingDrawer ([#13002](https://github.com/linode/manager/pull/13002)) +- Replace Formik with React Hook Form in ManageAccessControlDrawer ([#13044](https://github.com/linode/manager/pull/13044)) +- Add MSW crud support for types API ([#13067](https://github.com/linode/manager/pull/13067)) + +### Tests: + +- Fix flakey timerange test ([#12967](https://github.com/linode/manager/pull/12967)) +- Add Streams Landing, Create Stream and Edit Stream tests ([#13008](https://github.com/linode/manager/pull/13008)) +- Fix flakey stackscript tests ([#13072](https://github.com/linode/manager/pull/13072)) +- Fix flakey create-database test ([#13076](https://github.com/linode/manager/pull/13076)) + +### Upcoming Features: + +- Logs Delivery Stream/Destination Pendo tags ([#13022](https://github.com/linode/manager/pull/13022)) +- Update Delivery Stream factory ([#13038](https://github.com/linode/manager/pull/13038)) +- IAM: Empty state for the Default Roles and Default Entities Access tabs ([#13042](https://github.com/linode/manager/pull/13042)) +- ACLP-Alerting: Onboarding Blockstorage service for ACLP Alerts ([#13048](https://github.com/linode/manager/pull/13048)) +- Feature flag for Firewall Rulesets & Prefixlists ([#13051](https://github.com/linode/manager/pull/13051)) +- Feature flag for new Generational Compute Plans ([#13054](https://github.com/linode/manager/pull/13054)) +- Limit Logs feature based on `Akamai Cloud Pulse Logs` Account Capability ([#13062](https://github.com/linode/manager/pull/13062)) +- IAM Parent/Child: delegate permissions for parent account ([#13063](https://github.com/linode/manager/pull/13063)) +- CloudPulse-Metrics: Update `FilterConfig.ts`, add lke service and the associated filters ([#13064](https://github.com/linode/manager/pull/13064)) +- Update data-pendo-id tags placemanet within Logs Delivery ([#13069](https://github.com/linode/manager/pull/13069)) +- New IAM getUserEntitiesByPermission (types, queries, hooks, mocks) ([#13070](https://github.com/linode/manager/pull/13070)) +- CloudPulse-Metrics: Enhance `CloudPulseDashboardWithFilters.tsx` and `CloudPulseDashboardSelect.tsx` to handle switching between dashboards in contextual view ([#13073](https://github.com/linode/manager/pull/13073)) + ## [2025-11-04] - v1.154.0 ### Added: diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index aab31745a48..c122e309fd2 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -10,6 +10,7 @@ import { discardPassedTestRecordings } from './cypress/support/plugins/discard-p import { featureFlagOverrides } from './cypress/support/plugins/feature-flag-override'; import { fetchAccount } from './cypress/support/plugins/fetch-account'; import { fetchLinodeClusters } from './cypress/support/plugins/fetch-linode-clusters'; +import { fetchLinodeImages } from './cypress/support/plugins/fetch-linode-images'; import { fetchLinodeRegions } from './cypress/support/plugins/fetch-linode-regions'; import { generateTestWeights } from './cypress/support/plugins/generate-weights'; import { enableHtmlReport } from './cypress/support/plugins/html-report'; @@ -102,6 +103,7 @@ export default defineConfig({ fetchAccount, fetchLinodeRegions, fetchLinodeClusters, + fetchLinodeImages, resetUserPreferences, regionOverrideCheck, clusterOverrideCheck, diff --git a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts index a1effa63ac8..aecf5f176ad 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts @@ -101,9 +101,7 @@ describe('ACLP Components UI varies according to ACLP support by region and user .should('be.visible') .should('be.enabled'); // UI displays mock error msg - cy.contains( - `Error while loading Dashboard with Id - ${this.mockDashboardId}` - ); + cy.contains('Error loading dashboards'); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts index af6bf62bcd1..031178cd3e4 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts @@ -160,7 +160,7 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { .click(); }); - it('should display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is true and enabled is false', () => { + it('should not display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is true and enabled is false', () => { // Mock the feature flags to disable metrics for Linode const mockflags = flagsFactory.build({ @@ -179,21 +179,12 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.visitWithLogin('/metrics'); - ui.autocomplete.findByLabel('Dashboard').click(); + // Verify the autocomplete is disabled and shows the placeholder text - // Verify the autocomplete dropdown is visible and contains the "no options" message - - cy.get('[data-qa-autocomplete-popper]') + cy.findByPlaceholderText('Select a Dashboard') .should('be.visible') - .and('contain.text', 'You have no options to choose from') - .within(() => { - // Assert that "Linode" does not appear in the dropdown - - cy.contains('Linode').should('not.exist'); - // Assert that the beta chip is not rendered - - cy.get('[data-testid="betaChip"]').should('not.exist'); - }); + .and('be.disabled') + .and('have.value', ''); }); // SKIPPED: The feature flag normalizer auto-enables Linode when `{ aclpServices: { linode: {} } }` is provided, // expanding to `aclpServices.linode.metrics = { beta: true, enabled: true }`. Expected: Linode hidden when the @@ -215,16 +206,10 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.get('@dashboardInput').click(); // Verify the dropdown is visible and shows "no options" - cy.get('[data-qa-autocomplete-popper]') + cy.findByPlaceholderText('Select a Dashboard') .should('be.visible') - .and('contain.text', 'You have no options to choose from') - .within(() => { - // Assert "Linode" is not shown - cy.contains('Linode').should('not.exist'); - - // Assert no beta chip is visible - cy.get('[data-testid="betaChip"]').should('not.exist'); - }); + .and('be.disabled') + .and('have.value', ''); }); it('should not display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is false and the service is not enabled', () => { @@ -242,21 +227,12 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.visitWithLogin('/metrics'); - ui.autocomplete.findByLabel('Dashboard').click(); - - // Verify the autocomplete dropdown is visible and contains the "no options" message + // Verify the autocomplete is disabled and shows the placeholder text - cy.get('[data-qa-autocomplete-popper]') + cy.findByPlaceholderText('Select a Dashboard') .should('be.visible') - .and('contain.text', 'You have no options to choose from') - .within(() => { - // Assert that "Linode" does not appear in the dropdown - - cy.contains('Linode').should('not.exist'); - // Assert that the beta chip is not rendered - - cy.get('[data-testid="betaChip"]').should('not.exist'); - }); + .and('be.disabled') + .and('have.value', ''); }); // SKIPPED: If `aclpServices` is not passed, LaunchDarkly should treat it as null/false. // Currently, missing/partial flags are defaulted to `{ beta: true, enabled: true }`, @@ -274,17 +250,11 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); cy.get('@dashboardInput').click(); - // Verify the dropdown is visible and shows "no options" - cy.get('[data-qa-autocomplete-popper]') + // Verify the dropdown is disabled and shows the placeholder text + cy.findByPlaceholderText('Select a Dashboard') .should('be.visible') - .and('contain.text', 'You have no options to choose from') - .within(() => { - // Assert "Linode" is not shown - cy.contains('Linode').should('not.exist'); - - // Assert no beta chip is visible - cy.get('[data-testid="betaChip"]').should('not.exist'); - }); + .and('be.disabled') + .and('have.value', ''); }); it('should not show Linode in Dashboard dropdown when metrics flags are missing and service is not enabled', () => { @@ -306,20 +276,10 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.visitWithLogin('/metrics'); - ui.autocomplete.findByLabel('Dashboard').click(); - - // Verify the autocomplete dropdown is visible and contains the "no options" message - - cy.get('[data-qa-autocomplete-popper]') + // Verify the autocomplete is disabled and shows the placeholder text + cy.findByPlaceholderText('Select a Dashboard') .should('be.visible') - .and('contain.text', 'You have no options to choose from') - .within(() => { - // Assert that "Linode" does not appear in the dropdown - - cy.contains('Linode').should('not.exist'); - // Assert that the beta chip is not rendered - - cy.get('[data-testid="betaChip"]').should('not.exist'); - }); + .and('be.disabled') + .and('have.value', ''); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts index ac01f7351e9..3ce18db0626 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -95,20 +95,16 @@ const databaseMock: Database = databaseFactory.build({ type: engine, }); const mockProfile = profileFactory.build({ - timezone: 'UTC', + timezone: 'GMT', }); /** - * Generates a date in Indian Standard Time (IST) based on a specified number of days offset, - * hour, and minute. The function also provides individual date components such as day, hour, + * Generates a date in UTC based on a specified number of hours and minutes offset. The function also provides individual date components such as day, hour, * minute, month, and AM/PM. - * - * @param {number} daysOffset - The number of days to adjust from the current date. Positive - * values give a future date, negative values give a past date. * @param {number} hour - The hour to set for the resulting date (0-23). * @param {number} [minute=0] - The minute to set for the resulting date (0-59). Defaults to 0. * * @returns {Object} - Returns an object containing: - * - `actualDate`: The formatted date and time in IST (YYYY-MM-DD HH:mm). + * - `actualDate`: The formatted date and time in UTC (YYYY-MM-DD HH:mm). * - `day`: The day of the month as a number. * - `hour`: The hour in the 24-hour format as a number. * - `minute`: The minute of the hour as a number. @@ -324,8 +320,11 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get('@endMeridiemSelect').find('[aria-label="PM"]').click(); // --- Set timezone --- - cy.findByPlaceholderText('Choose a Timezone').as('timezoneInput').clear(); - cy.get('@timezoneInput').type('(GMT +0:00) Greenwich Mean Time{enter}'); + cy.findByPlaceholderText('Choose a Timezone').as('timezoneInput').click(); + cy.get('@timezoneInput').clear(); + cy.get('@timezoneInput') + .should('not.be.disabled') + .type('(GMT +0:00) Greenwich Mean Time{enter}'); // --- Apply date/time range --- cy.get('[data-qa-buttons="apply"]') diff --git a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts index 22f29d8c1c9..b0193b63cdf 100644 --- a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts @@ -204,9 +204,8 @@ describe('create a database cluster, mocked data', () => { }); cy.wait('@createDatabase'); - // TODO Update assertions upon completion of M3-7030. cy.url().should( - 'endWith', + 'contains', `/databases/${databaseMock.engine}/${databaseMock.id}` ); diff --git a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts index e035a3445b4..0418ab542b0 100644 --- a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts @@ -14,9 +14,13 @@ import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; describe('Create Destination', () => { - before(() => { + beforeEach(() => { mockAppendFeatureFlags({ - aclpLogs: { enabled: true, beta: true }, + aclpLogs: { + enabled: true, + beta: true, + bypassAccountCapabilities: true, + }, }); }); diff --git a/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts new file mode 100644 index 00000000000..1ca9b4cb643 --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts @@ -0,0 +1,288 @@ +import { streamType } from '@linode/api-v4'; +import { mockDestination } from 'support/constants/delivery'; +import { + mockCreateDestination, + mockCreateStream, + mockGetDestinations, + mockTestConnection, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetClusters } from 'support/intercepts/lke'; +import { ui } from 'support/ui'; +import { logsStreamForm } from 'support/ui/pages/logs-stream-form'; +import { randomLabel } from 'support/util/random'; + +import { kubernetesClusterFactory } from 'src/factories'; + +describe('Create Stream', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { + enabled: true, + beta: true, + bypassAccountCapabilities: true, + }, + }); + }); + + describe('given Audit Logs Stream Type', () => { + it('creates new destination and creates stream', () => { + // Mock API responses + mockGetDestinations([mockDestination]); + + // Visit the Create Stream page + cy.visitWithLogin('/logs/delivery/streams/create'); + + const streamName = randomLabel(); + + // Give the Name + logsStreamForm.setLabel(streamName); + + // Select Stream Type + logsStreamForm.selectStreamType(streamType.AuditLogs); + + // Fill out the Destination form + logsStreamForm.fillOutNewAkamaiObjectStorageDestination(); + + // Test connection button should be enabled + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button'); + + // Create Stream button should be disabled initially + cy.findByRole('button', { name: 'Create Stream' }).should('be.disabled'); + + // Test connection with failure + mockTestConnection(400); + ui.button.findByTitle('Test Connection').click(); + + ui.toast.assertMessage( + 'Delivery connection test failed. Verify your delivery settings and try again.' + ); + + // Create Stream button should remain disabled after failed test + cy.findByRole('button', { name: 'Create Stream' }).should('be.disabled'); + + // Test connection with success + mockTestConnection(200); + ui.button.findByTitle('Test Connection').click(); + + ui.toast.assertMessage( + 'Delivery connection test completed successfully. Data can now be sent using this configuration.' + ); + + // Create Stream button should now be enabled + cy.findByRole('button', { name: 'Create Stream' }) + .should('be.enabled') + .should('have.attr', 'type', 'button'); + + // Submit the stream create form - failure in creating destination + mockCreateDestination({}, 400); + cy.findByRole('button', { name: 'Create Stream' }) + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage(`There was an issue creating your destination`); + + // Submit the stream create form - success + mockCreateDestination(mockDestination); + mockCreateStream({ label: streamName }).as('createStream'); + + cy.findByRole('button', { name: 'Create Stream' }).click(); + cy.wait('@createStream') + .its('request.body') + .then((body) => { + expect(body).to.deep.equal({ + label: streamName, + type: streamType.AuditLogs, + destinations: [mockDestination.id], + details: null, + }); + }); + + ui.toast.assertMessage( + `${streamName} created successfully. Stream is being provisioned, which may take up to 45 minutes` + ); + cy.url().should('endWith', 'streams'); + }); + + it('selects existing destination and creates stream', () => { + // Mock API responses + mockGetDestinations([mockDestination]); + + // Visit the Create Stream page + cy.visitWithLogin('/logs/delivery/streams/create'); + + const streamName = randomLabel(); + + // Give the Name + logsStreamForm.setLabel(streamName); + + // Select Stream Type + logsStreamForm.selectStreamType(streamType.AuditLogs); + + // Select existing destination + logsStreamForm.selectExistingDestination(mockDestination.label); + + // Test Connection should be disabled for existing destination + ui.button + .findByTitle('Test Connection') + .should('be.disabled') + .should('have.attr', 'type', 'button'); + + // Create Stream should be enabled + cy.findByRole('button', { name: 'Create Stream' }) + .should('be.enabled') + .should('have.attr', 'type', 'button'); + + // Submit the stream create form - failure + mockCreateStream({}, 400).as('createStreamFail'); + + cy.findByRole('button', { name: 'Create Stream' }).click(); + cy.wait('@createStreamFail'); + ui.toast.assertMessage('There was an issue creating your stream'); + + // Submit the stream create form - success + mockCreateStream({ label: streamName }).as('createStream'); + + cy.findByRole('button', { name: 'Create Stream' }).click(); + cy.wait('@createStream') + .its('request.body') + .then((body) => { + expect(body).to.deep.equal({ + label: streamName, + type: streamType.AuditLogs, + destinations: [mockDestination.id], + details: null, + }); + }); + + ui.toast.assertMessage( + `${streamName} created successfully. Stream is being provisioned, which may take up to 45 minutes` + ); + cy.url().should('endWith', 'streams'); + }); + }); + + describe('given Kubernetes Audit Logs Stream Type', () => { + it('selects clusters and creates new stream', () => { + // Mock API responses + mockGetDestinations([mockDestination]); + mockGetClusters([ + kubernetesClusterFactory.build({ + id: 1, + label: 'cluster-1', + control_plane: { audit_logs_enabled: true }, + }), + kubernetesClusterFactory.build({ + id: 2, + label: 'cluster-2', + control_plane: { audit_logs_enabled: false }, + }), + kubernetesClusterFactory.build({ + id: 3, + label: 'cluster-3', + control_plane: { audit_logs_enabled: true }, + }), + ]); + + // Visit the Create Stream page + cy.visitWithLogin('/logs/delivery/streams/create'); + + const streamName = randomLabel(); + + // Give the Name + logsStreamForm.setLabel(streamName); + + // Select Stream Type + logsStreamForm.selectStreamType(streamType.LKEAuditLogs); + + // Select existing destination + logsStreamForm.selectExistingDestination(mockDestination.label); + + cy.findByText('Clusters').should('be.visible'); + cy.get('[data-testid="clusters-table"]').should('exist'); + + // Select cluster-1 and cluster-3 individually + logsStreamForm.findClusterCheckbox('cluster-1').check(); + + logsStreamForm.findClusterCheckbox('cluster-1').should('be.checked'); + logsStreamForm.findClusterCheckbox('cluster-3').check(); + logsStreamForm.findClusterCheckbox('cluster-3').should('be.checked'); + cy.findByLabelText('Toggle cluster-2 cluster').should('be.disabled'); + + // Unselect cluster-1 and cluster-3 individually + logsStreamForm.findClusterCheckbox('cluster-1').uncheck(); + logsStreamForm.findClusterCheckbox('cluster-1').should('not.be.checked'); + logsStreamForm.findClusterCheckbox('cluster-3').uncheck(); + logsStreamForm.findClusterCheckbox('cluster-3').should('not.be.checked'); + + // Use "Toggle all clusters" to select all eligible clusters + logsStreamForm.findClusterCheckbox('all').check(); + + logsStreamForm.findClusterCheckbox('all').should('be.checked'); + logsStreamForm.findClusterCheckbox('cluster-1').should('be.checked'); + logsStreamForm.findClusterCheckbox('cluster-3').should('be.checked'); + cy.findByLabelText('Toggle cluster-2 cluster').should('be.disabled'); + + // TODO: uncomment when "Automatically include all existing and recently configured clusters" feature is available + // Use "Toggle all clusters" to unselect all eligible clusters + // logsStreamForm.findClusterCheckbox('all').uncheck(); + // logsStreamForm.findClusterCheckbox('all').should('not.be.checked'); + // logsStreamForm.findClusterCheckbox('cluster-1').should('not.be.checked'); + // logsStreamForm.findClusterCheckbox('cluster-3').should('not.be.checked'); + + // Use "Automatically include all existing and recently configured clusters" to select all eligible clusters + // cy.findByLabelText( + // 'Automatically include all existing and recently configured clusters.' + // ) + // .should('exist') + // .should('be.enabled'); + // cy.findByLabelText( + // 'Automatically include all existing and recently configured clusters.' + // ).click(); + // + // logsStreamForm.findClusterCheckbox('all').should('be.disabled'); + // logsStreamForm + // .findClusterCheckbox('cluster-1') + // .should('be.disabled') + // .should('be.checked'); + // logsStreamForm + // .findClusterCheckbox('cluster-3') + // .should('be.disabled') + // .should('be.checked'); + // cy.findByLabelText('Toggle cluster-2 cluster').should('be.disabled'); + + // Select existing destination + logsStreamForm.selectExistingDestination(mockDestination.label); + + // Create Stream should be enabled + cy.findByRole('button', { name: 'Create Stream' }) + .should('be.enabled') + .should('have.attr', 'type', 'button'); + + // Submit the stream create form - success + mockCreateStream({ label: streamName }).as('createStream'); + cy.findByRole('button', { name: 'Create Stream' }).click(); + cy.wait('@createStream') + .its('request.body') + .then((body) => { + expect(body).to.deep.equal({ + label: streamName, + type: streamType.LKEAuditLogs, + destinations: [mockDestination.id], + details: { + cluster_ids: [1, 3], // TODO: change to is_auto_add_all_clusters_enabled: true when "Automatically include all existing and recently configured clusters" feature is available + }, + }); + }); + + ui.toast.assertMessage( + `${streamName} created successfully. Stream is being provisioned, which may take up to 45 minutes` + ); + cy.url().should('endWith', 'streams'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts index ff8de40f562..e9ad224226b 100644 --- a/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts @@ -8,6 +8,7 @@ describe('Destinations empty landing page', () => { aclpLogs: { enabled: true, beta: true, + bypassAccountCapabilities: true, }, }); }); diff --git a/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts index ea06005cebf..ab17f15ce77 100644 --- a/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts @@ -40,7 +40,10 @@ function checkActionMenu(tableAlias: string, mockDestinations: Destination[]) { }); } -function deleteItem(tableAlias: string, destination: Destination) { +function deleteDestinationViaActionMenu( + tableAlias: string, + destination: Destination +) { cy.get(tableAlias) .find('tbody tr') .should('contain', destination.label) @@ -70,7 +73,10 @@ function deleteItem(tableAlias: string, destination: Destination) { }); } -function editItemViaActionMenu(tableAlias: string, destination: Destination) { +function editDestinationViaActionMenu( + tableAlias: string, + destination: Destination +) { cy.get(tableAlias) .find('tbody tr') .should('contain', destination.label) @@ -99,7 +105,11 @@ const mockDestinations: Destination[] = new Array(3) describe('destinations landing checks for non-empty state', () => { beforeEach(() => { mockAppendFeatureFlags({ - aclpLogs: { enabled: true, beta: true }, + aclpLogs: { + enabled: true, + beta: true, + bypassAccountCapabilities: true, + }, }); // Mock setup to display the Destinations landing page in a non-empty state @@ -137,7 +147,7 @@ describe('destinations landing checks for non-empty state', () => { cy.get('table').should('exist').as('destinationsTable'); const exampleDestination = mockDestinations[0]; - deleteItem('@destinationsTable', exampleDestination); + deleteDestinationViaActionMenu('@destinationsTable', exampleDestination); mockGetDestination(exampleDestination).as('getDestination'); @@ -149,6 +159,6 @@ describe('destinations landing checks for non-empty state', () => { cy.visit('/logs/delivery/destinations'); cy.get('table').should('exist').as('destinationsTable'); cy.wait('@getDestinations'); - editItemViaActionMenu('@destinationsTable', exampleDestination); + editDestinationViaActionMenu('@destinationsTable', exampleDestination); }); }); diff --git a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts index ffce8d67d0c..5c9f4ba619e 100644 --- a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts @@ -21,7 +21,11 @@ import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; describe('Edit Destination', () => { beforeEach(() => { mockAppendFeatureFlags({ - aclpLogs: { enabled: true, beta: true }, + aclpLogs: { + enabled: true, + beta: true, + bypassAccountCapabilities: true, + }, }); cy.visitWithLogin(`/logs/delivery/destinations/${mockDestination.id}/edit`); mockGetDestination(mockDestination); @@ -43,8 +47,8 @@ describe('Edit Destination', () => { mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended ); - // Create Destination should be disabled before test connection - cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Save button should be disabled before test connection + cy.findByRole('button', { name: 'Save' }).should('be.disabled'); // Test connection of the destination form mockTestConnection(400); ui.button @@ -57,8 +61,8 @@ describe('Edit Destination', () => { 'Delivery connection test failed. Verify your delivery settings and try again.' ); - // Create Destination should be disabled after test connection failed - cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Save button should be disabled after test connection failed + cy.findByRole('button', { name: 'Save' }).should('be.disabled'); }); it('edit destination with correct data', () => { @@ -70,8 +74,8 @@ describe('Edit Destination', () => { mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended ); - // Create Destination should be disabled before test connection - cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Save button should be disabled before test connection + cy.findByRole('button', { name: 'Save' }).should('be.disabled'); // Test connection of the destination form mockTestConnection(); ui.button @@ -88,7 +92,7 @@ describe('Edit Destination', () => { mockUpdateDestination(mockDestinationPayloadWithId, updatedDestination); mockGetDestinations([updatedDestination]); // Submit the destination edit form - cy.findByRole('button', { name: 'Edit Destination' }) + cy.findByRole('button', { name: 'Save' }) .should('be.enabled') .should('have.attr', 'type', 'button') .click(); diff --git a/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts new file mode 100644 index 00000000000..9006b1935be --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts @@ -0,0 +1,285 @@ +import { + mockAuditLogsStream, + mockAuditLogsStreamPayload, + mockDestination, + mockLKEAuditLogsStream, +} from 'support/constants/delivery'; +import { + mockCreateDestination, + mockGetDestination, + mockGetDestinations, + mockGetStream, + mockGetStreams, + mockTestConnection, + mockUpdateStream, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetClusters } from 'support/intercepts/lke'; +import { ui } from 'support/ui'; +import { logsStreamForm } from 'support/ui/pages/logs-stream-form'; +import { randomLabel } from 'support/util/random'; + +import { kubernetesClusterFactory } from 'src/factories'; + +describe('Edit Stream', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { + enabled: true, + beta: true, + bypassAccountCapabilities: true, + }, + }); + }); + + describe('given Audit Logs Stream Type', () => { + it('edits stream label and destination and saves', () => { + // Mock API responses + mockGetDestinations([mockDestination]); + mockGetDestination(mockDestination); + mockGetStreams([mockAuditLogsStream]); + mockGetStream(mockAuditLogsStream); + + // Visit the Edit Stream page + cy.visitWithLogin( + `/logs/delivery/streams/${mockAuditLogsStream.id}/edit/` + ); + + const updatedLabel = randomLabel(); + + // Change the Name + cy.findByLabelText('Name') + .should('be.visible') + .should('be.enabled') + .should('have.value', mockAuditLogsStream.label); + + logsStreamForm.setLabel(updatedLabel); + + cy.findByLabelText('Name') + .should('be.visible') + .should('be.enabled') + .should('have.value', updatedLabel); + + // Stream Type should be disabled + cy.findByLabelText('Stream Type') + .should('be.visible') + .should('be.disabled') + .should('have.attr', 'value', 'Audit Logs'); + + // Save button should be enabled initially + ui.button.findByTitle('Save').should('be.enabled'); + + // Test Connection should be disabled for existing destination + ui.button.findByTitle('Test Connection').should('be.disabled'); + + const newDestinationLabel = randomLabel(); + // Change Destination to a new one + logsStreamForm.fillOutNewAkamaiObjectStorageDestination( + newDestinationLabel + ); + + // Test connection button should be enabled + ui.button.findByTitle('Test Connection').should('be.enabled'); + + // Save button should be disabled after changing destination + ui.button.findByTitle('Save').should('be.disabled'); + + // Test connection with failure + mockTestConnection(400); + ui.button.findByTitle('Test Connection').click(); + + ui.toast.assertMessage( + 'Delivery connection test failed. Verify your delivery settings and try again.' + ); + + // Save button should remain disabled after failed test + ui.button.findByTitle('Save').should('be.disabled'); + + // Test connection with success + mockTestConnection(200); + ui.button.findByTitle('Test Connection').click(); + + ui.toast.assertMessage( + 'Delivery connection test completed successfully. Data can now be sent using this configuration.' + ); + + // Save button should now be enabled + ui.button.findByTitle('Save').should('be.enabled'); + + // Submit the stream edit form - failure in creating destination + mockCreateDestination({}, 400); + ui.button.findByTitle('Save').should('be.enabled').click(); + + ui.toast.assertMessage(`There was an issue creating your destination`); + + // Submit the stream edit form - success + mockCreateDestination(mockDestination); + mockUpdateStream( + { + ...mockAuditLogsStreamPayload, + id: mockAuditLogsStream.id, + status: 'active', + }, + mockAuditLogsStream + ).as('updateStream'); + + ui.button.findByTitle('Save').click(); + cy.wait('@updateStream') + .its('request.body') + .then((body) => { + expect(body).to.deep.equal({ + label: updatedLabel, + status: 'active', + destinations: [mockDestination.id], + details: null, + }); + }); + + ui.toast.assertMessage( + `Destination ${newDestinationLabel} created successfully` + ); + ui.toast.assertMessage(`Stream ${updatedLabel} edited successfully`); + cy.url().should('endWith', 'streams'); + }); + }); + + describe('given Kubernetes Audit Logs Stream Type', () => { + it('edits stream label and clusters and saves', () => { + // Mock API responses + mockGetDestinations([mockDestination]); + mockGetDestination(mockDestination); + mockGetStreams([mockLKEAuditLogsStream]); + mockGetStream(mockLKEAuditLogsStream); + mockGetClusters([ + kubernetesClusterFactory.build({ + id: 1, + label: 'cluster-1', + control_plane: { audit_logs_enabled: true }, + }), + kubernetesClusterFactory.build({ + id: 2, + label: 'cluster-2', + control_plane: { audit_logs_enabled: false }, + }), + kubernetesClusterFactory.build({ + id: 3, + label: 'cluster-3', + control_plane: { audit_logs_enabled: true }, + }), + ]); + + // Visit the Edit Stream page + cy.visitWithLogin( + `/logs/delivery/streams/${mockLKEAuditLogsStream.id}/edit/` + ); + + const updatedLabel = randomLabel(); + + // Change the Name + cy.findByLabelText('Name') + .should('be.visible') + .should('be.enabled') + .should('have.value', mockLKEAuditLogsStream.label); + + logsStreamForm.setLabel(updatedLabel); + + cy.findByLabelText('Name') + .should('be.visible') + .should('be.enabled') + .should('have.value', updatedLabel); + + // Stream Type should be disabled + cy.findByLabelText('Stream Type') + .should('be.visible') + .should('be.disabled') + .should('have.attr', 'value', 'Kubernetes Audit Logs'); + + // Clusters table should be visible + cy.findByText('Clusters').should('be.visible'); + cy.get('[data-testid="clusters-table"]').should('exist'); + + // Initially selected clusters should be checked + logsStreamForm.findClusterCheckbox('cluster-1').should('be.checked'); + logsStreamForm.findClusterCheckbox('cluster-3').should('be.checked'); + + // Deselect clusters + logsStreamForm.findClusterCheckbox('cluster-1').uncheck(); + logsStreamForm.findClusterCheckbox('cluster-3').uncheck(); + + // Clusters should be unchecked + logsStreamForm.findClusterCheckbox('cluster-1').should('not.be.checked'); + logsStreamForm.findClusterCheckbox('cluster-3').should('not.be.checked'); + + // TODO: uncomment when "Automatically include all existing and recently configured clusters" feature is available + // Use "Automatically include all existing and recently configured clusters" to select all eligible clusters + // cy.findByLabelText( + // 'Automatically include all existing and recently configured clusters.' + // ).click(); + // logsStreamForm.findClusterCheckbox('all').should('be.disabled'); + // logsStreamForm + // .findClusterCheckbox('cluster-1') + // .should('be.disabled') + // .should('be.checked'); + // logsStreamForm + // .findClusterCheckbox('cluster-3') + // .should('be.disabled') + // .should('be.checked'); + + // Use "Toggle all clusters" to select all eligible clusters + logsStreamForm.findClusterCheckbox('all').check(); + + // Save button should be enabled + ui.button.findByTitle('Save').should('be.enabled'); + + // Submit the stream edit form - failure + mockUpdateStream( + { + id: mockLKEAuditLogsStream.id, + label: updatedLabel, + status: 'active', + destinations: [mockDestination.id], + details: { + is_auto_add_all_clusters_enabled: true, + }, + }, + {}, + 400 + ).as('updateStreamFail'); + + ui.button.findByTitle('Save').click(); + cy.wait('@updateStreamFail'); + ui.toast.assertMessage('There was an issue editing your stream'); + + // Submit the stream edit form - success + mockUpdateStream( + { + id: mockLKEAuditLogsStream.id, + label: updatedLabel, + status: 'active', + destinations: [mockDestination.id], + details: { + cluster_ids: [1, 3], // TODO: change to is_auto_add_all_clusters_enabled: true when "Automatically include all existing and recently configured clusters" feature is available + }, + }, + mockLKEAuditLogsStream + ).as('updateStream'); + + ui.button.findByTitle('Save').click(); + cy.wait('@updateStream') + .its('request.body') + .then((body) => { + expect(body).to.deep.equal({ + label: updatedLabel, + status: 'active', + destinations: [mockDestination.id], + details: { + cluster_ids: [1, 3], // TODO: change to is_auto_add_all_clusters_enabled: true when "Automatically include all existing and recently configured clusters" feature is available + }, + }); + }); + + ui.toast.assertMessage(`Stream ${updatedLabel} edited successfully`); + cy.url().should('endWith', 'streams'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/streams-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/streams-empty-landing-page.spec.ts new file mode 100644 index 00000000000..3b24062ea15 --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/streams-empty-landing-page.spec.ts @@ -0,0 +1,36 @@ +import { mockGetStreams } from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +describe('Streams empty landing page', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { + enabled: true, + beta: true, + bypassAccountCapabilities: true, + }, + }); + }); + + it('shows the empty state when there are no streams', () => { + mockGetStreams([]).as('getStreams'); + + cy.visitWithLogin('/logs/delivery/streams'); + cy.wait(['@getStreams']); + + // Empty state message is displayed when no streams exist + cy.findByText( + 'Create a stream and configure delivery of cloud logs' + ).should('be.visible'); + + // Click the Create Stream button and verify navigation to the stream creation page + ui.button + .findByTitle('Create Stream') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/logs/delivery/streams/create'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/streams-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/streams-non-empty-landing-page.spec.ts new file mode 100644 index 00000000000..ad9df1e518b --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/streams-non-empty-landing-page.spec.ts @@ -0,0 +1,191 @@ +import { mockDestination } from 'support/constants/delivery'; +import { + mockDeleteStream, + mockGetDestinations, + mockGetStream, + mockGetStreams, + mockUpdateStream, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +import { streamFactory } from 'src/factories'; + +import type { Stream } from '@linode/api-v4'; + +function checkActionMenu(tableAlias: string, mockStreams: Stream[]) { + mockStreams.forEach((stream) => { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', stream.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Stream ${stream.label}`) + .should('be.visible') + .click(); + + // Check that all items are enabled + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled'); + + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled'); + + ui.actionMenuItem + .findByTitle('Deactivate') + .should('be.visible') + .should('be.enabled'); + }); + + // Close the action menu + cy.get('body').click(0, 0); + }); +} + +function deleteStreamViaActionMenu(tableAlias: string, stream: Stream) { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', stream.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Stream ${stream.label}`) + .should('be.visible') + .click(); + + mockDeleteStream(404).as('deleteStream'); + + // Delete stream + ui.actionMenuItem.findByTitle('Delete').click(); + + // Find confirmation modal + cy.findByText( + `Are you sure you want to delete "${stream.label}" stream?` + ); + ui.button.findByTitle('Delete').click(); + + cy.wait('@deleteStream'); + + // Close confirmation modal after failure + ui.button.findByTitle('Cancel').click(); + }); +} + +function editStreamViaActionMenu(tableAlias: string, stream: Stream) { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', stream.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Stream ${stream.label}`) + .should('be.visible') + .click(); + + mockGetStream(stream); + // Edit stream redirect + ui.actionMenuItem.findByTitle('Edit').click(); + cy.url().should('endWith', `/streams/${stream.id}/edit`); + }); +} + +function deactivateStreamViaActionMenu(tableAlias: string, stream: Stream) { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', stream.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Stream ${stream.label}`) + .should('be.visible') + .click(); + + mockUpdateStream({ + ...stream, + destinations: stream.destinations.map(({ id }) => id), + id: stream.id, + status: 'inactive', + }); + + // Deactivate stream + ui.actionMenuItem.findByTitle('Deactivate').click(); + + ui.toast.assertMessage(`Stream ${stream.label} deactivated`); + }); +} + +const mockStreams: Stream[] = new Array(3) + .fill(null) + .map((_item: null, index: number): Stream => { + return streamFactory.build({ + label: `Stream ${index}`, + }); + }); + +describe('Streams non-empty landing page', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { + enabled: true, + beta: true, + bypassAccountCapabilities: true, + }, + }); + + // Mock setup to display the Streams landing page in a non-empty state + mockGetStreams(mockStreams).as('getStreams'); + + // Alias the mockStreams array + cy.wrap(mockStreams).as('mockStreams'); + }); + + it('create stream button is enabled and user can see existing streams', () => { + // Login and wait for application to load + cy.visitWithLogin('/logs/delivery/streams'); + cy.wait('@getStreams'); + cy.url().should('endWith', '/streams'); + + cy.get('table').should('exist').as('streamTable'); + + // Assert that the Create Stream button is visible and enabled + ui.button + .findByTitle('Create Stream') + .should('be.visible') + .and('be.enabled'); + + // Assert that the correct number of Streams entries are present in the table + cy.get('@streamTable') + .find('tbody tr') + .should('have.length', mockStreams.length); + + checkActionMenu('@streamTable', mockStreams); + }); + + it('checks actions from stream menu actions and stream name', () => { + cy.visitWithLogin('/logs/delivery/streams'); + cy.wait('@getStreams'); + cy.get('table').should('exist').as('streamsTable'); + + const exampleStream = mockStreams[0]; + deleteStreamViaActionMenu('@streamsTable', exampleStream); + deactivateStreamViaActionMenu('@streamsTable', exampleStream); + + mockGetStream(exampleStream).as('getStream'); + mockGetDestinations([mockDestination]).as('getDestinations'); + + // Redirect to stream edit page via name + cy.findByText(exampleStream.label).click(); + cy.url().should('endWith', `/streams/${exampleStream.id}/edit`); + cy.wait(['@getStream', '@getDestinations']); + + // Redirect to stream edit page via menu item + cy.visit('/logs/delivery/streams'); + cy.get('table').should('exist').as('streamsTable'); + editStreamViaActionMenu('@streamsTable', exampleStream); + }); +}); diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index e3d328b43b3..b92602d8b6b 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -16,12 +16,17 @@ import { mockGetChildAccounts, mockGetChildAccountsError, mockGetInvoices, + mockGetMaintenance, mockGetPaymentMethods, mockGetPayments, mockGetUser, } from 'support/intercepts/account'; import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; import { mockAllApiRequests } from 'support/intercepts/general'; +import { + mockGetRolePermissionsError, + mockGetUserAccountPermissionsError, +} from 'support/intercepts/iam'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetProfile, @@ -426,6 +431,9 @@ describe('Parent/Child account switching', () => { // We'll mitigate this by broadly mocking ALL API-v4 requests, then applying more specific mocks to the // individual requests as needed. mockAllApiRequests(); + mockGetRolePermissionsError('Not found', 404); + mockGetUserAccountPermissionsError('Not found', 404); + mockGetMaintenance([], []); mockGetLinodes([]); mockGetRegions([]); mockGetEvents([]); @@ -433,6 +441,7 @@ describe('Parent/Child account switching', () => { mockGetAccount(mockParentAccount); mockGetProfile(mockParentProfile); mockGetUser(mockParentUser); + mockGetChildAccounts([]); mockGetPaymentMethods(paymentMethodFactory.buildList(1)).as( 'getPaymentMethods' ); @@ -511,6 +520,8 @@ describe('Parent/Child account switching', () => { // We'll mitigate this by broadly mocking ALL API-v4 requests, then applying more specific mocks to the // individual requests as needed. mockAllApiRequests(); + mockGetRolePermissionsError('Not found', 404); + mockGetUserAccountPermissionsError('Not found', 404); mockGetLinodes([]); mockGetRegions([]); mockGetEvents([]); diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index a0e95b46b21..01a7dbe891e 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -11,6 +11,7 @@ import { import { ui } from 'support/ui'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; +import { chooseImage } from 'support/util/images'; import { createTestLinode } from 'support/util/linodes'; import { pollImageStatus, @@ -185,8 +186,9 @@ describe('Create stackscripts', () => { it('creates a StackScript and deploys a Linode with it', () => { const stackscriptLabel = randomLabel(); const stackscriptDesc = randomPhrase(); - const stackscriptImage = 'Alpine 3.19'; - + // use random image. can specify image w/ getImageByLabel, then set images option in chooseImage + const randomImage = chooseImage(); + const stackscriptImage = randomImage.label; const linodeLabel = randomLabel(); const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index e44b741e5ce..183c0af195e 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -11,6 +11,8 @@ import { ui } from 'support/ui'; import { depaginate } from 'support/util/paginate'; import { randomLabel, randomPhrase } from 'support/util/random'; +import { isImageDeprecated } from 'src/components/ImageSelect/utilities'; + import type { Image, StackScript } from '@linode/api-v4'; // StackScript fixture paths. @@ -98,7 +100,7 @@ describe('Update stackscripts', () => { getImages({ page }, { is_public: true }) ); return allPublicImages.find( - (image) => image.vendor === 'Alpine' && image.deprecated === false + (image) => image.vendor === 'Alpine' && !isImageDeprecated(image) ); }; 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 6a5615b8c00..8454fa75054 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 @@ -119,7 +119,7 @@ describe('VPC assign/unassign flows', () => { .click(); }); - cy.wait(['@createSubnet', '@getVPC', '@getSubnets', '@getLinodes']); + cy.wait(['@createSubnet', '@getVPC', '@getSubnets']); mockGetSubnet(mockVPC.id, mockSubnet.id, mockSubnet); @@ -139,6 +139,8 @@ describe('VPC assign/unassign flows', () => { .should('be.visible') .click(); + cy.wait(['@getLinodes']); + ui.drawer .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label}`) .should('be.visible') @@ -395,7 +397,7 @@ describe('VPC assign/unassign flows', () => { mockGetLinodes([mockLinode, mockSecondLinode]).as('getLinodes'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getVPC', '@getSubnets', '@getLinodes', '@getFeatureFlags']); + cy.wait(['@getVPC', '@getSubnets', '@getFeatureFlags']); // confirm that subnet should get displayed on VPC's detail page cy.findByText(mockVPC.label).should('be.visible'); @@ -415,6 +417,8 @@ describe('VPC assign/unassign flows', () => { .should('be.visible') .click(); + cy.wait(['@getLinodes']); + ui.drawer .findByTitle( `Unassign Linodes from subnet: ${mockSubnet.label} (0.0.0.0/0)` diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts index 1d3a03f5e36..ad60d8d68fd 100644 --- a/packages/manager/cypress/support/constants/delivery.ts +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -1,11 +1,16 @@ -import { destinationType } from '@linode/api-v4'; +import { destinationType, streamType } from '@linode/api-v4'; import { randomLabel, randomString } from 'support/util/random'; -import { destinationFactory } from 'src/factories'; +import { destinationFactory, streamFactory } from 'src/factories'; -import type { Destination } from '@linode/api-v4'; +import type { + CreateDestinationPayload, + CreateStreamPayload, + Destination, + Stream, +} from '@linode/api-v4'; -export const mockDestinationPayload = { +export const mockDestinationPayload: CreateDestinationPayload = { label: randomLabel(), type: destinationType.AkamaiObjectStorage, details: { @@ -27,3 +32,33 @@ export const mockDestinationPayloadWithId = { id: mockDestination.id, ...mockDestinationPayload, }; + +export const mockAuditLogsStreamPayload: CreateStreamPayload = { + label: randomLabel(), + type: streamType.AuditLogs, + destinations: [mockDestination.id], + details: null, +}; + +export const mockAuditLogsStream: Stream = streamFactory.build({ + ...mockAuditLogsStreamPayload, + id: 122, + destinations: [mockDestination], + version: '1.0', +}); + +export const mockLKEAuditLogsStreamPayload: CreateStreamPayload = { + label: randomLabel(), + type: streamType.LKEAuditLogs, + destinations: [mockDestination.id], + details: { + cluster_ids: [1, 3], + }, +}; + +export const mockLKEAuditLogsStream: Stream = streamFactory.build({ + ...mockLKEAuditLogsStreamPayload, + id: 123, + destinations: [mockDestination], + version: '1.0', +}); diff --git a/packages/manager/cypress/support/intercepts/delivery.ts b/packages/manager/cypress/support/intercepts/delivery.ts index 03dff6f8db8..6e01a8671ee 100644 --- a/packages/manager/cypress/support/intercepts/delivery.ts +++ b/packages/manager/cypress/support/intercepts/delivery.ts @@ -8,7 +8,9 @@ import { makeResponse } from 'support/util/response'; import type { Destination, + Stream, UpdateDestinationPayloadWithId, + UpdateStreamPayloadWithId, } from '@linode/api-v4'; /** @@ -45,24 +47,6 @@ export const mockGetDestinations = ( ); }; -/** - * Intercepts POST request to create a Destination record. - * - * @returns Cypress chainable. - */ -export const interceptCreateDestination = (): Cypress.Chainable => { - return cy.intercept('POST', apiMatcher('monitor/streams/destinations*')); -}; - -/** - * Intercepts DELETE request to delete Destination record. - * - * @returns Cypress chainable. - */ -export const interceptDeleteDestination = (): Cypress.Chainable => { - return cy.intercept('DELETE', apiMatcher(`monitor/streams/destinations/*`)); -}; - /** * Intercepts PUT request to update a destination and mocks response. * @@ -134,3 +118,106 @@ export const mockDeleteDestination = ( makeResponse({}, responseCode) ); }; + +/** + * Intercepts POST request to create a Destination record. + * + * @returns Cypress chainable. + */ +export const interceptCreateDestination = (): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher('monitor/streams/destinations*')); +}; + +/** + * Intercepts DELETE request to delete Destination record. + * + * @returns Cypress chainable. + */ +export const interceptDeleteDestination = (): Cypress.Chainable => { + return cy.intercept('DELETE', apiMatcher(`monitor/streams/destinations/*`)); +}; + +/** + * Intercepts POST request to create a stream and mocks response. + * + * @param responseCode + * @param responseBody - Full stream object returned when created. + * + * @returns Cypress chainable. + */ +export const mockCreateStream = ( + responseBody = {}, + responseCode = 200 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`monitor/streams`), + makeResponse(responseBody, responseCode) + ); +}; + +/** + * Intercepts POST request to update a stream and mocks response. + * + * @param stream - Stream data to update. + * @param responseBody - Full updated stream object. + * @param responseCode + * + * @returns Cypress chainable. + */ +export const mockUpdateStream = ( + stream: UpdateStreamPayloadWithId, + responseBody = {}, + responseCode = 200 +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`monitor/streams/${stream.id}`), + makeResponse(responseBody, responseCode) + ); +}; + +/** + * Intercepts GET request to fetch stream instance and mocks response. + * + * @param stream - Response stream. + * + * @returns Cypress chainable. + */ +export const mockGetStream = (stream: Stream): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`monitor/streams/${stream.id}`), + makeResponse(stream) + ); +}; + +/** + * Intercepts GET request to mock stream data. + * + * @param streams - an array of mock stream objects. + * + * @returns Cypress chainable. + */ +export const mockGetStreams = (streams: Stream[]): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('monitor/streams*'), + paginateResponse(streams) + ); +}; + +/** + * Intercept DELETE mock request to delete a Stream record. + * + * @returns Cypress chainable. + */ +export const mockDeleteStream = ( + responseCode = 200 +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`monitor/streams/*`), + makeResponse({}, responseCode) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/iam.ts b/packages/manager/cypress/support/intercepts/iam.ts new file mode 100644 index 00000000000..8049507896d --- /dev/null +++ b/packages/manager/cypress/support/intercepts/iam.ts @@ -0,0 +1,37 @@ +import { makeErrorResponse } from 'support/util/errors'; +import { apiMatcher } from 'support/util/intercepts'; +import { makeResponse } from 'support/util/response'; + +import type { PermissionType } from '@linode/api-v4'; + +export const mockGetUserAccountPermissions = ( + userAccountPermissions: PermissionType[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('iam/users/*/permissions/account'), + makeResponse(userAccountPermissions) + ); +}; + +export const mockGetUserAccountPermissionsError = ( + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('iam/users/*/permissions/account'), + makeErrorResponse(errorMessage, statusCode) + ); +}; + +export const mockGetRolePermissionsError = ( + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('iam/role-permissions'), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/cypress/support/plugins/fetch-linode-images.ts b/packages/manager/cypress/support/plugins/fetch-linode-images.ts new file mode 100644 index 00000000000..7edf07677ee --- /dev/null +++ b/packages/manager/cypress/support/plugins/fetch-linode-images.ts @@ -0,0 +1,20 @@ +import { getImages } from '@linode/api-v4'; + +import type { CypressPlugin } from './plugin'; +import type { Image, ResourcePage } from '@linode/api-v4'; + +/** + * Fetches and stores Linode image data in Cypress environment object. + */ +export const fetchLinodeImages: CypressPlugin = async (_, config) => { + const data: ResourcePage = await getImages({ page_size: 500 }); + + const images = data.data; + return { + ...config, + env: { + ...config.env, + cloudManagerImages: images, + }, + }; +}; diff --git a/packages/manager/cypress/support/ui/pages/logs-stream-form.ts b/packages/manager/cypress/support/ui/pages/logs-stream-form.ts new file mode 100644 index 00000000000..7f2ac7f42dc --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/logs-stream-form.ts @@ -0,0 +1,104 @@ +import { mockDestinationPayload } from 'support/constants/delivery'; +/** + * @file Page utilities for Logs Delivery Stream Form. + * Create/Edit Stream Page + */ +import { ui } from 'support/ui'; +import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; +import { randomLabel } from 'support/util/random'; + +import { getStreamTypeOption } from 'src/features/Delivery/deliveryUtils'; + +import type { + AkamaiObjectStorageDetailsExtended, + StreamType, +} from '@linode/api-v4'; + +export const logsStreamForm = { + /** + * Sets stream's label + * + * @param label - stream label to set + */ + setLabel: (label: string) => { + cy.findByLabelText('Name') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Stream name') + .clear(); + cy.focused().type(label); + }, + + /** + * Selects stream type + * + * @param type - stream type to set + */ + selectStreamType: (type: StreamType) => { + // Find Stream Type select and open it + cy.findByLabelText('Stream Type') + .should('be.visible') + .should('be.enabled') + .click(); + // Select the Stream Type + ui.autocompletePopper + .findByTitle(getStreamTypeOption(type)!.label) + .should('be.visible') + .should('be.enabled') + .click(); + }, + + /** + * Selects destination label from Destination Name autocomplete + * + * @param label - destination name to select + */ + selectExistingDestination: (label: string) => { + cy.findByLabelText('Destination Name') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Create or Select Destination Name') + .clear(); + // Select the Destination Name + ui.autocompletePopper + .findByTitle(label) + .should('be.visible') + .should('be.enabled') + .click(); + }, + + /** + * Finds the cluster checkbox by its label text or 'all' for the toggle all clusters checkbox. + * + * @param label - Cluster label or 'all' for the toggle all clusters checkbox. + * @returns Cypress chainable for the checkbox input. + */ + findClusterCheckbox: (label: string) => { + const ariaLabel = + label === 'all' ? 'Toggle all clusters' : `Toggle ${label} cluster`; + return cy.findByLabelText(ariaLabel).find('input[type="checkbox"]'); + }, + + /** + * Fills all form fields related to destination's details (AkamaiObjectStorageDetails type) + * + * @param label - new destination label to set + * @param details - object with destination details of AkamaiObjectStorageDetails type + */ + fillOutNewAkamaiObjectStorageDestination: ( + label = randomLabel(), + details: AkamaiObjectStorageDetailsExtended = mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended + ) => { + // Create new destination label + cy.findByLabelText('Destination Name') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Create or Select Destination Name') + .clear(); + cy.focused().type(label); + cy.findByText(new RegExp(`"${label}"`)).click(); + + // Fills all form fields related to destination's details + logsDestinationForm.fillDestinationDetailsForm(details); + }, +}; diff --git a/packages/manager/cypress/support/util/images.ts b/packages/manager/cypress/support/util/images.ts new file mode 100644 index 00000000000..03b066de027 --- /dev/null +++ b/packages/manager/cypress/support/util/images.ts @@ -0,0 +1,138 @@ +import { randomItem } from 'support/util/random'; + +import { isImageDeprecated } from 'src/components/ImageSelect/utilities'; + +import type { Image, ImageCapabilities } from '@linode/api-v4'; +/** + * Images that cannot be selected using `chooseImages()`. + */ +const disallowedImageIds: string[] = []; + +/** + * All Linode images available to the current Cloud Manager user. + * + * Retrieved via Linode APIv4 during Cypress start-up. + */ +export const images: Image[] = Cypress.env('cloudManagerImages') as Image[]; + +/** + * Returns a known Cloud Manager image at random, or returns a user-chosen + * image if one was specified. + * + * @param options - Image selection options. + * + * @returns Object describing a Cloud Manager image to use during tests. + */ +export const chooseImage = (options?: ChooseImageOptions): Image => { + return randomItem(resolveSearchImages(options)); +}; + +/** + * Returns an array of Image objects that meet the given criteria. + * + * @param options - Object describing Image selection criteria. + * + * @throws If no images meet the desired criteria. + * @throws If an override image is specified which does not meet the given criteria. + * + * @returns Array of Image objects that meet criteria specified by `options` param. + */ +const resolveSearchImages = (options?: ChooseImageOptions): Image[] => { + const imageFixtures = options?.images ?? images; + const currentImages = imageFixtures.filter( + (image) => !isImageDeprecated(image) + ); + const requiredCapabilities = options?.capabilities ?? []; + const allDisallowedImageIds = [ + ...disallowedImageIds, + ...(options?.exclude ?? []), + ]; + const capableImages = imagesWithCapabilities( + currentImages, + requiredCapabilities + ).filter((image: Image) => !allDisallowedImageIds.includes(image.id)); + + if (!capableImages.length) { + throw new Error( + `No images are available with the required capabilities: ${requiredCapabilities.join( + ', ' + )}` + ); + } + return capableImages; +}; + +/** + * Returns `true` if the given Image has all of the given capabilities and availability for each capability. + * + * @param image - Image to check capabilities. + * @param capabilities - ImageCapabilities to check. + * + * @returns `true` if `image` has all of the given capabilities. + */ +const imageHasCapabilities = ( + image: Image, + capabilities: ImageCapabilities[] +): boolean => { + return capabilities.every((capability) => + image.capabilities.includes(capability) + ); +}; + +/** + * Returns an array of Image objects that have all of the given capabilities. + * + * @param images - Images from which to search. + * @param capabilities - ImageCapabilities to check. + * + * @returns Array of Image objects containing the required capabilities. + */ +const imagesWithCapabilities = ( + images: Image[], + capabilities: ImageCapabilities[] +): Image[] => { + return images.filter((image: Image) => + imageHasCapabilities(image, capabilities) + ); +}; + +/** + * Returns an object describing a Cloud Manager image with the given label. + * + * If no known image exists with the given human-readable label, an error is + * thrown. + * + * @param label - Label (API or Cloud-specific) of the image to find. + * @param searchImages - Optional array of Images from which to search. + * + * @throws When no image exists in the `images` array with the given label. + */ +export const getImageByLabel = (label: string, searchImages?: Image[]) => { + const image = (searchImages ?? images).find( + (findImage: Image) => findImage.label === label + ); + if (!image) { + throw new Error( + `Unable to find image by label. Unknown image label '${label}'.` + ); + } + return image; +}; + +interface ChooseImageOptions { + /** + * If specified, the image returned will support the defined capabilities + * @example ['cloud-init', 'distributed-sites'] + */ + capabilities?: ImageCapabilities[]; + + /** + * Array of image IDs to exclude from results. + */ + exclude?: string[]; + + /** + * Images from which to choose. If unspecified, Images exposed by the API will be used. + */ + images?: Image[]; +} diff --git a/packages/manager/package.json b/packages/manager/package.json index 4079788a8d8..d3b16ad2d23 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.154.0", + "version": "1.155.0", "private": true, "type": "module", "bugs": { diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index b752d7794d3..baae0362351 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -11,6 +11,7 @@ export interface Action { hidden?: boolean; id?: string; onClick: () => void; + pendoId?: string; title: string; tooltip?: string; } @@ -32,6 +33,10 @@ export interface ActionMenuProps { * A function that is called when the Menu is opened. Useful for analytics. */ onOpen?: () => void; + /** + * Pendo ID to be added to ActionMenu IconButton via data-pendo-id attribute + */ + pendoId?: string; /** * If true, stop event propagation when handling clicks * Ex: If the action menu is in an accordion, we don't want the click also opening/closing the accordion @@ -45,8 +50,14 @@ export interface ActionMenuProps { * No more than 8 items should be displayed within an action menu. */ export const ActionMenu = React.memo((props: ActionMenuProps) => { - const { actionsList, ariaLabel, loading, onOpen, stopClickPropagation } = - props; + const { + actionsList, + ariaLabel, + loading, + onOpen, + pendoId, + stopClickPropagation, + } = props; const filteredActionsList = actionsList.filter((action) => !action.hidden); @@ -102,6 +113,7 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { aria-haspopup="true" aria-label={ariaLabel} color="inherit" + data-pendo-id={pendoId} id={buttonId} loading={loading} loadingIndicator={} @@ -159,6 +171,7 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { > {filteredActionsList.map((a, idx) => ( void; tooltipText?: string; diff --git a/packages/manager/src/components/PaginationFooter/PaginationFooter.test.tsx b/packages/manager/src/components/PaginationFooter/PaginationFooter.test.tsx index 055bc14998e..b7663090f32 100644 --- a/packages/manager/src/components/PaginationFooter/PaginationFooter.test.tsx +++ b/packages/manager/src/components/PaginationFooter/PaginationFooter.test.tsx @@ -1,3 +1,10 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PaginationFooter } from './PaginationFooter'; import { PAGE_SIZES } from './PaginationFooter.constants'; import { getMinimumPageSizeForNumberOfItems } from './PaginationFooter.utils'; @@ -13,3 +20,27 @@ describe('getMinimumPageSizeForNumberOfItems', () => { expect(getMinimumPageSizeForNumberOfItems(100, [25, 50])).toBe(Infinity); }); }); + +describe('PaginationFooter component', () => { + it('renders custom page size options when provided', async () => { + renderWithTheme( + + ); + + const select = screen.getByLabelText('Number of items to show'); + await userEvent.click(select); + + expect(screen.getByRole('option', { name: 'Show 15' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Show 25' })).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx index 8e9f20e9157..98796549440 100644 --- a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx +++ b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx @@ -17,7 +17,13 @@ export interface PaginationProps { sx?: SxProps; } +interface PaginationOption { + label: string; + value: number; +} + interface Props extends PaginationProps { + customOptions?: PaginationOption[]; handlePageChange: (page: number) => void; handleSizeChange: (pageSize: number) => void; } @@ -33,6 +39,7 @@ export const PaginationFooter = (props: Props) => { const theme = useTheme(); const { count, + customOptions, fixedSize, handlePageChange, handleSizeChange, @@ -46,7 +53,7 @@ export const PaginationFooter = (props: Props) => { return null; } - const finalOptions = [...baseOptions]; + const finalOptions = [...(customOptions ?? baseOptions)]; // Add "Show All" to the list of options if the consumer has so specified. if (showAll) { diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index c560e1d0815..220bf0121c8 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -3,12 +3,10 @@ import { screen, waitFor } from '@testing-library/react'; import * as React from 'react'; import { accountFactory } from 'src/factories'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; import PrimaryNav from './PrimaryNav'; -import type { ManagerPreferences } from '@linode/utilities'; import type { Flags } from 'src/featureFlags'; const props = { @@ -28,6 +26,8 @@ const queryMocks = vi.hoisted(() => ({ isIAMEnabled: false, })), usePreferences: vi.fn().mockReturnValue({}), + useAccount: vi.fn().mockReturnValue({}), + useAccountSettings: vi.fn().mockReturnValue({}), })); vi.mock('src/features/IAM/hooks/useIsIAMEnabled', () => ({ @@ -39,28 +39,42 @@ vi.mock('@linode/queries', async () => { return { ...actual, usePreferences: queryMocks.usePreferences, + useAccount: queryMocks.useAccount, + useAccountSettings: queryMocks.useAccountSettings, }; }); describe('PrimaryNav', () => { - const preference: ManagerPreferences['collapsedSideNavProductFamilies'] = []; + beforeEach(() => { + queryMocks.usePreferences.mockReturnValue({ + data: { + collapsedSideNavProductFamilies: [], + }, + isLoading: false, + error: null, + }); + }); it('only contains a "Managed" menu link if the user has Managed services.', async () => { - server.use( - http.get('*/account/maintenance', () => { - return HttpResponse.json({ managed: false }); - }) - ); + queryMocks.useAccountSettings.mockReturnValue({ + data: { + managed: false, + }, + isLoading: false, + error: null, + }); const { findByTestId, getByTestId, queryByTestId, rerender } = renderWithTheme(, { queryClient }); expect(queryByTestId(queryString)).not.toBeInTheDocument(); - server.use( - http.get('*/account/maintenance', () => { - return HttpResponse.json({ managed: true }); - }) - ); + queryMocks.useAccountSettings.mockReturnValue({ + data: { + managed: true, + }, + isLoading: false, + error: null, + }); rerender(wrapWithTheme(, { queryClient })); @@ -70,6 +84,13 @@ describe('PrimaryNav', () => { }); it('should have aria-current attribute for accessible links', () => { + queryMocks.useAccountSettings.mockReturnValue({ + data: { + managed: true, + }, + isLoading: false, + error: null, + }); const { getByTestId } = renderWithTheme(, { queryClient, }); @@ -78,19 +99,15 @@ describe('PrimaryNav', () => { }); it('should show Databases menu item if the user has the account capability V1', async () => { - queryMocks.usePreferences.mockReturnValue({ - data: preference, - }); - const account = accountFactory.build({ capabilities: ['Managed Databases'], }); - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); const flags: Partial = { dbaasV2: { @@ -99,33 +116,26 @@ describe('PrimaryNav', () => { }, }; - const { findByTestId, queryByTestId } = renderWithTheme( - , - { - flags, - } - ); + renderWithTheme(, { + flags, + }); - const databaseNavItem = await findByTestId('menu-item-Databases'); + const databaseNavItem = screen.getByTestId('menu-item-Databases'); expect(databaseNavItem).toBeVisible(); - expect(queryByTestId('betaChip')).toBeNull(); + expect(screen.queryByTestId('betaChip')).toBeNull(); }); it('should show Databases menu item if the user has the account capability V2 Beta', async () => { - queryMocks.usePreferences.mockReturnValue({ - data: preference, - }); - const account = accountFactory.build({ capabilities: ['Managed Databases Beta'], }); - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); const flags: Partial = { dbaasV2: { @@ -146,19 +156,15 @@ describe('PrimaryNav', () => { }); it('should show Databases menu item if the user has the account capability V2', async () => { - queryMocks.usePreferences.mockReturnValue({ - data: preference, - }); - const account = accountFactory.build({ capabilities: ['Managed Databases'], }); - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); const flags: Partial = { dbaasV2: { @@ -181,19 +187,15 @@ describe('PrimaryNav', () => { }); it('should show Databases menu item if the user has the account capability V2', async () => { - queryMocks.usePreferences.mockReturnValue({ - data: preference, - }); - const account = accountFactory.build({ capabilities: ['Managed Databases Beta'], }); - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); const flags: Partial = { dbaasV2: { @@ -216,11 +218,11 @@ describe('PrimaryNav', () => { capabilities: ['Akamai Cloud Pulse'], }); - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); const flags = { aclp: { @@ -257,11 +259,11 @@ describe('PrimaryNav', () => { capabilities: ['Akamai Cloud Pulse'], }); - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); const flags = { aclp: { @@ -297,11 +299,11 @@ describe('PrimaryNav', () => { capabilities: ['Akamai Cloud Pulse'], }); - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); const flags = { aclp: { @@ -333,6 +335,116 @@ describe('PrimaryNav', () => { expect(betaChip).toBeVisible(); }); + it('should show Logs menu item if user has capability and aclpLogs flag is enabled', async () => { + const account = accountFactory.build({ + capabilities: ['Akamai Cloud Pulse Logs'], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + const flags = { + aclpLogs: { + enabled: true, + beta: false, + bypassAccountCapabilities: false, + }, + }; + + const { findByTestId, queryByTestId } = renderWithTheme( + , + { flags } + ); + + const logsNavItem = await findByTestId('menu-item-Logs'); + expect(logsNavItem).toBeVisible(); + expect(queryByTestId('betaChip')).toBeNull(); + }); + + it('should not show Logs menu item if aclpLogs flag is not enabled', async () => { + const account = accountFactory.build({ + capabilities: ['Akamai Cloud Pulse Logs'], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + const flags = { + aclpLogs: { + enabled: false, + beta: false, + bypassAccountCapabilities: true, + }, + }; + + const { queryByTestId } = renderWithTheme(, { + flags, + }); + + expect(queryByTestId('menu-item-Logs')).toBeNull(); + }); + + it('should show Logs menu item if user lacks capability, bypassAccountCapabilities is true and aclpLogs flag is enabled', async () => { + const account = accountFactory.build({ + capabilities: [], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + const flags = { + aclpLogs: { + enabled: true, + beta: true, + bypassAccountCapabilities: true, + }, + }; + + const { findByTestId } = renderWithTheme(, { + flags, + }); + + const logsNavItem = await findByTestId('menu-item-Logs'); + expect(logsNavItem).toBeVisible(); + const betaChip = await findByTestId('betaChip'); + expect(betaChip).toBeVisible(); + }); + + it('should not show Logs menu item if user lacks capability, bypassAccountCapabilities is false and aclpLogs flag is enabled', async () => { + const account = accountFactory.build({ + capabilities: [], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + const flags = { + aclpLogs: { + enabled: true, + beta: false, + bypassAccountCapabilities: false, + }, + }; + + const { queryByTestId } = renderWithTheme(, { + flags, + }); + + expect(queryByTestId('menu-item-Logs')).toBeNull(); + }); + it('should show Administration links if iamRbacPrimaryNavChanges flag is enabled', async () => { const flags: Partial = { iamRbacPrimaryNavChanges: true, diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 4c14e98bbf3..191648a49b1 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -20,6 +20,7 @@ import { } from 'src/components/PrimaryNav/constants'; import { useIsACLPEnabled } from 'src/features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; +import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -96,9 +97,11 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const location = useLocation(); const { data: accountSettings } = useAccountSettings(); + const isManaged = accountSettings?.managed ?? false; const { isACLPEnabled } = useIsACLPEnabled(); + const { isACLPLogsEnabled, isACLPLogsBeta } = useIsACLPLogsEnabled(); const isAlertsEnabled = isACLPEnabled && @@ -114,16 +117,18 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); const { - data: collapsedSideNavPreference, + data: preferences, error: preferencesError, isLoading: preferencesLoading, - } = usePreferences( - (preferences) => preferences?.collapsedSideNavProductFamilies - ); + } = usePreferences(); - const collapsedAccordions = collapsedSideNavPreference ?? [ - 1, 2, 3, 4, 5, 6, 7, - ]; // by default, we collapse all categories if no preference is set; + const collapsedSideNavPreference = + preferences?.collapsedSideNavProductFamilies; + + const collapsedAccordions = React.useMemo( + () => collapsedSideNavPreference ?? [1, 2, 3, 4, 5, 6, 7], // by default, we collapse all categories if no preference is set; + [collapsedSideNavPreference] + ); const { mutateAsync: updatePreferences } = useMutatePreferences(); @@ -241,9 +246,9 @@ export const PrimaryNav = (props: PrimaryNavProps) => { }, { display: 'Logs', - hide: !flags.aclpLogs?.enabled, + hide: !isACLPLogsEnabled, to: '/logs/delivery', - isBeta: flags.aclpLogs?.beta, + isBeta: isACLPLogsBeta, }, ], name: 'Monitor', @@ -330,6 +335,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isManaged, isPlacementGroupsEnabled, isACLPEnabled, + isACLPLogsBeta, + isACLPLogsEnabled, isIAMBeta, isIAMEnabled, iamRbacPrimaryNavChanges, @@ -337,22 +344,22 @@ export const PrimaryNav = (props: PrimaryNavProps) => { ] ); - const accordionClicked = (index: number) => { - let updatedCollapsedAccordions: number[] = [1, 2, 3, 4, 5, 6, 7]; - if (collapsedAccordions.includes(index)) { - updatedCollapsedAccordions = collapsedAccordions.filter( - (accIndex) => accIndex !== index - ); - updatePreferences({ - collapsedSideNavProductFamilies: updatedCollapsedAccordions, - }); - } else { - updatedCollapsedAccordions = [...collapsedAccordions, index]; + const accordionClicked = React.useCallback( + (index: number) => { + let updatedCollapsedAccordions: number[]; + if (collapsedAccordions.includes(index)) { + updatedCollapsedAccordions = collapsedAccordions.filter( + (accIndex) => accIndex !== index + ); + } else { + updatedCollapsedAccordions = [...collapsedAccordions, index]; + } updatePreferences({ collapsedSideNavProductFamilies: updatedCollapsedAccordions, }); - } - }; + }, + [collapsedAccordions, updatePreferences] + ); const checkOverflow = React.useCallback(() => { if (navItemsRef.current && primaryNavRef.current) { @@ -404,10 +411,17 @@ export const PrimaryNav = (props: PrimaryNavProps) => { // When a user lands on a page and does not have any preference set, // we want to expand the accordion that contains the active link for convenience and discoverability React.useEffect(() => { + // Wait for preferences to load or if there's an error if (preferencesLoading || preferencesError) { return; } + // Wait for preferences data to be available (not just the field, but the whole object) + if (!preferences) { + return; + } + + // If user has already set collapsedSideNavProductFamilies preference, don't override it if (collapsedSideNavPreference) { return; } @@ -424,13 +438,13 @@ export const PrimaryNav = (props: PrimaryNavProps) => { if (activeGroupIndex !== -1) { accordionClicked(activeGroupIndex); } - - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ + accordionClicked, location.pathname, location.search, productFamilyLinkGroups, collapsedSideNavPreference, + preferences, preferencesLoading, preferencesError, ]); diff --git a/packages/manager/src/dev-tools/DevTools.tsx b/packages/manager/src/dev-tools/DevTools.tsx index c3454d40852..22de6e63c1e 100644 --- a/packages/manager/src/dev-tools/DevTools.tsx +++ b/packages/manager/src/dev-tools/DevTools.tsx @@ -57,7 +57,7 @@ export const DevTools = (props: Props) => { }; const handleGoToPreferences = () => { - window.location.assign('/profile/settings?preferenceEditor=true'); + window.location.assign('/profile/preferences?preferenceEditor=true'); }; React.useEffect(() => { diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index f08b7cea774..b6c0bf419fb 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -30,7 +30,12 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'blockStorageVolumeLimit', label: 'Block Storage Volume Limit' }, { flag: 'cloudNat', label: 'Cloud NAT' }, { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, + { + flag: 'firewallRulesetsPrefixlists', + label: 'Firewall Rulesets & Prefixlists', + }, { flag: 'gecko2', label: 'Gecko' }, + { flag: 'generationalPlans', label: 'Generational compute plans' }, { flag: 'limitsEvolution', label: 'Limits Evolution' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index ae09ca13fa9..bd5c92c220d 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -360,3 +360,222 @@ export const objectStorageMetricRules: MetricDefinition[] = [ ], }, ]; + +export const blockStorageMetricRules: MetricDefinition[] = [ + { + label: 'Volume Read Operations', + metric: 'volume_read_ops', + unit: 'Count', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Operations', + metric: 'volume_write_ops', + unit: 'Count', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Bytes', + metric: 'volume_read_bytes', + unit: 'KB', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Bytes', + metric: 'volume_write_bytes', + unit: 'KB', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Latency p99', + metric: 'volume_read_latency_p99', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Latency p95', + metric: 'volume_read_latency_p95', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Latency p90', + metric: 'volume_read_latency_p90', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Read Latency p50', + metric: 'volume_read_latency_p50', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Latency p99', + metric: 'volume_write_latency_p99', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Latency p95', + metric: 'volume_write_latency_p95', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Latency p90', + metric: 'volume_write_latency_p90', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, + { + label: 'Volume Write Latency p50', + metric: 'volume_write_latency_p50', + unit: 'ms', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + values: [], + }, + ], + }, +]; + +export const blockStorageMetricCriteria = + Factory.Sync.makeFactory({ + label: 'Volume Read Operations', + metric: 'volume_read_ops', + unit: 'Count', + aggregate_function: 'avg', + operator: 'gt', + threshold: 1000, + dimension_filters: [ + { + label: 'linode_id', + dimension_label: 'linode_id', + operator: 'in', + value: '1,2,3', + }, + { + label: 'linode_id', + dimension_label: 'linode_id', + operator: 'eq', + value: '5', + }, + ], + }); diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts index 5bc57f475c5..f26c05bc914 100644 --- a/packages/manager/src/factories/delivery.ts +++ b/packages/manager/src/factories/delivery.ts @@ -30,9 +30,7 @@ export const streamFactory = Factory.Sync.makeFactory({ updated_by: 'username', id: Factory.each((id) => id), label: Factory.each((id) => `Stream ${id}`), - primary_destination_id: 1, status: 'active', - stream_audit_id: 1, type: streamType.AuditLogs, version: '1.0', created: '2025-07-30', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index b96989841a2..ce42d10a2e6 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -1,6 +1,7 @@ import type { OCA } from './features/OneClickApps/types'; import type { Region } from '@linode/api-v4'; import type { + AlertStatusType, CloudPulseServiceType, TPAProvider, } from '@linode/api-v4/lib/profile'; @@ -104,6 +105,13 @@ interface AclpFlag { showWidgetDimensionFilters?: boolean; } +interface AclpLogsFlag extends BetaFeatureFlag { + /** + * This property indicates whether to bypass account capabilities check or not + */ + bypassAccountCapabilities?: boolean; +} + interface LkeEnterpriseFlag extends BaseFeatureFlag { ga: boolean; la: boolean; @@ -141,6 +149,7 @@ interface AclpAlerting { accountAlertLimit: number; accountMetricLimit: number; alertDefinitions: boolean; + editDisabledStatuses?: AlertStatusType[]; notificationChannels: boolean; recentActivity: boolean; } @@ -167,7 +176,7 @@ export interface Flags { aclp: AclpFlag; aclpAlerting: AclpAlerting; aclpAlertServiceTypeConfig: AclpAlertServiceTypeConfig[]; - aclpLogs: BetaFeatureFlag; + aclpLogs: AclpLogsFlag; aclpReadEndpoint: string; aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; aclpServices: Partial; @@ -191,7 +200,9 @@ export interface Flags { dbaasV2: BetaFeatureFlag; dbaasV2MonitorMetrics: BetaFeatureFlag; disableLargestGbPlans: boolean; + firewallRulesetsPrefixlists: boolean; gecko2: GeckoFeatureFlag; + generationalPlans: boolean; gpuv2: GpuV2; iam: BetaFeatureFlag; iamDelegation: BaseFeatureFlag; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx index eafa15edc0c..79c055abf15 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertActionMenu.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { useFlags } from 'src/hooks/useFlags'; import { getAlertTypeToActionsList } from '../Utils/AlertsActionMenu'; @@ -48,9 +49,19 @@ export interface AlertActionMenuProps { export const AlertActionMenu = (props: AlertActionMenuProps) => { const { alertLabel, alertStatus, alertType, handlers } = props; + + // Get the statuses to be disabled in alert action menu based on the flag etc.. + const { aclpAlerting } = useFlags(); + return ( ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx index 35f8b0f1013..a7a415aeeb2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -172,6 +172,7 @@ describe('Alert Row', () => { it("should disable 'Edit' action item in menu if alert has no enabled/disabled status", async () => { const alert = alertFactory.build({ status: 'in progress', type: 'user' }); + const { getByLabelText, getByText } = renderWithTheme( { handleStatusChange: vi.fn(), }} services={mockServices} - /> + />, + { + flags: { + aclpAlerting: { + editDisabledStatuses: ['failed', 'in progress'], + accountAlertLimit: 10, + accountMetricLimit: 100, + alertDefinitions: true, + notificationChannels: false, + recentActivity: false, + }, + }, + } ); const ActionMenu = getByLabelText(`Action menu for Alert ${alert.label}`); await userEvent.click(ActionMenu); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 2b2bbc9b6eb..cbc5daf1d74 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -138,6 +138,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { if ( serviceType === 'firewall' || serviceType === 'objectstorage' || + serviceType === 'blockstorage' || !supportedRegionIds?.length ) { return undefined; @@ -197,7 +198,11 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { ); const regionFilteredResources = React.useMemo(() => { - if (serviceType === 'objectstorage' && resources && supportedRegionIds) { + if ( + (serviceType === 'objectstorage' || serviceType === 'blockstorage') && + resources && + supportedRegionIds + ) { return getOfflineRegionFilteredResources(resources, supportedRegionIds); } return resources; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts index 4c6cabef159..57a793cc4b2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts @@ -104,6 +104,18 @@ export const serviceTypeBasedColumns: ServiceColumns = { sortingKey: 'endpoint', }, ], + blockstorage: [ + { + accessor: ({ label }) => label, + label: 'Entity', + sortingKey: 'label', + }, + { + accessor: ({ region }) => region, + label: 'Region', + sortingKey: 'region', + }, + ], }; export const serviceToFiltersMap: Partial< @@ -125,6 +137,7 @@ export const serviceToFiltersMap: Partial< { component: AlertsRegionFilter, filterKey: 'region' }, { component: AlertsEndpointFilter, filterKey: 'endpoint' }, ], + blockstorage: [{ component: AlertsRegionFilter, filterKey: 'region' }], }; export const applicableAdditionalFilterKeys: AlertAdditionalFilterKey[] = [ 'engineType', // Extendable in future for filter keys like 'tags', 'plan', etc. diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx new file mode 100644 index 00000000000..013d6248fc7 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx @@ -0,0 +1,320 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { BlockStorageDimensionFilterAutocomplete } from './BlockStorageDimensionFilterAutocomplete'; + +import type { DimensionFilterAutocompleteProps } from './constants'; + +const queryMocks = vi.hoisted(() => ({ + useRegionsQuery: vi.fn(), + useBlockStorageFetchOptions: vi.fn(), +})); + +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), + useRegionsQuery: queryMocks.useRegionsQuery.mockReturnValue({ data: [] }), +})); + +vi.mock('./useBlockStorageFetchOptions', () => ({ + ...vi.importActual('./useBlockStorageFetchOptions'), + useBlockStorageFetchOptions: queryMocks.useBlockStorageFetchOptions, +})); + +describe('', () => { + const defaultProps: DimensionFilterAutocompleteProps = { + name: 'dimension-filter', + dimensionLabel: 'linode_id', + disabled: false, + errorText: undefined, + fieldOnBlur: vi.fn(), + fieldOnChange: vi.fn(), + fieldValue: null, + multiple: false, + placeholderText: 'Select a Value', + entities: [], + scope: null, + selectedRegions: null, + serviceType: 'blockstorage', + type: 'alerts', + values: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with correct label and placeholder', () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + renderWithTheme( + + ); + expect(screen.getByLabelText(/Value/i)).toBeVisible(); + expect(screen.getByPlaceholderText('Select a Value')).toBeVisible(); + }); + + it('calls fieldOnBlur when input is blurred', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const user = userEvent.setup(); + renderWithTheme( + + ); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.tab(); + expect(defaultProps.fieldOnBlur).toHaveBeenCalled(); + }); + + it('disables the Autocomplete when disabled is true', () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: false, + }); + renderWithTheme( + + ); + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + + it('renders error text from props', () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: false, + }); + renderWithTheme( + + ); + expect(screen.getByText('Custom error')).toBeVisible(); + }); + + it('renders API error text when isError is true', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: true, + }); + renderWithTheme( + + ); + expect( + await screen.findByText('Failed to fetch the values.') + ).toBeVisible(); + }); + + it('shows loading state when fetching values', () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: true, + isError: false, + }); + renderWithTheme( + + ); + expect(screen.getByTestId('circle-progress')).toBeVisible(); + }); + + it('calls fieldOnChange with correct value when selecting an option (single)', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + const user = userEvent.setup(); + const fieldOnChange = vi.fn(); + renderWithTheme( + + ); + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: /Linode-1/ })); + expect(fieldOnChange).toHaveBeenCalledWith('1'); + }); + + it('calls fieldOnChange with multiple values when multiple=true', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const user = userEvent.setup(); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + // Select first option + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: /Linode-1/ })); + expect(fieldOnChange).toHaveBeenCalledWith('1'); + // Rerender with updated form state + rerender( + + ); + // Select second option + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: /Linode-2/ })); + expect(fieldOnChange).toHaveBeenCalledWith('1,2'); + }); + + it('does not call fieldOnChange when typing with no options', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: false, + }); + renderWithTheme( + + ); + const user = userEvent.setup(); + await user.type(screen.getByRole('combobox'), 'test'); + expect(defaultProps.fieldOnChange).not.toHaveBeenCalled(); + }); + + it('cleans up invalid single value (string)', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + // Simulate update to trigger effect + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(null); + }); + }); + + it('cleans up invalid multi value (comma-separated string)', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith('1,2'); + }); + }); + + it('cleans up all invalid multi values (comma-separated string)', async () => { + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + queryMocks.useBlockStorageFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(''); + }); + }); +}); \ No newline at end of file diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx new file mode 100644 index 00000000000..0d48cae694d --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx @@ -0,0 +1,78 @@ +import { useRegionsQuery } from '@linode/queries'; +import { Autocomplete } from '@linode/ui'; +import React from 'react'; + +import { useBlockStorageFetchOptions } from './useBlockStorageFetchOptions'; +import { useCleanupStaleValues } from './useCleanupStaleValues'; +import { handleValueChange, resolveSelectedValues } from './utils'; + +import type { DimensionFilterAutocompleteProps } from './constants'; + +export const BlockStorageDimensionFilterAutocomplete = ( + props: DimensionFilterAutocompleteProps +) => { + const { + dimensionLabel, + multiple, + name, + fieldOnChange, + disabled, + fieldOnBlur, + placeholderText, + errorText, + entities, + fieldValue, + scope, + selectedRegions, + serviceType, + type, + } = props; + + const { data: regions } = useRegionsQuery(); + const { values, isLoading, isError } = useBlockStorageFetchOptions({ + entities, + dimensionLabel, + regions, + type, + scope, + selectedRegions, + serviceType, + }); + + useCleanupStaleValues({ + options: values, + fieldValue, + multiple, + onChange: fieldOnChange, + isLoading, + }); + + return ( + value.value === option.value} + label="Value" + limitTags={1} + loading={!disabled && isLoading && !isError} + multiple={multiple} + onBlur={fieldOnBlur} + onChange={(_, selected, operation) => { + const newValue = handleValueChange( + selected, + operation, + multiple ?? false + ); + fieldOnChange(newValue); + }} + options={values} + placeholder={placeholderText} + sx={{ flex: 1 }} + value={resolveSelectedValues(values, fieldValue, multiple ?? false)} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx index 9be5bb91063..d632857c973 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx @@ -7,14 +7,27 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ValueFieldRenderer } from './ValueFieldRenderer'; +import type { DimensionFilterAutocompleteProps } from './constants'; import type { CloudPulseServiceType, DimensionFilterOperatorType, } from '@linode/api-v4'; // Mock child components +vi.mock('./BlockStorageDimensionFilterAutocomplete', () => ({ + BlockStorageDimensionFilterAutocomplete: ( + props: DimensionFilterAutocompleteProps + ) => ( +
+ BlockStorage Autocomplete +
+ ), +})); + vi.mock('./FirewallDimensionFilterAutocomplete', () => ({ - FirewallDimensionFilterAutocomplete: (props: any) => ( + FirewallDimensionFilterAutocomplete: ( + props: DimensionFilterAutocompleteProps + ) => (
Firewall Autocomplete
@@ -22,7 +35,9 @@ vi.mock('./FirewallDimensionFilterAutocomplete', () => ({ })); vi.mock('./ObjectStorageDimensionFilterAutocomplete', () => ({ - ObjectStorageDimensionFilterAutocomplete: (props: any) => ( + ObjectStorageDimensionFilterAutocomplete: ( + props: DimensionFilterAutocompleteProps + ) => (
ObjectStorage Autocomplete
@@ -30,7 +45,7 @@ vi.mock('./ObjectStorageDimensionFilterAutocomplete', () => ({ })); vi.mock('./DimensionFilterAutocomplete', () => ({ - DimensionFilterAutocomplete: (props: any) => ( + DimensionFilterAutocomplete: (props: DimensionFilterAutocompleteProps) => (
DimensionFilter Autocomplete
@@ -40,7 +55,9 @@ vi.mock('./DimensionFilterAutocomplete', () => ({ const EQ: DimensionFilterOperatorType = 'eq'; const IN: DimensionFilterOperatorType = 'in'; const NB: CloudPulseServiceType = 'nodebalancer'; - +const CF: CloudPulseServiceType = 'firewall'; +const OS: CloudPulseServiceType = 'objectstorage'; +const BS: CloudPulseServiceType = 'blockstorage'; describe('', () => { const defaultProps = { serviceType: NB, @@ -85,6 +102,7 @@ describe('', () => { ...defaultProps, dimensionLabel: 'linode_id', // assume this is configured with useCustomFetch: 'firewall' operator: IN, + serviceType: CF, }; renderWithTheme(); @@ -96,11 +114,23 @@ describe('', () => { ...defaultProps, dimensionLabel: 'endpoint', // assume this is configured with useCustomFetch: 'objectstorage' operator: IN, + serviceType: OS, }; renderWithTheme(); expect(screen.getByTestId('objectstorage-autocomplete')).toBeVisible(); }); + it('renders BlockStorageDimensionFilter if config.useCustomFetch = blockstorage', () => { + const props = { + ...defaultProps, + serviceType: BS, + dimensionLabel: 'linode_id', // assume this is configured with useCustomFetch: 'blockstorage' + operator: IN, + }; + + renderWithTheme(); + expect(screen.getByTestId('blockstorage-autocomplete')).toBeVisible(); + }); it('calls onChange when typing into TextField', async () => { const user = userEvent.setup(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx index 2ac7691b057..3bb1ce9d033 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx @@ -1,6 +1,7 @@ import { TextField } from '@linode/ui'; import React from 'react'; +import { BlockStorageDimensionFilterAutocomplete } from './BlockStorageDimensionFilterAutocomplete'; import { MULTISELECT_PLACEHOLDER_TEXT, SINGLESELECT_PLACEHOLDER_TEXT, @@ -145,58 +146,61 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { ? MULTISELECT_PLACEHOLDER_TEXT : SINGLESELECT_PLACEHOLDER_TEXT; - switch (config.useCustomFetch) { + // Common props shared across all autocomplete components + const commonAutocompleteProps = { + dimensionLabel, + disabled, + errorText, + fieldOnBlur: onBlur, + fieldOnChange: onChange, + fieldValue: value, + multiple: config.multiple, + name, + placeholderText: config.placeholder ?? autocompletePlaceholder, + serviceType: serviceType ?? null, + type, + }; + + // Determine custom fetch behaviour if there are same dimension_labels across service types + const customFetch = Array.isArray(config.useCustomFetch) + ? config.useCustomFetch.includes(serviceType ?? '') + ? serviceType + : undefined + : config.useCustomFetch === serviceType + ? serviceType + : undefined; + + switch (customFetch) { + case 'blockstorage': + return ( + + ); case 'firewall': return ( ); case 'objectstorage': return ( ); default: return ( ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts index 5e8e2a20fd5..a4350f92b33 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts @@ -102,7 +102,7 @@ export interface AutocompleteConfig extends BaseConfig { /** * Flag to use a custom fetch function instead of the static options. */ - useCustomFetch?: string; + useCustomFetch?: string | string[]; } /** @@ -161,7 +161,7 @@ export const valueFieldConfig: ValueFieldConfigMap = { eq_neq: { type: 'autocomplete', multiple: false, - useCustomFetch: 'firewall', + useCustomFetch: ['firewall', 'blockstorage'], }, startswith_endswith: { type: 'textfield', @@ -170,7 +170,7 @@ export const valueFieldConfig: ValueFieldConfigMap = { in: { type: 'autocomplete', multiple: true, - useCustomFetch: 'firewall', + useCustomFetch: ['firewall', 'blockstorage'], }, '*': { type: 'textfield', diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useBlockStorageFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useBlockStorageFetchOptions.ts new file mode 100644 index 00000000000..8e41e5b2bda --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useBlockStorageFetchOptions.ts @@ -0,0 +1,99 @@ +import { useAllLinodesQuery } from '@linode/queries'; +import { useMemo } from 'react'; + +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import { getOfflineRegionFilteredResources } from '../../../Utils/AlertResourceUtils'; +import { filterRegionByServiceType } from '../../../Utils/utils'; +import { getBlockStorageLinodes, scopeBasedFilteredResources } from './utils'; + +import type { FetchOptions, FetchOptionsProps } from './constants'; +import type { Filter } from '@linode/api-v4'; + +export function useBlockStorageFetchOptions( + props: FetchOptionsProps +): FetchOptions { + const { + dimensionLabel, + regions, + entities, + serviceType, + type, + scope, + selectedRegions, + } = props; + + const { + data: blockStorageResources, + isLoading: isBlockStorageLoading, + isError: isBlockStorageError, + } = useResourcesQuery(dimensionLabel === 'linode_id', 'blockstorage'); + + // Offline filter buckets by supported regions + const supportedRegionIds = + (regions && + filterRegionByServiceType(type, regions, 'blockstorage').map( + ({ id }) => id + )) || + []; + + // Create a filter for regions based on supported region IDs + const regionFilter: Filter = { + '+or': + supportedRegionIds && supportedRegionIds.length > 0 + ? supportedRegionIds.map((regionId) => ({ + region: regionId, + })) + : undefined, + }; + + const regionFilteredBuckets = getOfflineRegionFilteredResources( + blockStorageResources ?? [], + supportedRegionIds + ); + + const filteredResources = scopeBasedFilteredResources({ + scope: scope ?? null, + resources: regionFilteredBuckets, + entities, + selectedRegions, + }); + + const filteredBlockStorageParentEntityIds = filteredResources?.map( + ({ volumeLinodeId }) => volumeLinodeId + ); + + const idFilter: Filter = { + '+or': filteredBlockStorageParentEntityIds.length + ? filteredBlockStorageParentEntityIds.map((id) => ({ id })) + : undefined, + }; + + const combinedFilter: Filter = { + '+and': [regionFilter, idFilter].filter(Boolean), + }; + + const { + data: linodes, + isError: isLinodesError, + isLoading: isLinodesLoading, + } = useAllLinodesQuery( + {}, + combinedFilter, + serviceType === 'blockstorage' && + dimensionLabel === 'linode_id' && + filteredBlockStorageParentEntityIds.length > 0 && + supportedRegionIds.length > 0 + ); + + const blockStorageLinodes = useMemo( + () => getBlockStorageLinodes(linodes ?? []), + [linodes] + ); + + return { + values: blockStorageLinodes, + isLoading: isLinodesLoading || isBlockStorageLoading, + isError: isBlockStorageError || isLinodesError, + }; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts index e842db0468d..64d2371e15e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts @@ -51,7 +51,7 @@ export function useFirewallFetchOptions( ? supportedRegionIds.map((regionId) => ({ region: regionId, })) - : [{ region: '' }], + : undefined, }; const filterLabels: string[] = [ diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useObjectStorageFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useObjectStorageFetchOptions.ts index d709aca65bd..7a651b4d33a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useObjectStorageFetchOptions.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useObjectStorageFetchOptions.ts @@ -5,7 +5,7 @@ import { getOfflineRegionFilteredResources, } from '../../../Utils/AlertResourceUtils'; import { filterRegionByServiceType } from '../../../Utils/utils'; -import { scopeBasedFilteredBuckets } from './utils'; +import { scopeBasedFilteredResources } from './utils'; import type { FetchOptions, FetchOptionsProps } from './constants'; /** @@ -40,9 +40,9 @@ export function useObjectStorageFetchOptions( ); // Filtering the buckets based on the scope - const filteredBuckets = scopeBasedFilteredBuckets({ + const filteredBuckets = scopeBasedFilteredResources({ scope: scope ?? null, - buckets: regionFilteredBuckets, + resources: regionFilteredBuckets, entities, selectedRegions, }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts index 099c3bcd7d8..80c6f9787a8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts @@ -10,7 +10,7 @@ import { getStaticOptions, handleValueChange, resolveSelectedValues, - scopeBasedFilteredBuckets, + scopeBasedFilteredResources, } from './utils'; import type { Linode } from '@linode/api-v4'; @@ -243,17 +243,17 @@ describe('Utils', () => { ]; it('returns all buckets for account scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'account', - buckets, + resources: buckets, }); expect(result).toEqual(buckets); }); it('filters buckets by entity IDs for entity scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'entity', - buckets, + resources: buckets, entities: ['bucket-1', 'bucket-3'], }); expect(result).toEqual([ @@ -263,26 +263,26 @@ describe('Utils', () => { }); it('returns empty array if no entities match for entity scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'entity', - buckets, + resources: buckets, entities: ['bucket-99'], }); expect(result).toEqual([]); }); it('returns empty array if entities is undefined for entity scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'entity', - buckets, + resources: buckets, }); expect(result).toEqual([]); }); it('filters buckets by region IDs for region scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'region', - buckets, + resources: buckets, selectedRegions: ['us-east', 'eu-central'], }); expect(result).toEqual([ @@ -292,34 +292,34 @@ describe('Utils', () => { }); it('returns empty array if no regions match for region scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'region', - buckets, + resources: buckets, selectedRegions: ['ap-south'], }); expect(result).toEqual([]); }); it('returns empty array if selectedRegions is undefined for region scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: 'region', - buckets, + resources: buckets, }); expect(result).toEqual([]); }); it('returns all buckets for null scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: null, - buckets, + resources: buckets, }); expect(result).toEqual(buckets); }); it('returns all buckets for unrecognized scope', () => { - const result = scopeBasedFilteredBuckets({ + const result = scopeBasedFilteredResources({ scope: null, - buckets, + resources: buckets, }); expect(result).toEqual(buckets); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts index 5aee1f0f177..ab8be225fab 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts @@ -180,15 +180,15 @@ export const getVPCSubnets = (vpcs: VPC[]): Item[] => { ); }; -interface ScopeBasedFilteredBucketsProps { +interface ScopeBasedFilteredResourcesProps { /** - * The full list of available CloudPulse resources (buckets). + * A list of entity IDs to filter by when scope is `entity`. */ - buckets: CloudPulseResources[]; + entities?: string[]; /** - * A list of entity IDs (bucket IDs) to filter by when scope is `entity`. + * The full list of available CloudPulse resources. */ - entities?: string[]; + resources: CloudPulseResources[]; /** * The scope of the alert definition (`account`, `entity`, `region`, or `null`). */ @@ -199,31 +199,45 @@ interface ScopeBasedFilteredBucketsProps { selectedRegions?: null | string[]; } -/** - * Filters a list of Object Storage buckets based on the given alert definition scope. +/* Filters a list of Resource objects based on the given alert definition scope. * * @param props - Object containing filter parameters. - * @returns A filtered list of buckets based on the provided scope. + * @returns A filtered list of resources based on the provided scope. */ -export const scopeBasedFilteredBuckets = ( - props: ScopeBasedFilteredBucketsProps +export const scopeBasedFilteredResources = ( + props: ScopeBasedFilteredResourcesProps ): CloudPulseResources[] => { - const { scope, buckets, selectedRegions, entities } = props; + const { scope, resources, selectedRegions, entities } = props; switch (scope) { case 'account': - return buckets; + return resources; case 'entity': return entities - ? buckets.filter((bucket) => entities.includes(bucket.id)) + ? resources.filter((resource) => entities.includes(resource.id)) : []; case 'region': return selectedRegions - ? buckets.filter((bucket) => - selectedRegions.includes(bucket.region ?? '') + ? resources.filter((resource) => + selectedRegions.includes(resource.region ?? '') ) : []; default: - return buckets; + return resources; } }; + +/** + * Extracts linode items from firewall resources by merging entities. + * @param resources - List of firewall resources with entity mappings. + * @returns - Flattened list of linode ID/label pairs as options. + */ +export const getBlockStorageLinodes = ( + linodes: Linode[] +): Item[] => { + if (!linodes) return []; + return linodes.map((linode) => ({ + label: transformDimensionValue('blockstorage', 'linode_id', linode.label), + value: String(linode.id), + })); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts index 625408c0512..124d297bfa9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts @@ -15,7 +15,8 @@ export const getAlertTypeToActionsList = ( handleEdit, handleStatusChange, }: ActionHandlers, - alertStatus: AlertStatusType + alertStatus: AlertStatusType, + editDisableStatuses: AlertStatusType[] = [] ): Record => ({ // for now there is system and user alert types, in future more alert types can be added and action items will differ according to alert types system: [ @@ -34,7 +35,7 @@ export const getAlertTypeToActionsList = ( title: 'Show Details', }, { - disabled: alertStatus === 'in progress' || alertStatus === 'failed', + disabled: editDisableStatuses.includes(alertStatus), onClick: handleEdit, title: 'Edit', }, @@ -44,7 +45,7 @@ export const getAlertTypeToActionsList = ( title: getTitleForStatusChange(alertStatus), }, { - disabled: alertStatus === 'in progress' || alertStatus === 'failed', + disabled: editDisableStatuses.includes(alertStatus), onClick: handleDelete, title: 'Delete', }, diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx index e0c7ce43c12..4b199e9dfe7 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx @@ -2,16 +2,14 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { dashboardFactory, serviceTypesFactory } from 'src/factories'; +import { dashboardFactory } from 'src/factories'; import * as utils from 'src/features/CloudPulse/Utils/utils'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseDashboardWithFilters } from './CloudPulseDashboardWithFilters'; const queryMocks = vi.hoisted(() => ({ - useCloudPulseDashboardByIdQuery: vi.fn().mockReturnValue({}), useCloudPulseDashboardsQuery: vi.fn().mockReturnValue({}), - useCloudPulseServiceTypes: vi.fn().mockReturnValue({}), })); const circleProgress = 'circle-progress'; @@ -21,79 +19,35 @@ vi.mock('src/queries/cloudpulse/dashboards', async () => { const actual = await vi.importActual('src/queries/cloudpulse/dashboards'); return { ...actual, - useCloudPulseDashboardByIdQuery: queryMocks.useCloudPulseDashboardByIdQuery, useCloudPulseDashboardsQuery: queryMocks.useCloudPulseDashboardsQuery, }; }); - -vi.mock('src/queries/cloudpulse/services.ts', async () => { - const actual = await vi.importActual('src/queries/cloudpulse/services'); - - return { - ...actual, - useCloudPulseServiceTypes: queryMocks.useCloudPulseServiceTypes, - }; -}); const mockDashboard = dashboardFactory.build(); -const mockServiceTypesList = serviceTypesFactory.build(); -queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { - data: mockDashboard, - }, - error: false, - isLoading: false, -}); - -queryMocks.useCloudPulseDashboardsQuery.mockReturnValue({ - data: { - data: [mockDashboard], - }, - error: false, - isLoading: false, -}); - -queryMocks.useCloudPulseServiceTypes.mockReturnValue({ - data: { - data: [mockServiceTypesList], - }, - error: false, - isLoading: false, -}); - -vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ - data: [mockDashboard], - error: '', - isLoading: false, -}); describe('CloudPulseDashboardWithFilters component tests', () => { it('renders a CloudPulseDashboardWithFilters component with error placeholder', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { - data: mockDashboard, - }, - error: false, - isError: true, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [], + error: 'test error', isLoading: false, }); renderWithTheme( - + ); - const error = screen.getByText('Error while loading Dashboard with Id - 1'); + const error = screen.getByText('Error loading dashboards'); expect(error).toBeDefined(); }); it('renders a CloudPulseDashboardWithFilters component successfully without error placeholders', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [], + error: '', isLoading: false, }); - renderWithTheme( - + ); const circle = screen.getByTestId(circleProgress); @@ -101,15 +55,14 @@ describe('CloudPulseDashboardWithFilters component tests', () => { }); it('renders a CloudPulseDashboardWithFilters component successfully for dbaas', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, service_type: 'dbaas' }, - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [{ ...mockDashboard, service_type: 'dbaas' }], + error: '', isLoading: false, }); renderWithTheme( - + ); const startDate = screen.getByText('Start Date'); @@ -119,15 +72,14 @@ describe('CloudPulseDashboardWithFilters component tests', () => { }); it('renders a CloudPulseDashboardWithFilters component with mandatory filter error for dbaas', async () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, service_type: 'dbaas' }, - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [{ ...mockDashboard, service_type: 'dbaas' }], + error: '', isLoading: false, }); renderWithTheme( - + ); const closeIcon = screen.getByTestId('CloseIcon'); expect(closeIcon).toBeDefined(); @@ -139,32 +91,30 @@ describe('CloudPulseDashboardWithFilters component tests', () => { }); it('renders a CloudPulseDashboardWithFilters component with no filters configured error', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, id: -1, service_type: 'xyz' }, - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [{ ...mockDashboard, id: 0 }], + error: '', isLoading: false, }); renderWithTheme( - + ); const noFilterText = screen.getByText( - 'No Filters Configured for Service Type - xyz' + 'No Filters Configured for Dashboard with Id - 0' ); expect(noFilterText).toBeDefined(); }); it('renders a CloudPulseDashboardWithFilters component successfully for nodebalancer', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, service_type: 'nodebalancer', id: 3 }, - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [{ ...mockDashboard, service_type: 'nodebalancer', id: 3 }], + error: '', isLoading: false, }); renderWithTheme( - + ); const startDate = screen.getByText('Start Date'); const portsSelect = screen.getByPlaceholderText('e.g., 80,443,3000'); @@ -173,14 +123,16 @@ describe('CloudPulseDashboardWithFilters component tests', () => { }); it('renders a CloudPulseDashboardWithFilters component successfully for firewall', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, service_type: 'firewall', id: 4 }, - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [ + { ...mockDashboard, service_type: 'firewall', id: 4 }, + { ...mockDashboard, service_type: 'firewall', id: 8 }, + ], + error: '', isLoading: false, }); renderWithTheme( - + ); const startDate = screen.getByText('Start Date'); expect(startDate).toBeInTheDocument(); @@ -190,18 +142,17 @@ describe('CloudPulseDashboardWithFilters component tests', () => { }); it('renders a CloudPulseDashboardWithFilters component successfully for objectstorage', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, service_type: 'objectstorage', id: 6 }, - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [{ ...mockDashboard, service_type: 'objectstorage', id: 6 }], + error: '', isLoading: false, }); renderWithTheme( ); @@ -210,48 +161,65 @@ describe('CloudPulseDashboardWithFilters component tests', () => { }); it('renders a CloudPulseDashboardWithFilters component with mandatory filter error for objectstorage if region is not provided', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, service_type: 'objectstorage', id: 6 }, - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [{ ...mockDashboard, service_type: 'objectstorage', id: 6 }], + error: '', isLoading: false, }); renderWithTheme( - + ); const error = screen.getByText(mandatoryFiltersError); expect(error).toBeDefined(); }); it('renders a CloudPulseDashboardWithFilters component successfully for blockstorage', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, service_type: 'blockstorage', id: 7 }, - error: false, - isError: false, + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [{ ...mockDashboard, service_type: 'blockstorage', id: 7 }], + error: '', isLoading: false, }); renderWithTheme( - + ); const startDate = screen.getByText('Start Date'); expect(startDate).toBeInTheDocument(); }); - it('renders a CloudPulseDashboardWithFilters component successfully for firewall nodebalancer', () => { - queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: { ...mockDashboard, service_type: 'firewall', id: 8 }, - error: false, - isError: false, + it('renders a CloudPulseDashboardWithFilters component successfully for firewall nodebalancer', async () => { + vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ + data: [ + { + ...mockDashboard, + service_type: 'firewall', + id: 4, + label: 'linode_firewall_dashbaord', + }, + { + ...mockDashboard, + service_type: 'firewall', + id: 8, + label: 'nodebalancer_firewall_dashbaord', + }, + ], + error: '', isLoading: false, }); renderWithTheme( - + ); + const startDate = screen.getByText('Start Date'); expect(startDate).toBeInTheDocument(); + + await userEvent.click(screen.getByPlaceholderText('Select a Dashboard')); + await userEvent.click(screen.getByText('nodebalancer_firewall_dashbaord')); expect( screen.getByPlaceholderText('Select a NodeBalancer Region') ).toBeVisible(); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index bf17ffbc46a..da2046e17cf 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -2,7 +2,10 @@ import { Box, CircleProgress, Divider, ErrorState, Paper } from '@linode/ui'; import { GridLegacy } from '@mui/material'; import React from 'react'; -import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; +import { + useCloudPulseDashboardByIdQuery, + useCloudPulseDashboardsQuery, +} from 'src/queries/cloudpulse/dashboards'; import { GlobalFilterGroupByRenderer } from '../GroupBy/GlobalFilterGroupByRenderer'; import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; @@ -18,16 +21,21 @@ import { checkMandatoryFiltersSelected, getDashboardProperties, } from '../Utils/ReusableDashboardFilterUtils'; +import { getAllDashboards } from '../Utils/utils'; import { CloudPulseDashboard } from './CloudPulseDashboard'; import type { FilterData, FilterValueType } from './CloudPulseDashboardLanding'; -import type { DateTimeWithPreset } from '@linode/api-v4'; +import type { + CloudPulseServiceType, + Dashboard, + DateTimeWithPreset, +} from '@linode/api-v4'; export interface CloudPulseDashboardWithFiltersProp { /** * The id of the dashboard that needs to be rendered */ - dashboardId: number; + dashboardId?: number; /** * The region for which the metrics will be listed */ @@ -36,18 +44,40 @@ export interface CloudPulseDashboardWithFiltersProp { * The resource id for which the metrics will be listed */ resource: number | string; + /** + * The service type for which the metrics will be listed + */ + serviceType?: CloudPulseServiceType; } export const CloudPulseDashboardWithFilters = React.memo( (props: CloudPulseDashboardWithFiltersProp) => { - const { dashboardId, resource, region } = props; - const { data: dashboard, isError } = - useCloudPulseDashboardByIdQuery(dashboardId); + const { dashboardId, resource, region, serviceType } = props; + + const { data: dashboardById, isError: isDashboardByIdError } = + useCloudPulseDashboardByIdQuery(dashboardId, !serviceType); + + const { data: dashboardsList, error: isError } = getAllDashboards( + useCloudPulseDashboardsQuery(serviceType ? [serviceType] : []), + serviceType ? [serviceType] : [] + ); + const [filterData, setFilterData] = React.useState({ id: {}, label: {}, }); + const [dashboard, setDashboard] = React.useState(); + + // Update dashboard when dashboardsList loads + React.useEffect(() => { + if (dashboardsList.length > 0 && !dashboard) { + setDashboard(dashboardsList[0]); + } + }, [dashboardsList, dashboard]); + + const currentDashboard = serviceType ? dashboard : dashboardById; + const [groupBy, setGroupBy] = React.useState([]); const [timeDuration, setTimeDuration] = @@ -82,6 +112,14 @@ export const CloudPulseDashboardWithFilters = React.memo( setGroupBy(groupBy); }, []); + const handleDashboardChange = React.useCallback( + (dashboard: Dashboard | undefined) => { + setFilterData({ id: {}, label: {} }); + setDashboard(dashboard); + }, + [] + ); + const handleTimeRangeChange = React.useCallback( (timeDuration: DateTimeWithPreset) => { setTimeDuration({ @@ -101,29 +139,25 @@ export const CloudPulseDashboardWithFilters = React.memo( ); }; - if (isError) { - return ( - - ); + if (isError || isDashboardByIdError) { + return ; } - if (!dashboard) { + if (!currentDashboard) { return ; } - if (!FILTER_CONFIG.get(dashboardId)) { + if (!FILTER_CONFIG.get(currentDashboard.id)) { return ( ); } - const isFilterBuilderNeeded = checkIfFilterBuilderNeeded(dashboard); + const isFilterBuilderNeeded = checkIfFilterBuilderNeeded(currentDashboard); const isMandatoryFiltersSelected = checkMandatoryFiltersSelected({ - dashboardObj: dashboard, + dashboardObj: currentDashboard, filterValue: filterData.id, resource, region, @@ -149,8 +183,12 @@ export const CloudPulseDashboardWithFilters = React.memo( m={3} > {showAppliedFilters && ( )} @@ -214,7 +252,7 @@ export const CloudPulseDashboardWithFilters = React.memo( {isMandatoryFiltersSelected ? ( = { { configuration: { filterKey: 'region', + children: ['resource_id'], filterType: 'string', isFilterable: false, isMetricsFilter: false, @@ -72,6 +73,7 @@ export const DBAAS_CONFIG: Readonly = { { configuration: { filterKey: 'engine', + children: ['region', 'resource_id'], filterType: 'string', isFilterable: false, // isFilterable -- this determines whethere you need to pass it metrics api isMetricsFilter: false, // if it is false, it will go as a part of filter params, else global filter @@ -97,6 +99,7 @@ export const DBAAS_CONFIG: Readonly = { { configuration: { dependency: ['engine'], + children: ['resource_id'], filterKey: 'region', filterType: 'string', isFilterable: false, @@ -110,6 +113,7 @@ export const DBAAS_CONFIG: Readonly = { { configuration: { dependency: ['region', 'engine'], + children: ['node_type'], filterKey: 'resource_id', filterType: 'string', isFilterable: true, @@ -164,6 +168,7 @@ export const NODEBALANCER_CONFIG: Readonly = { { configuration: { filterKey: 'region', + children: ['resource_id'], filterType: 'string', isFilterable: false, isMetricsFilter: false, @@ -229,6 +234,7 @@ export const FIREWALL_CONFIG: Readonly = { { configuration: { filterKey: 'resource_id', + children: [PARENT_ENTITY_REGION], filterType: 'string', isFilterable: true, isMetricsFilter: true, @@ -333,6 +339,7 @@ export const FIREWALL_NODEBALANCER_CONFIG: Readonly = { { configuration: { filterKey: 'region', + children: ['resource_id'], filterType: 'string', isFilterable: false, isMetricsFilter: false, @@ -470,7 +481,41 @@ export const BLOCKSTORAGE_CONFIG: Readonly = { ], serviceType: 'blockstorage', }; - +export const LKE_CONFIG: Readonly = { + capability: capabilityServiceTypeMapping['lke'], + filters: [ + { + configuration: { + filterKey: 'region', + filterType: 'string', + isFilterable: false, + isMetricsFilter: false, + name: 'Region', + priority: 1, + neededInViews: [CloudPulseAvailableViews.central], + }, + name: 'Region', + }, + { + configuration: { + dependency: ['region'], + filterKey: 'resource_id', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: true, + name: 'Clusters', + neededInViews: [CloudPulseAvailableViews.central], + placeholder: 'Select Clusters', + priority: 2, + filterFn: (resources: KubernetesCluster[]) => + filterKubernetesClusters(resources), + }, + name: 'Clusters', + }, + ], + serviceType: 'lke', +}; export const FILTER_CONFIG: Readonly< Map > = new Map([ @@ -481,4 +526,5 @@ export const FILTER_CONFIG: Readonly< [6, OBJECTSTORAGE_CONFIG_BUCKET], [7, BLOCKSTORAGE_CONFIG], [8, FIREWALL_NODEBALANCER_CONFIG], + [9, LKE_CONFIG], ]); diff --git a/packages/manager/src/features/CloudPulse/Utils/UserPreference.test.ts b/packages/manager/src/features/CloudPulse/Utils/UserPreference.test.ts index 20d401a56dc..7124c993934 100644 --- a/packages/manager/src/features/CloudPulse/Utils/UserPreference.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/UserPreference.test.ts @@ -7,10 +7,14 @@ const queryMocks = vi.hoisted(() => ({ usePreferences: vi.fn(), })); -vi.mock('@linode/queries', () => ({ - useMutatePreferences: queryMocks.useMutatePreferences, - usePreferences: queryMocks.usePreferences, -})); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useMutatePreferences: queryMocks.useMutatePreferences, + usePreferences: queryMocks.usePreferences, + }; +}); describe('usePreferencesToggle', () => { it('should initialize with undefined preference', () => { diff --git a/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts b/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts index c8985199e51..d02a521867b 100644 --- a/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts +++ b/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts @@ -2,6 +2,7 @@ import { useMutatePreferences, usePreferences } from '@linode/queries'; import { useRef } from 'react'; import { DASHBOARD_ID, WIDGETS } from './constants'; +import { FILTER_CONFIG } from './FilterConfig'; import type { AclpConfig, AclpWidget } from '@linode/api-v4'; import type { ManagerPreferences } from '@linode/utilities'; @@ -127,3 +128,49 @@ export const usePreferencesToggle = ({ toggle, }; }; + +const preferenceToFilterKeyMap: Record = { + resource_id: 'resources', +}; + +export const clearChildPreferences = ( + dashboardId: number, + parentFilterKey: string +): Record => { + const filters = FILTER_CONFIG.get(dashboardId)?.filters; + + if (!filters) { + return {}; + } + // Create a mapping of filterKey to its children for quick lookup + const filterToChildrenMap: Record = filters.reduce< + Record + >((previousValue, filter) => { + const { filterKey: key, children } = filter.configuration; + if (children) { + previousValue[key] = children; + } + return previousValue; + }, {}); + const clearedPreferences = new Set([parentFilterKey]); + const filterKeyQueue = [parentFilterKey]; + const response: Record = {}; + + while (filterKeyQueue.length > 0) { + const currentFilterKey = filterKeyQueue.shift(); + if (currentFilterKey === undefined) { + continue; + } + const children = filterToChildrenMap[currentFilterKey]; + + // Clear all the children which are not already cleared + children?.forEach((childKey) => { + if (!clearedPreferences.has(childKey)) { + clearedPreferences.add(childKey); + filterKeyQueue.push(childKey); + response[preferenceToFilterKeyMap[childKey] ?? childKey] = undefined; + } + }); + } + return response; +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index 2095c2852be..82f9e6b4599 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -98,6 +98,7 @@ export const NO_REGION_MESSAGE: Record = { 6: 'No Object Storage buckets configured in any region.', 7: 'No volumes configured in any regions.', 8: 'No firewalls configured in any Nodebalancer regions.', + 9: 'No LKE clusters configured in any regions.', }; export const HELPER_TEXT: Record = { diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index 0386c8bd7a2..a4ef0497eb6 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -6,6 +6,7 @@ import type { DatabaseInstance, DatabaseType, Firewall, + KubernetesCluster, Linode, NodeBalancer, ObjectStorageBucket, @@ -59,11 +60,11 @@ export type QueryFunctionType = | DatabaseInstance[] | DatabaseType[] | Firewall[] + | KubernetesCluster[] | Linode[] | NodeBalancer[] | ObjectStorageBucket[] | Volume[]; - /** * The non array types of QueryFunctionType like DatabaseEngine|DatabaseType */ @@ -113,6 +114,10 @@ export interface CloudPulseServiceTypeFiltersConfiguration { * This is an optional field, controls the associated entity type for the dashboard */ associatedEntityType?: AssociatedEntityType; + /** + * This is an optional field, it is used to define the child filters for a parent filter + */ + children?: string[]; /** * This is an optional field, it is used to disable a certain filter, untill of the dependent filters are selected diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index 657235635bb..3c6d3be0e5f 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -1,7 +1,7 @@ import { regionFactory } from '@linode/utilities'; import { describe, expect, it } from 'vitest'; -import { serviceTypesFactory } from 'src/factories'; +import { kubernetesClusterFactory, serviceTypesFactory } from 'src/factories'; import { firewallEntityfactory, firewallFactory, @@ -25,6 +25,7 @@ import { arePortsValid, areValidInterfaceIds, filterFirewallResources, + filterKubernetesClusters, getAssociatedEntityType, getEnabledServiceTypes, getFilteredDimensions, @@ -452,6 +453,49 @@ describe('getEnabledServiceTypes', () => { }); }); +describe('filterKubernetesClusters', () => { + it('should return the filtered kubernetes clusters for enterprise', () => { + const clusters = [ + ...kubernetesClusterFactory.buildList(5, { tier: 'standard' }), + ...kubernetesClusterFactory.buildList(5, { tier: 'enterprise' }), + ]; + expect(filterKubernetesClusters(clusters)).toHaveLength(5); + }); + it('should return the filtered kubernetes clusters for enterprise sorted by label', () => { + const clusters = [ + kubernetesClusterFactory.build({ + tier: 'enterprise', + label: 'pl-labkrk-2-redis-cluster', + }), + kubernetesClusterFactory.build({ + tier: 'enterprise', + label: 'pl-labkrk-2-mr-api-4', + }), + kubernetesClusterFactory.build({ + tier: 'enterprise', + label: 'pl-labkrk-2-alertmanager2', + }), + kubernetesClusterFactory.build({ + tier: 'enterprise', + label: 'pl-labkrk-2-alertmanager', + }), + ]; + + expect(filterKubernetesClusters(clusters)[0].label).toBe( + 'pl-labkrk-2-alertmanager' + ); + expect(filterKubernetesClusters(clusters)[1].label).toBe( + 'pl-labkrk-2-alertmanager2' + ); + expect(filterKubernetesClusters(clusters)[2].label).toBe( + 'pl-labkrk-2-mr-api-4' + ); + expect(filterKubernetesClusters(clusters)[3].label).toBe( + 'pl-labkrk-2-redis-cluster' + ); + }); +}); + describe('isValidFilter', () => { const valuedDim: Dimension = { dimension_label: 'browser', diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index c49c50500ae..2c2f6481d44 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -39,6 +39,7 @@ import type { Dimension, Firewall, FirewallDeviceEntity, + KubernetesCluster, MonitoringCapabilities, ResourcePage, Service, @@ -587,3 +588,15 @@ export const filterFirewallResources = ( }) ); }; + +/** + * @param clusters The list of kubernetes clusters + * @returns The filtered kubernetes clusters based on the tier + */ +export const filterKubernetesClusters = ( + clusters: KubernetesCluster[] +): KubernetesCluster[] => { + return clusters + .filter(({ tier }) => tier === 'enterprise') + .sort((a, b) => a.label.localeCompare(b.label)); +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx index 108e344c3ca..843799579e7 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx @@ -54,6 +54,7 @@ describe('CloudPulseCustomSelect component tests', () => { it('should render a component successfully with required props static', () => { const screen = renderWithTheme( { it('should render a component successfully with required props static with multi select', () => { const screen = renderWithTheme( { const screen = renderWithTheme( { const screen = renderWithTheme( { it('should render a component successfully with static props and no default value with isOptional true', () => { renderWithTheme( { filterKey: 'test', handleSelectionChange, value: selectedValue, + dashboardId: 1, }); expect(result).toBeDefined(); @@ -36,6 +37,7 @@ it('test handleCustomSelectionChange method for multiple selection', () => { filterKey: 'test', handleSelectionChange, value: selectedValue, + dashboardId: 1, }); expect(result).toBeDefined(); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts index 605aa371a49..4333bb178a9 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts @@ -1,3 +1,5 @@ +import { clearChildPreferences } from '../Utils/UserPreference'; + import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseServiceTypeFiltersOptions } from '../Utils/models'; import type { AclpConfig, FilterValue } from '@linode/api-v4'; @@ -103,6 +105,11 @@ interface CloudPulseCustomSelectionChangeProps */ clearSelections: string[]; + /** + * Id of the selected dashboard + */ + dashboardId: number; + /** * The maximum number of selections that needs to be allowed */ @@ -194,6 +201,7 @@ export const handleCustomSelectionChange = ( handleSelectionChange, maxSelections, savePreferences, + dashboardId, } = selectionChangeProps; let { value } = selectionChangeProps; @@ -218,7 +226,10 @@ export const handleCustomSelectionChange = ( // update the preferences if (savePreferences) { - updatedPreferenceData = { [filterKey]: result }; + updatedPreferenceData = { + ...clearChildPreferences(dashboardId, filterKey), + [filterKey]: result, + }; } // update the clear selections in the preference diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 4b033145e94..502d51dbfa4 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -35,6 +35,7 @@ import { } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { type CloudPulseServiceTypeFilters } from '../Utils/models'; +import { clearChildPreferences } from '../Utils/UserPreference'; import type { CloudPulseMetricsFilter, @@ -174,11 +175,12 @@ export const CloudPulseDashboardFilterBuilder = React.memo( label.filter((l) => l !== ''), savePref, { + ...clearChildPreferences(dashboard.id, filterKey), [filterKey]: port, } ); }, - [emitFilterChangeByFilterKey] + [dashboard.id, emitFilterChangeByFilterKey] ); const handleNodeTypeChange = React.useCallback( @@ -188,10 +190,11 @@ export const CloudPulseDashboardFilterBuilder = React.memo( savePref: boolean = false ) => { emitFilterChangeByFilterKey(NODE_TYPE, nodeTypeId, label, savePref, { + ...clearChildPreferences(dashboard.id, NODE_TYPE), [NODE_TYPE]: nodeTypeId, }); }, - [emitFilterChangeByFilterKey] + [dashboard.id, emitFilterChangeByFilterKey] ); const handleTagsChange = React.useCallback( @@ -203,12 +206,12 @@ export const CloudPulseDashboardFilterBuilder = React.memo( selectedTags, savePref, { - [RESOURCE_ID]: undefined, + ...clearChildPreferences(dashboard.id, TAGS), [TAGS]: selectedTags, } ); }, - [emitFilterChangeByFilterKey] + [dashboard.id, emitFilterChangeByFilterKey] ); const handleResourceChange = React.useCallback( @@ -219,15 +222,14 @@ export const CloudPulseDashboardFilterBuilder = React.memo( resourceId.map((resource) => resource.label), savePref, { - [NODE_TYPE]: undefined, - [PARENT_ENTITY_REGION]: undefined, + ...clearChildPreferences(dashboard.id, RESOURCE_ID), [RESOURCES]: resourceId.map((resource: { id: string }) => String(resource.id) ), } ); }, - [emitFilterChangeByFilterKey] + [dashboard.id, emitFilterChangeByFilterKey] ); const handleRegionChange = React.useCallback( @@ -237,18 +239,10 @@ export const CloudPulseDashboardFilterBuilder = React.memo( labels: string[], savePref: boolean = false ) => { - const updatedPreferenceData = - filterKey === REGION - ? { - [filterKey]: region, - [ENDPOINT]: undefined, - [RESOURCES]: undefined, - [TAGS]: undefined, - } - : { - [filterKey]: region, - [NODEBALANCER_ID]: undefined, - }; + const updatedPreferenceData: AclpConfig = { + ...clearChildPreferences(dashboard.id, filterKey), + [filterKey]: region, + }; emitFilterChangeByFilterKey( filterKey, region, @@ -257,17 +251,17 @@ export const CloudPulseDashboardFilterBuilder = React.memo( updatedPreferenceData ); }, - [emitFilterChangeByFilterKey] + [dashboard.id, emitFilterChangeByFilterKey] ); const handleEndpointsChange = React.useCallback( (endpoints: string[], savePref: boolean = false) => { emitFilterChangeByFilterKey(ENDPOINT, endpoints, endpoints, savePref, { + ...clearChildPreferences(dashboard.id, ENDPOINT), [ENDPOINT]: endpoints, - [RESOURCES]: undefined, }); }, - [emitFilterChangeByFilterKey] + [dashboard.id, emitFilterChangeByFilterKey] ); const handleFirewallNodebalancersChange = React.useCallback( @@ -278,13 +272,14 @@ export const CloudPulseDashboardFilterBuilder = React.memo( nodebalancers.map((nodebalancer) => nodebalancer.label), savePref, { + ...clearChildPreferences(dashboard.id, NODEBALANCER_ID), [NODEBALANCER_ID]: nodebalancers.map( (nodebalancer) => nodebalancer.id ), } ); }, - [emitFilterChangeByFilterKey] + [dashboard.id, emitFilterChangeByFilterKey] ); const handleCustomSelectChange = React.useCallback( diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx index 2da4aae3abf..362d88a8a4c 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx @@ -18,7 +18,7 @@ const queryMocks = vi.hoisted(() => ({ useCloudPulseDashboardsQuery: vi.fn().mockReturnValue({}), useCloudPulseServiceTypes: vi.fn().mockReturnValue({}), })); -const mockDashboard = dashboardFactory.build(); +const mockDashboard = dashboardFactory.buildList(2); const mockServiceTypesList = serviceTypesFactory.build(); vi.mock('src/queries/cloudpulse/dashboards', async () => { @@ -52,7 +52,7 @@ queryMocks.useCloudPulseServiceTypes.mockReturnValue({ }); vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ - data: [mockDashboard], + data: mockDashboard, error: '', isLoading: false, }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index 07153cc8529..3bb9537f959 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -28,9 +28,13 @@ export interface CloudPulseDashboardSelectProps { savePref?: boolean ) => void; /** - * flag value to identify whether this component is being used in service level integration or not + * The service type to be used for the dashboard select in service level integration */ - isServiceIntegration?: boolean; + integrationServiceType?: CloudPulseServiceType; + /** + * boolean value to identify whether only dashboard id is provided by service owner + */ + onlyServiceLevelDashboardIdAvailable?: boolean; /** * boolean value to identify whether changes to be saved on preferences or not */ @@ -42,23 +46,29 @@ export const CloudPulseDashboardSelect = React.memo( const { defaultValue, handleDashboardChange = () => {}, - isServiceIntegration, savePreferences, + integrationServiceType, + onlyServiceLevelDashboardIdAvailable, } = props; const { data: serviceTypesList, error: serviceTypesError, isLoading: serviceTypesLoading, - } = useCloudPulseServiceTypes(true); + } = useCloudPulseServiceTypes(!!savePreferences); const { aclpServices } = useFlags(); + // Check if the integration service type is enabled + const serviceType = + integrationServiceType && + aclpServices?.[integrationServiceType]?.metrics?.enabled + ? integrationServiceType + : undefined; // Get formatted enabled service types based on the LD flag - const serviceTypes: CloudPulseServiceType[] = getEnabledServiceTypes( - serviceTypesList, - aclpServices - ); + const serviceTypes: CloudPulseServiceType[] = serviceType + ? [serviceType] + : getEnabledServiceTypes(serviceTypesList, aclpServices); const serviceTypeMap: Map = new Map( (serviceTypesList?.data || []) @@ -104,7 +114,7 @@ export const CloudPulseDashboardSelect = React.memo( React.useEffect(() => { // only call this code when the component is rendered initially if ( - (savePreferences || isServiceIntegration) && + (savePreferences || !!serviceType) && dashboardsList.length > 0 && selectedDashboard === undefined ) { @@ -112,7 +122,10 @@ export const CloudPulseDashboardSelect = React.memo( ? dashboardsList.find((obj: Dashboard) => obj.id === defaultValue) : undefined; setSelectedDashboard(dashboard); - handleDashboardChange(dashboard); + // If only dashboard id is provided by service owner, there is no need to call the handleDashboardChange function + if (!onlyServiceLevelDashboardIdAvailable) { + handleDashboardChange(dashboard); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dashboardsList]); @@ -121,7 +134,12 @@ export const CloudPulseDashboardSelect = React.memo( autoHighlight clearOnBlur data-testid="cloudpulse-dashboard-select" - disabled={isServiceIntegration || !dashboardsList} + disableClearable={!!serviceType} + disabled={ + !dashboardsList.length || + (!savePreferences && !!onlyServiceLevelDashboardIdAvailable) || + (!savePreferences && dashboardsList.length === 1) + } errorText={dashboardsList?.length ? '' : errorText} fullWidth groupBy={(option: Dashboard) => option.service_type} @@ -143,11 +161,13 @@ export const CloudPulseDashboardSelect = React.memo( sx={{ marginLeft: '3.5%' }} variant="h3" > - {serviceTypeMap.get(params.group as CloudPulseServiceType) || - params.group} + {!serviceType && + (serviceTypeMap.get(params.group as CloudPulseServiceType) || + params.group)} - {aclpServices?.[params.group as CloudPulseServiceType]?.metrics - ?.beta && } + {!serviceType && + aclpServices?.[params.group as CloudPulseServiceType]?.metrics + ?.beta && } {params.children} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index 744071010af..14d53e1a7e8 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -304,6 +304,21 @@ describe('CloudPulseRegionSelect', () => { expect(screen.getByText(NO_REGION_MESSAGE[4])).toBeVisible(); }); + it('should render a Region Select component with correct info message when no regions are available for lke service type', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText(NO_REGION_MESSAGE[9])).toBeVisible(); + }); + it('Should show the correct linode region in the dropdown for firewall service type when savePreferences is true', async () => { const user = userEvent.setup(); queryMocks.useRegionsQuery.mockReturnValue({ diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 49f7f6ebbd5..c49ad4c0fd7 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -15,7 +15,10 @@ import { } from '../Utils/constants'; import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; -import { getAssociatedEntityType } from '../Utils/utils'; +import { + getAssociatedEntityType, + getResourcesFilterConfig, +} from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { Item } from '../Alerts/constants'; @@ -56,6 +59,11 @@ export const CloudPulseRegionSelect = React.memo( } = props; const { data: regions, isError, isLoading } = useRegionsQuery(); + // Get the resources filter configuration for the dashboard + const resourcesFilterConfig = getResourcesFilterConfig( + selectedDashboard?.id + ); + const filterFn = resourcesFilterConfig?.filterFn; const { data: resources, isError: isResourcesError, @@ -66,7 +74,9 @@ export const CloudPulseRegionSelect = React.memo( {}, { ...(RESOURCE_FILTER_MAP[selectedDashboard?.service_type ?? ''] ?? {}), - } + }, + undefined, + filterFn ); const flags = useFlags(); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index ed1950ee874..a6b234c03cc 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -23,6 +23,7 @@ export interface CloudPulseResources { label: string; region?: string; tags?: string[]; + volumeLinodeId?: string; } export interface CloudPulseResourcesSelectProps { diff --git a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts index c7ccdae7870..d90b7c1d669 100644 --- a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts +++ b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts @@ -38,4 +38,7 @@ export const DIMENSION_TRANSFORM_CONFIG: Partial< objectstorage: { endpoint: TRANSFORMS.original, }, + blockstorage: { + linode_id: TRANSFORMS.original, + }, }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index d9b3b0d8aa2..7c670ebf370 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -53,7 +53,7 @@ export interface DatabaseCreateValues { cluster_size: ClusterSize; engine: Engine; label: string; - private_network?: PrivateNetwork; + private_network?: null | PrivateNetwork; region: string; type: string; } diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx index 30d03f0a799..46353595165 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx @@ -2,7 +2,7 @@ import { Typography } from '@linode/ui'; import * as React from 'react'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; -import { DatabaseVPCSelector } from './DatabaseVPCSelector'; +import { DatabaseCreateVPC } from './DatabaseCreateVPC'; import type { AccessProps } from './DatabaseCreateAccessControls'; import type { VPC } from '@linode/api-v4'; @@ -30,7 +30,7 @@ export const DatabaseCreateNetworkingConfiguration = ( - + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateVPC.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateVPC.tsx new file mode 100644 index 00000000000..6143a0c9037 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateVPC.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { DatabaseVPC } from './DatabaseVPC'; + +import type { DatabaseCreateValues } from './DatabaseCreate'; +import type { VPC } from '@linode/api-v4'; + +interface DatabaseCreateVPCProps { + onChange: (selectedVPC: null | VPC) => void; +} + +export const DatabaseCreateVPC = (props: DatabaseCreateVPCProps) => { + const { onChange } = props; + + const { control } = + useFormContext>(); + + const { control: networkControl, setValue } = + useFormContext>(); + + const region = useWatch({ + control, + name: 'region', + }); + + const [vpcId, subnetId] = useWatch({ + control: networkControl, + name: ['private_network.vpc_id', 'private_network.subnet_id'], + }); + + return ( + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.test.tsx similarity index 57% rename from packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx rename to packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.test.tsx index 1ca6bade784..a0b01817574 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.test.tsx @@ -5,9 +5,10 @@ import * as React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { subnetFactory, vpcFactory } from 'src/factories'; -import { DatabaseVPCSelector } from 'src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { DatabaseCreateVPC } from 'src/features/Databases/DatabaseCreate/DatabaseCreateVPC'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; +import type { DatabaseCreateValues } from './DatabaseCreate'; import type { PrivateNetwork } from '@linode/api-v4'; // Hoist query mocks @@ -62,21 +63,12 @@ const vpcSelectorTestId = 'database-vpc-selector'; const subnetSelectorTestId = 'database-subnet-selector'; const vpcPlaceholder = 'Select a VPC'; const subnetPlaceholder = 'Select a subnet'; -const mockMode: 'create' | 'networking' = 'create'; - -describe('DatabaseVPCSelector', () => { - const mockProps = { - errors: {}, - onChange: vi.fn(), - onConfigurationChange: vi.fn(), - privateNetworkValues: { - vpc_id: null, - subnet_id: null, - public_access: false, - }, - resetFormFields: vi.fn(), - selectedRegionId: '', - mode: mockMode, + +describe('DatabaseCreateVPC', () => { + const defaultPrivateNetworkValues = { + vpc_id: null, + subnet_id: null, + public_access: false, }; beforeEach(() => { @@ -92,13 +84,23 @@ describe('DatabaseVPCSelector', () => { }); it('Should render the VPC selector heading', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { private_network: defaultPrivateNetworkValues }, + }, + }); const vpcField = screen.getByText('Assign a VPC', { exact: true }); expect(vpcField).toBeInTheDocument(); }); it('Should render VPC autocomplete in initial disabled state', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { private_network: defaultPrivateNetworkValues }, + }, + }); const vpcSelector = screen.getByTestId(vpcSelectorTestId); expect(vpcSelector).toBeInTheDocument(); const vpcSelectorInput = screen.getByPlaceholderText(vpcPlaceholder); @@ -126,8 +128,15 @@ describe('DatabaseVPCSelector', () => { isLoading: false, }); - const mockEnabledProps = { ...mockProps, selectedRegionId: 'us-east' }; - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: 'us-east', + }, + }, + }); const vpcSelector = screen.getByTestId(vpcSelectorTestId); expect(vpcSelector).toBeInTheDocument(); @@ -151,12 +160,15 @@ describe('DatabaseVPCSelector', () => { public_access: false, }; - const mockEnabledProps = { - ...mockProps, - privateNetworkValues: mockPrivateNetwork, - selectedRegionId: 'us-east', - }; - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-east', + }, + }, + }); const vpcInput = screen.getByPlaceholderText( vpcPlaceholder @@ -184,12 +196,15 @@ describe('DatabaseVPCSelector', () => { public_access: true, }; - const mockEnabledProps = { - ...mockProps, - privateNetworkValues: mockPrivateNetwork, - selectedRegionId: 'us-east', - }; - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-east', + }, + }, + }); const vpcInput = screen.getByPlaceholderText( vpcPlaceholder @@ -243,29 +258,29 @@ describe('DatabaseVPCSelector', () => { const resetFormFields = vi.fn(); const onConfigurationChange = vi.fn(); - const { rerender } = renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-east', + }, + }, + }); // Change region to a new one queryMocks.useRegionQuery.mockReturnValue({ data: region2 }); queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); - rerender( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-west', + }, + }, + }); expect(resetFormFields).toHaveBeenCalled(); expect(onConfigurationChange).toHaveBeenCalledWith(null); @@ -283,34 +298,44 @@ describe('DatabaseVPCSelector', () => { const resetFormFields = vi.fn(); const onConfigurationChange = vi.fn(); - const { rerender } = renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: '', + }, + }, + }); // Now render with a valid region queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); - rerender( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: 'us-east', + }, + }, + }); expect(resetFormFields).not.toHaveBeenCalled(); expect(onConfigurationChange).not.toHaveBeenCalledWith(null); }); it('Should show long helper text when no region is selected', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: '', + }, + }, + }); const expectedHelperText = screen.getByText(initialHelperText, { exact: true, }); @@ -321,9 +346,15 @@ describe('DatabaseVPCSelector', () => { queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); - renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: 'us-east', + }, + }, + }); const expectedHelperText = screen.getByText(altHelperText, { exact: true }); expect(expectedHelperText).toBeInTheDocument(); @@ -340,77 +371,24 @@ describe('DatabaseVPCSelector', () => { isLoading: false, }); - renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: 'us-east', + }, + }, + }); + const expectedAltHelperText = screen.queryByText(altHelperText); const expectedInitialHelperText = screen.queryByText(initialHelperText); expect(expectedAltHelperText).not.toBeInTheDocument(); expect(expectedInitialHelperText).not.toBeInTheDocument(); }); - it('Should show vpc validation error text when there is a vpc error', () => { - setUpBaseMocks(); - const mockPrivateNetwork: PrivateNetwork = { - vpc_id: 1234, - subnet_id: null, - public_access: false, - }; - - const mockErrors = { - private_network: { - vpc_id: 'VPC is required.', - }, - }; - - renderWithTheme( - - ); - - const subnetSelector = screen.getByTestId(subnetSelectorTestId); - expect(subnetSelector).toBeInTheDocument(); - const expectedValidationError = screen.getByText('VPC is required.'); - expect(expectedValidationError).toBeInTheDocument(); - }); - - it('Should show subnet validation error text when there is a subnet error', () => { - setUpBaseMocks(); - const mockPrivateNetwork: PrivateNetwork = { - vpc_id: 1234, - subnet_id: null, - public_access: false, - }; - - const mockErrors = { - private_network: { - subnet_id: 'Subnet is required.', - }, - }; - - renderWithTheme( - - ); - - const subnetSelector = screen.getByTestId(subnetSelectorTestId); - expect(subnetSelector).toBeInTheDocument(); - const expectedValidationError = screen.getByText('Subnet is required.'); - expect(expectedValidationError).toBeInTheDocument(); - }); - - it('Should clear subnet field when the VPC field is cleared', async () => { + it('Should hide the subnet field when the VPC field is cleared', async () => { setUpBaseMocks(); - const onChange = vi.fn(); - // Start with both VPC and subnet selected const mockPrivateNetwork: PrivateNetwork = { vpc_id: 1234, @@ -418,102 +396,29 @@ describe('DatabaseVPCSelector', () => { public_access: false, }; - renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-east', + }, + }, + }); - // Simulate clearing the VPC field (user clears the Autocomplete) + // Clear VPC selection const vpcSelector = screen.getByTestId(vpcSelectorTestId); const clearButton = vpcSelector.querySelector( 'button[title="Clear"]' ) as HTMLElement; await userEvent.click(clearButton); - // ...assertions as above... - expect(onChange).toHaveBeenCalledWith('private_network.vpc_id', null); - expect(onChange).toHaveBeenCalledWith('private_network.subnet_id', null); - expect(onChange).toHaveBeenCalledWith( - 'private_network.public_access', - false - ); - }); - - it('Should call onChange for the VPC field when a value is selected', async () => { - setUpBaseMocks(); - const onChange = vi.fn(); - - // Start with no VPC selected - const mockPrivateNetwork: PrivateNetwork = { - vpc_id: null, - subnet_id: null, - public_access: false, - }; - renderWithTheme( - - ); - - // Simulate selecting a VPC from the Autocomplete - const vpcInput = screen.getByPlaceholderText( - vpcPlaceholder - ) as HTMLInputElement; - // Open the autocomplete dropdown - await userEvent.click(vpcInput); - - // Select the option - const newVPC = await screen.findByText('VPC 1'); - await userEvent.click(newVPC); - - expect(onChange).toHaveBeenCalledWith( - 'private_network.vpc_id', - mockVPCWithSubnet.id - ); - }); - - it('Should call onChange for the Subnet field when subnet value is selected', async () => { - setUpBaseMocks(); - const onChange = vi.fn(); - - // Start with VPC selected and no subnet selection - const mockPrivateNetwork: PrivateNetwork = { - vpc_id: 1234, - subnet_id: null, - public_access: false, - }; - - renderWithTheme( - - ); - - // Simulate selecting a Subnet from the Autocomplete - const subnetInput = screen.getByPlaceholderText( - subnetPlaceholder - ) as HTMLInputElement; - - await userEvent.click(subnetInput); - - // Select the option - const expectedSubnetLabel = `${mockSubnets[0].label} (${mockSubnets[0].ipv4})`; - const newSubnet = await screen.findByText(expectedSubnetLabel); - await userEvent.click(newSubnet); - - expect(onChange).toHaveBeenCalledWith( - 'private_network.subnet_id', - mockSubnets[0].id - ); + const subnetSelector = screen.queryByTestId(subnetSelectorTestId); + expect(subnetSelector).not.toBeInTheDocument(); + expect( + screen.getByText( + 'The cluster will have public access by default if a VPC is not assigned.' + ) + ).toBeVisible(); }); }); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.tsx similarity index 78% rename from packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx rename to packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.tsx index 843d55aefdf..c7d0a1bfb5f 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.tsx @@ -9,31 +9,44 @@ import { Typography, } from '@linode/ui'; import * as React from 'react'; -import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import type { Control, UseFormSetValue, UseFormTrigger } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; import { Link } from 'src/components/Link'; +import { MANAGE_NETWORKING_LEARN_MORE_LINK } from 'src/features/Databases/constants'; import { useFlags } from 'src/hooks/useFlags'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../constants'; - -import type { DatabaseCreateValues } from './DatabaseCreate'; -import type { VPC } from '@linode/api-v4'; +import type { PrivateNetwork, VPC } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -interface DatabaseVPCSelectorProps { - onChange: (selectedVPC: null | VPC) => void; +interface NetworkValues { + private_network?: null | PrivateNetwork; } -export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { - const { onChange } = props; - const flags = useFlags(); - const { control, setValue } = useFormContext(); +interface DatabaseVPCProps { + control: Control; + mode: 'create' | 'networking'; + onChange?: (selectedVPC: null | VPC) => void; + region: string; + setValue: UseFormSetValue; + subnetId: null | number; + trigger?: UseFormTrigger; + vpcId: null | number; +} - const [region, vpcId, subnetId] = useWatch({ +export const DatabaseVPC = (props: DatabaseVPCProps) => { + const { + onChange, + setValue, + trigger, control, - name: ['region', 'private_network.vpc_id', 'private_network.subnet_id'], - }); + region, + vpcId, + subnetId, + mode, + } = props; + const flags = useFlags(); const { data: selectedRegion } = useRegionQuery(region); const regionSupportsVPCs = selectedRegion?.capabilities.includes('VPCs'); @@ -41,7 +54,7 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { const { data: vpcs, error: vpcsError, - isLoading, + isLoading: vpcsLoading, } = useAllVPCsQuery({ enabled: regionSupportsVPCs, filter: { region }, @@ -96,19 +109,20 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { errorText={vpcErrorMessage || fieldState.error?.message} helperText={disableVPCSelectors ? vpcHelperTextCopy : undefined} label="VPC" - loading={isLoading} + loading={vpcsLoading} noOptionsText="There are no VPCs in the selected region." onChange={(e, value) => { setValue('private_network.subnet_id', null); // Always reset subnet selection when VPC changes + trigger?.('private_network.subnet_id'); if (!value) { setValue('private_network.public_access', false); } - onChange(value ?? null); // Update VPC in DatabaseCreate.tsx + onChange?.(value ?? null); // Update VPC in DatabaseCreate.tsx field.onChange(value?.id ?? null); }} options={vpcs ?? []} placeholder="Select a VPC" - sx={{ width: '354px' }} + sx={{ width: '390px' }} textFieldProps={{ tooltipText: 'A cluster may be assigned only to a VPC in the same region', @@ -132,6 +146,7 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { label="Subnet" onChange={(e, value) => { field.onChange(value?.id ?? null); + trigger?.('private_network.subnet_id'); }} options={selectedVPC?.subnets ?? []} placeholder="Select a subnet" @@ -176,13 +191,15 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { ) : ( - ({ - marginTop: theme.spacingFunction(20), - })} - text="The cluster will have public access by default if a VPC is not assigned." - variant="info" - /> + mode === 'create' && ( + ({ + marginTop: theme.spacingFunction(20), + })} + text="The cluster will have public access by default if a VPC is not assigned." + variant="info" + /> + ) )} ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx index 0514ccebc7e..be62f9b5cb5 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx @@ -12,7 +12,7 @@ import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import AddAccessControlDrawer from './AddAccessControlDrawer'; +import { ManageAccessControlDrawer } from './ManageAccessControlDrawer'; import type { APIError, Database } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; @@ -93,7 +93,7 @@ export const AccessControls = (props: Props) => { const [accessControlToBeRemoved, setAccessControlToBeRemoved] = React.useState(null); - const [addAccessControlDrawerOpen, setAddAccessControlDrawerOpen] = + const [manageAccessControlDrawerOpen, setManageAccessControlDrawerOpen] = React.useState(false); const { isPending: databaseUpdating, mutateAsync: updateDatabase } = @@ -176,7 +176,7 @@ export const AccessControls = (props: Props) => { className={classes.addAccessControlBtn} data-testid="button-access-control" disabled={disabled} - onClick={() => setAddAccessControlDrawerOpen(true)} + onClick={() => setManageAccessControlDrawerOpen(true)} variant="secondary" > Manage Access @@ -197,10 +197,10 @@ export const AccessControls = (props: Props) => { address. - setAddAccessControlDrawerOpen(false)} - open={addAccessControlDrawerOpen} + onClose={() => setManageAccessControlDrawerOpen(false)} + open={manageAccessControlDrawerOpen} /> ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.tsx deleted file mode 100644 index 25295048ca9..00000000000 --- a/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useDatabaseMutation } from '@linode/queries'; -import { ActionsPanel, Drawer, Notice, Typography } from '@linode/ui'; -import { useFormik } from 'formik'; -import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; - -import { Link } from 'src/components/Link'; -import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; -import { - ACCESS_CONTROLS_DRAWER_TEXT, - ACCESS_CONTROLS_DRAWER_TEXT_LEGACY, - ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT, - ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT_LEGACY, - LEARN_MORE_LINK, - LEARN_MORE_LINK_LEGACY, -} from 'src/features/Databases/constants'; -import { isDefaultDatabase } from 'src/features/Databases/utilities'; -import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; -import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; -import { - extendedIPToString, - ipFieldPlaceholder, - ipV6FieldPlaceholder, - stringToExtendedIP, - validateIPs, -} from 'src/utilities/ipUtils'; - -import type { Database, DatabaseInstance } from '@linode/api-v4'; -import type { APIError } from '@linode/api-v4/lib/types'; -import type { Theme } from '@mui/material/styles'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; - -const useStyles = makeStyles()((theme: Theme) => ({ - instructions: { - marginBottom: '2rem', - }, - ipSelect: { - marginTop: theme.spacing(2), - }, -})); - -interface Props { - database: Database | DatabaseInstance; - onClose: () => void; - open: boolean; -} - -interface Values { - _allowList: ExtendedIP[]; -} - -type CombinedProps = Props; - -const AddAccessControlDrawer = (props: CombinedProps) => { - const { database, onClose, open } = props; - - const { classes } = useStyles(); - - const [error, setError] = React.useState(''); - const [allowListErrors, setAllowListErrors] = React.useState(); - - // This will be set to `true` once a form field has been touched. This is used to disable the - // "Update Access Controls" button unless there have been changes to the form. - const [formTouched, setFormTouched] = React.useState(false); - - const handleIPBlur = (_ips: ExtendedIP[]) => { - const _ipsWithMasks = enforceIPMasks(_ips); - - setValues({ _allowList: _ipsWithMasks }); - }; - - const { mutateAsync: updateDatabase } = useDatabaseMutation( - database.engine, - database.id - ); - - const isDefaultDB = isDefaultDatabase(database); - - const handleUpdateAccessControlsClick = ( - { _allowList }: Values, - { - setFieldError, - setSubmitting, - }: { - setFieldError: (field: string, reason: string) => void; - setSubmitting: (isSubmitting: boolean) => void; - } - ) => { - // Get the IP address strings out of the objects and filter empty strings out. - // Ensure we append /32 to all IPs if / is not already present. - const allowListRetracted = _allowList.reduce((acc, currentIP) => { - let ipString = extendedIPToString(currentIP); - if (ipString === '') { - return acc; - } - - if (ipString.indexOf('/') === -1) { - ipString += '/32'; - } - - return [...acc, ipString]; - }, []); - - updateDatabase({ allow_list: [...allowListRetracted] }) - .then(() => { - setSubmitting(false); - onClose(); - }) - .catch((errors: any) => { - // Surface allow_list errors -- for example, "Invalid IPv4 address(es): ..." - const allowListErrors = errors.filter( - (error: APIError) => error.field === 'allow_list' - ); - if (allowListErrors) { - setAllowListErrors(allowListErrors); - } - - handleAPIErrors(errors, setFieldError, setError); - setSubmitting(false); - }); - }; - - const onValidate = ({ _allowList }: Values) => { - const validatedIPs = validateIPs(_allowList, { - allowEmptyAddress: false, - errorMessage: isDefaultDB - ? ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT - : ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT_LEGACY, - }); - - setValues({ _allowList: validatedIPs }); - - const ipsWithErrors = validatedIPs.filter((thisIP) => - Boolean(thisIP.error) - ); - - if (ipsWithErrors.length === 0) { - return {}; - } - - return { - _allowList: ipsWithErrors, - }; - }; - - const { handleSubmit, isSubmitting, resetForm, setValues, values } = - useFormik({ - enableReinitialize: true, - initialValues: { - _allowList: database?.allow_list?.map(stringToExtendedIP), - }, - onSubmit: handleUpdateAccessControlsClick, - validate: (values: Values) => onValidate(values), - validateOnBlur: false, - validateOnChange: false, - }); - - const handleIPChange = React.useCallback( - (_ips: ExtendedIP[]) => { - if (!formTouched) { - setFormTouched(true); - } - - setValues({ _allowList: _ips }); - }, - [formTouched, setValues] - ); - - React.useEffect(() => { - if (open) { - setError(''); - setAllowListErrors([]); - resetForm(); - } - }, [open, resetForm]); - - const learnMoreLink = isDefaultDB ? LEARN_MORE_LINK : LEARN_MORE_LINK_LEGACY; - return ( - - - {error ? : null} - {allowListErrors - ? allowListErrors.map((allowListError) => ( - - )) - : null} - - {isDefaultDB - ? ACCESS_CONTROLS_DRAWER_TEXT - : ACCESS_CONTROLS_DRAWER_TEXT_LEGACY}{' '} - Learn more. - -
- 0 - ? 'Add Another IP' - : 'Add an IP' - } - className={classes.ipSelect} - forDatabaseAccessControls - inputProps={{ autoFocus: true }} - ips={values._allowList!} - onBlur={handleIPBlur} - onChange={handleIPChange} - placeholder={ - isDefaultDB ? ipV6FieldPlaceholder : ipFieldPlaceholder - } - title="Allowed IP Addresses or Ranges" - /> - - -
-
- ); -}; - -export default AddAccessControlDrawer; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC.tsx new file mode 100644 index 00000000000..7ddaa0a9145 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { DatabaseVPC } from '../../DatabaseCreate/DatabaseVPC'; + +import type { ManageNetworkingFormValues } from './DatabaseManageNetworkingDrawer'; + +interface DatabaseDetailVPCProps { + region: string; +} + +export const DatabaseDetailVPC = (props: DatabaseDetailVPCProps) => { + const { region } = props; + + const { control, setValue, trigger } = + useFormContext(); + + const [vpcId, subnetId] = useWatch({ + control, + name: ['private_network.vpc_id', 'private_network.subnet_id'], + }); + + return ( + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx index bd5c235a3fa..c3d9bca2806 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx @@ -1,19 +1,15 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { useDatabaseMutation } from '@linode/queries'; import { Box, Button, Drawer, Notice } from '@linode/ui'; import { updatePrivateNetworkSchema } from '@linode/validation'; import { useNavigate } from '@tanstack/react-router'; -import { useFormik } from 'formik'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; -import { DatabaseVPCSelector } from './DatabaseVPCSelector'; +import { DatabaseDetailVPC } from 'src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC'; -import type { - Database, - PrivateNetwork, - UpdateDatabasePayload, - VPC, -} from '@linode/api-v4'; +import type { Database, UpdateDatabasePayload, VPC } from '@linode/api-v4'; import type { Theme } from '@linode/ui'; interface Props { @@ -24,9 +20,10 @@ interface Props { vpc: undefined | VPC; } -export type ManageNetworkingFormValues = { - private_network: PrivateNetwork; -}; +export type ManageNetworkingFormValues = Pick< + UpdateDatabasePayload, + 'private_network' +>; const DatabaseManageNetworkingDrawer = (props: Props) => { const { database, vpc, onClose, onUnassign, open } = props; @@ -41,10 +38,22 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { }, }; - const submitForm = () => { - const payload: UpdateDatabasePayload = { ...values }; + const form = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + // @ts-expect-error handle null validation with trigger + resolver: yupResolver(updatePrivateNetworkSchema), + }); + + const { + formState: { isDirty, isValid }, + handleSubmit, + reset, + watch, + } = form; - updateDatabase(payload).then(() => { + const onSubmit = (values: ManageNetworkingFormValues) => { + updateDatabase(values).then(() => { enqueueSnackbar('Changes are being applied.', { variant: 'info', }); @@ -59,34 +68,20 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { }); }; - const { - errors, - handleSubmit, - resetForm, - isValid, - dirty, - setFieldValue, - values, - } = useFormik({ - initialValues, - onSubmit: submitForm, - validationSchema: updatePrivateNetworkSchema, - validateOnChange: true, - validateOnBlur: true, - }); // TODO (UIE-8903): Replace deprecated Formik with React Hook Form + const [publicAccess, subnetId, vpcId] = watch([ + 'private_network.public_access', + 'private_network.subnet_id', + 'private_network.vpc_id', + ]); const hasVPCConfigured = !!database?.private_network?.vpc_id; const hasConfigChanged = - values.private_network.vpc_id !== database?.private_network?.vpc_id || - values.private_network.subnet_id !== database?.private_network?.subnet_id || - values.private_network.public_access !== - database?.private_network?.public_access; - const hasValidSelection = - !!values.private_network.vpc_id && - !!values.private_network.subnet_id && - hasConfigChanged; + vpcId !== database?.private_network?.vpc_id || + subnetId !== database?.private_network?.subnet_id || + publicAccess !== database?.private_network?.public_access; + const hasValidSelection = !!vpcId && !!subnetId && hasConfigChanged; - const isSaveDisabled = !dirty || !isValid || !hasValidSelection; + const isSaveDisabled = !isDirty || !isValid || !hasValidSelection; const { error: manageNetworkingError, @@ -97,14 +92,14 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { const handleOnClose = () => { onClose(); - resetForm(); + reset(); resetMutation?.(); }; /** Resets the form after opening the unassign VPC dialog */ const handleOnUnassign = () => { onUnassign(); - resetForm(); + reset(); resetMutation?.(); }; @@ -113,59 +108,53 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { {manageNetworkingError && ( )} -
- - setFieldValue(field, value) - } - privateNetworkValues={values.private_network} - selectedRegionId={database?.region} - /> - ({ - marginTop: theme.spacingFunction(50), - paddingTop: theme.spacingFunction(8), - paddingBottom: theme.spacingFunction(8), - display: 'flex', - justifyContent: hasVPCConfigured ? 'space-between' : 'flex-end', - })} - > - {hasVPCConfigured && ( - - )} - - - + + + + ({ + marginTop: theme.spacingFunction(50), + paddingTop: theme.spacingFunction(8), + paddingBottom: theme.spacingFunction(8), + display: 'flex', + justifyContent: hasVPCConfigured ? 'space-between' : 'flex-end', + })} + > + {hasVPCConfigured && ( + + )} + + + + - - + + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx deleted file mode 100644 index 6f5c7b7b220..00000000000 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useAllVPCsQuery, useRegionQuery } from '@linode/queries'; -import { - Autocomplete, - BetaChip, - Box, - Checkbox, - FormHelperText, - Notice, - TooltipIcon, - Typography, -} from '@linode/ui'; -import * as React from 'react'; - -import { Link } from 'src/components/Link'; -import { MANAGE_NETWORKING_LEARN_MORE_LINK } from 'src/features/Databases/constants'; -import { useFlags } from 'src/hooks/useFlags'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; - -import type { ClusterSize, Engine, PrivateNetwork, VPC } from '@linode/api-v4'; -import type { Theme } from '@mui/material/styles'; -import type { FormikErrors } from 'formik'; - -interface DatabaseCreateValuesFormik { - allow_list: { - address: string; - error: string; - }[]; - cluster_size: ClusterSize; - engine: Engine; - label: string; - private_network: PrivateNetwork; - region: string; - type: string; -} - -interface DatabaseVPCSelectorProps { - errors: FormikErrors; // TODO (UIE-8903): Replace deprecated Formik with React Hook Form - mode: 'create' | 'networking'; - onChange: (field: string, value: boolean | null | number) => void; - onConfigurationChange?: (vpc: null | VPC) => void; - privateNetworkValues: PrivateNetwork; - resetFormFields?: ( - partialValues?: Partial - ) => void; - selectedRegionId: string; -} - -export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { - const { - errors, - mode, - onConfigurationChange, - onChange, - selectedRegionId, - resetFormFields, - privateNetworkValues, - } = props; - - const flags = useFlags(); - const isCreate = mode === 'create'; - const { data: selectedRegion } = useRegionQuery(selectedRegionId); - const regionSupportsVPCs = selectedRegion?.capabilities.includes('VPCs'); - - const { - data: vpcs, - error: vpcsError, - isLoading, - } = useAllVPCsQuery({ - enabled: regionSupportsVPCs, - filter: { region: selectedRegionId }, - }); - - const vpcErrorMessage = - vpcsError && - getAPIErrorOrDefault(vpcsError, 'Unable to load VPCs')[0].reason; - - const selectedVPC = vpcs?.find( - (vpc) => vpc.id === privateNetworkValues.vpc_id - ); - - const selectedSubnet = selectedVPC?.subnets.find( - (subnet) => subnet.id === privateNetworkValues.subnet_id - ); - - const prevRegionId = React.useRef(undefined); - const regionHasVPCs = Boolean(vpcs && vpcs.length > 0); - const disableVPCSelectors = - !!vpcsError || !regionSupportsVPCs || !regionHasVPCs; - - const resetVPCConfiguration = () => { - resetFormFields?.({ - private_network: { - vpc_id: null, - subnet_id: null, - public_access: false, - }, - }); - }; - - React.useEffect(() => { - // When the selected region has changed, reset VPC configuration. - // Then switch back to default validation behavior - if (prevRegionId.current && prevRegionId.current !== selectedRegionId) { - resetVPCConfiguration(); - onConfigurationChange?.(null); - } - prevRegionId.current = selectedRegionId; - }, [selectedRegionId]); - - const vpcHelperTextCopy = !selectedRegionId - ? 'In the Select Engine and Region section, select a region with an existing VPC to see available VPCs.' - : 'No VPC is available in the selected region.'; - - /** Returns dynamic marginTop value used to center TooltipIcon in different scenarios */ - const getVPCTooltipIconMargin = () => { - const margins = { - longHelperText: '.75rem', - shortHelperText: '1.75rem', - noHelperText: '2.75rem', - errorText: '1.5rem', - errorTextWithLongHelperText: '-.5rem', - }; - if (disableVPCSelectors && vpcsError) - return margins.errorTextWithLongHelperText; - if (errors?.private_network?.vpc_id) return margins.errorText; - if (disableVPCSelectors && !selectedRegionId) return margins.longHelperText; - if (disableVPCSelectors && selectedRegionId) return margins.shortHelperText; - return margins.noHelperText; - }; - - const accessNotice = isCreate && ( - ({ - marginTop: theme.spacingFunction(20), - })} - text="The cluster will have public access by default if a VPC is not assigned." - variant="info" - /> - ); - - return ( - <> - ({ - display: 'flex', - marginTop: theme.spacingFunction(20), - marginBottom: theme.spacingFunction(4), - })} - > - Assign a VPC - {flags.databaseVpcBeta && } - - - - Assign this cluster to an existing VPC.{' '} - - Learn more. - - - - { - onChange('private_network.subnet_id', null); // Always reset subnet selection when VPC changes - if (!value) { - onChange('private_network.public_access', false); - } - onConfigurationChange?.(value ?? null); - onChange('private_network.vpc_id', value?.id ?? null); - }} - options={vpcs ?? []} - placeholder="Select a VPC" - sx={{ width: '354px' }} - value={selectedVPC ?? null} - /> - - - - {selectedVPC ? ( - <> - `${subnet.label} (${subnet.ipv4})`} - label="Subnet" - onChange={(e, value) => { - onChange('private_network.subnet_id', value?.id ?? null); - }} - options={selectedVPC?.subnets ?? []} - placeholder="Select a subnet" - value={selectedSubnet ?? null} - /> - ({ - marginTop: theme.spacingFunction(20), - })} - > - { - onChange('private_network.public_access', value ?? null); - }} - text={'Enable public access'} - toolTipText={ - 'Adds a public endpoint to the database in addition to the private VPC endpoint.' - } - /> - {errors?.private_network?.public_access && ( - - {errors?.private_network?.public_access} - - )} - - - ) : ( - accessNotice - )} - - ); -}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ManageAccessControlDrawer.test.tsx similarity index 62% rename from packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.test.tsx rename to packages/manager/src/features/Databases/DatabaseDetail/ManageAccessControlDrawer.test.tsx index 12118fd1433..f65c6ee9c56 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ManageAccessControlDrawer.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -7,22 +7,21 @@ import { IPv4List } from 'src/factories/databases'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import AccessControls from './AccessControls'; -import AddAccessControlDrawer from './AddAccessControlDrawer'; +import { ManageAccessControlDrawer } from './ManageAccessControlDrawer'; import type { DatabaseInstance } from '@linode/api-v4'; beforeAll(() => mockMatchMedia()); -describe('Add Access Controls drawer', () => { - const database = databaseFactory.build(); - const { getByTestId } = renderWithTheme( - - ); - - const button = getByTestId('button-access-control'); - fireEvent.click(button); +describe('Manage Access Controls drawer', () => { + it('Should open when a user clicks the Manage Access Controls button', async () => { + const database = databaseFactory.build(); + const { getByTestId } = renderWithTheme( + + ); - it('Should open when a user clicks the Add Access Controls button', () => { + const button = getByTestId('button-access-control'); + await userEvent.click(button); // 'drawer' is the data-testid of the component expect(getByTestId('drawer')).toBeVisible(); }); @@ -35,7 +34,11 @@ describe('Add Access Controls drawer', () => { id: 123, } as DatabaseInstance; const { getAllByTestId } = renderWithTheme( - null} open={true} /> + null} + open={true} + /> ); expect(getAllByTestId('domain-transfer-input')).toHaveLength( @@ -54,19 +57,26 @@ describe('Add Access Controls drawer', () => { id: 123, } as DatabaseInstance; const { getByText } = renderWithTheme( - null} open={true} /> + null} + open={true} + /> ); - const addAccessControlsButton = getByText('Update Access Controls').closest( - 'button' - ); + const updateAccessControlsButton = getByText( + 'Update Access Controls' + ).closest('button'); // Before making a change to the IP addresses, the "Add Inbound Sources" button should be disabled. - expect(addAccessControlsButton).toHaveAttribute('aria-disabled', 'true'); + expect(updateAccessControlsButton).toHaveAttribute('aria-disabled', 'true'); const addAnIPButton = getByText('Add Another IP'); await userEvent.click(addAnIPButton); - expect(addAccessControlsButton).toHaveAttribute('aria-disabled', 'false'); + expect(updateAccessControlsButton).toHaveAttribute( + 'aria-disabled', + 'false' + ); }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ManageAccessControlDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ManageAccessControlDrawer.tsx new file mode 100644 index 00000000000..a578a11a2c2 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ManageAccessControlDrawer.tsx @@ -0,0 +1,183 @@ +import { useDatabaseMutation } from '@linode/queries'; +import { ActionsPanel, Drawer, Notice, Typography } from '@linode/ui'; +import * as React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; + +import { Link } from 'src/components/Link'; +import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +import { + ACCESS_CONTROLS_DRAWER_TEXT, + ACCESS_CONTROLS_DRAWER_TEXT_LEGACY, + ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT, + ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT_LEGACY, + LEARN_MORE_LINK, + LEARN_MORE_LINK_LEGACY, +} from 'src/features/Databases/constants'; +import { isDefaultDatabase } from 'src/features/Databases/utilities'; +import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; +import { + ipFieldPlaceholder, + ipV6FieldPlaceholder, + stringToExtendedIP, + validateIPs, +} from 'src/utilities/ipUtils'; + +import type { APIError, Database, DatabaseInstance } from '@linode/api-v4'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; + +interface Props { + database: Database | DatabaseInstance; + onClose: () => void; + open: boolean; +} + +interface ManageAccessControlValues { + allow_list: ExtendedIP[]; +} + +export const ManageAccessControlDrawer = (props: Props) => { + const { database, onClose, open } = props; + + const [allowListErrors, setAllowListErrors] = React.useState(); + + const handleValidateIPs = (_ips: ExtendedIP[]) => { + const _ipsWithMasks = enforceIPMasks(_ips); + + const validatedIPs = validateIPs(_ipsWithMasks, { + allowEmptyAddress: false, + errorMessage: isDefaultDB + ? ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT + : ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT_LEGACY, + }); + + setValue('allow_list', validatedIPs); + }; + + const { mutateAsync: updateDatabase } = useDatabaseMutation( + database.engine, + database.id + ); + + const isDefaultDB = isDefaultDatabase(database); + + const onSubmit = async (values: ManageAccessControlValues) => { + handleValidateIPs(values.allow_list); + + const allowList = getValues('allow_list'); + + if (allowList.some((ip) => ip.error)) { + return; + } + + try { + await updateDatabase({ allow_list: allowList.map((ip) => ip.address) }); + onClose(); + } catch (errors) { + // Surface allow_list errors -- for example, "Invalid IPv4 address(es): ..." + const allowListErrors = errors.filter( + (error: APIError) => error.field === 'allow_list' + ); + if (allowListErrors) { + setAllowListErrors(allowListErrors); + } + + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } + } + }; + + const initialValues: ManageAccessControlValues = { + allow_list: database?.allow_list + ? database?.allow_list?.map(stringToExtendedIP) + : [ + { + address: '', + error: '', + }, + ], + }; + + const form = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + }); + + const { + control, + formState: { isSubmitting, errors, isDirty }, + handleSubmit, + setError, + reset, + getValues, + setValue, + } = form; + + React.useEffect(() => { + if (open) { + reset(initialValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, reset]); + + const learnMoreLink = isDefaultDB ? LEARN_MORE_LINK : LEARN_MORE_LINK_LEGACY; + + return ( + + {errors.root && } + {allowListErrors && + allowListErrors.map((allowListError) => ( + + ))} + + {isDefaultDB + ? ACCESS_CONTROLS_DRAWER_TEXT + : ACCESS_CONTROLS_DRAWER_TEXT_LEGACY}{' '} + Learn more. + + +
+ ( + 0 + ? 'Add Another IP' + : 'Add an IP' + } + forDatabaseAccessControls + inputProps={{ autoFocus: true }} + ips={field.value} + onBlur={handleValidateIPs} + onChange={field.onChange} + placeholder={ + isDefaultDB ? ipV6FieldPlaceholder : ipFieldPlaceholder + } + title="Allowed IP Addresses or Ranges" + /> + )} + /> + + +
+
+ ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index f5834cc32bf..dba7dd1599a 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -11,9 +11,9 @@ import { import React from 'react'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import AddAccessControlDrawer from 'src/features/Databases/DatabaseDetail/AddAccessControlDrawer'; import DatabaseSettingsDeleteClusterDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog'; import DatabaseSettingsResetPasswordDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog'; +import { ManageAccessControlDrawer } from 'src/features/Databases/DatabaseDetail/ManageAccessControlDrawer'; import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; import DatabaseRow from 'src/features/Databases/DatabaseLanding/DatabaseRow'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; @@ -283,7 +283,7 @@ const DatabaseLandingTable = ({ onClose={() => setIsSuspendClusterDialogOpen(false)} open={isSuspendClusterDialogOpen} /> - { loading: isPending, disabled: false, onClick: handleDelete, + 'data-pendo-id': 'Logs Delivery Destinations Delete-Delete', + }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: onClose, + 'data-pendo-id': 'Logs Delivery Destinations Delete-Cancel', }} - secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} style={{ padding: 0 }} /> ); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx index b3d1bfea622..5da8931abf6 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import type { Destination } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; export interface DestinationHandlers { onDelete: (destination: Destination) => void; @@ -16,18 +17,20 @@ interface DestinationActionMenuProps extends DestinationHandlers { export const DestinationActionMenu = (props: DestinationActionMenuProps) => { const { destination, onDelete, onEdit } = props; - const menuActions = [ + const menuActions: Action[] = [ { onClick: () => { onEdit(destination); }, title: 'Edit', + pendoId: 'Logs Delivery Destinations-Edit', }, { onClick: () => { onDelete(destination); }, title: 'Delete', + pendoId: 'Logs Delivery Destinations-Delete', }, ]; @@ -35,6 +38,7 @@ export const DestinationActionMenu = (props: DestinationActionMenuProps) => { ); }; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx index 50f850619ae..561272ddc52 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -60,7 +60,7 @@ describe('DestinationEdit', () => { describe('given Test Connection and Edit Destination buttons', () => { const testConnectionButtonText = 'Test Connection'; - const editDestinationButtonText = 'Edit Destination'; + const saveDestinationButtonText = 'Save'; const editDestinationSpy = vi.fn(); const verifyDestinationSpy = vi.fn(); @@ -89,23 +89,23 @@ describe('DestinationEdit', () => { const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, }); - const editDestinationButton = screen.getByRole('button', { - name: editDestinationButtonText, + const saveDestinationButton = screen.getByRole('button', { + name: saveDestinationButtonText, }); // Enter Secret Access Key const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); await userEvent.type(secretAccessKeyInput, 'Test'); - expect(editDestinationButton).toBeDisabled(); + expect(saveDestinationButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); await waitFor(() => { - expect(editDestinationButton).toBeEnabled(); + expect(saveDestinationButton).toBeEnabled(); }); - await userEvent.click(editDestinationButton); + await userEvent.click(saveDestinationButton); expect(editDestinationSpy).toHaveBeenCalled(); }); }); @@ -131,20 +131,20 @@ describe('DestinationEdit', () => { const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, }); - const editDestinationButton = screen.getByRole('button', { - name: editDestinationButtonText, + const saveDestinationButton = screen.getByRole('button', { + name: saveDestinationButtonText, }); // Enter Secret Access Key const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); await userEvent.type(secretAccessKeyInput, 'Test'); - expect(editDestinationButton).toBeDisabled(); + expect(saveDestinationButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); await waitFor(() => { - expect(editDestinationButton).toBeDisabled(); + expect(saveDestinationButton).toBeDisabled(); }); }); }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx index d80ecc78a87..92bcc1bcbed 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -1,5 +1,6 @@ import { destinationType } from '@linode/api-v4'; import { Autocomplete, Paper, TextField } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useEffect } from 'react'; @@ -61,6 +62,11 @@ export const DestinationForm = (props: DestinationFormProps) => { field.onChange(value); }} options={destinationTypeOptions} + textFieldProps={{ + inputProps: { + 'data-pendo-id': `Logs Delivery Destinations ${capitalize(mode)}-Destination Type`, + }, + }} value={getDestinationTypeOption(field.value)} /> )} @@ -72,6 +78,9 @@ export const DestinationForm = (props: DestinationFormProps) => { { @@ -83,7 +92,10 @@ export const DestinationForm = (props: DestinationFormProps) => { )} /> {destination.type === destinationType.AkamaiObjectStorage && ( - + )} diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx index 0fed3b16756..8ef54400dda 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx @@ -23,7 +23,10 @@ export const DestinationTableRow = React.memo( return ( - + {destination.label} diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx index 23226180731..b3ffe5d4ef0 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx @@ -34,6 +34,8 @@ export const DestinationsLandingEmptyState = ( }); navigateToCreate(); }, + 'data-pendo-id': + 'Logs Delivery Destinations Empty-Create Destination', }, ]} gettingStartedGuidesData={gettingStartedGuides} diff --git a/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx index c2155a8cb1b..790cf51fdb3 100644 --- a/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx +++ b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, Button } from '@linode/ui'; +import { Autocomplete, Box, Button, SelectedIcon } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -7,19 +7,19 @@ import * as React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import type { Theme } from '@mui/material/styles'; -import type { LabelValueOption } from 'src/features/Delivery/Shared/types'; +import type { AutocompleteOption } from 'src/features/Delivery/Shared/types'; export interface DeliveryTabHeaderProps { buttonDataAttrs?: { [key: string]: boolean | string }; createButtonText?: string; disabledCreateButton?: boolean; - entity?: string; + entity: string; isSearching?: boolean; onButtonClick?: () => void; onSearch?: (label: string) => void; onSelect?: (status: string) => void; searchValue?: string; - selectList?: LabelValueOption[]; + selectList?: AutocompleteOption[]; selectValue?: string; spacingBottom?: 0 | 4 | 16 | 24; } @@ -89,6 +89,9 @@ export const DeliveryTabHeader = ({ { + return ( +
  • + + {option.label} + + +
  • + ); + }} + textFieldProps={{ + inputProps: { + 'data-pendo-id': `Logs Delivery ${entity}s-Status`, + }, + }} value={selectList.find(({ value }) => value === selectValue)} /> )} {onButtonClick && ( diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts index 30067fbf20d..8c447865f22 100644 --- a/packages/manager/src/features/Delivery/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -9,12 +9,13 @@ import type { export type FormMode = 'create' | 'edit'; export type FormType = 'destination' | 'stream'; -export interface LabelValueOption { +export interface AutocompleteOption { label: string; + pendoId?: string; value: string; } -export const destinationTypeOptions: LabelValueOption[] = [ +export const destinationTypeOptions: AutocompleteOption[] = [ { value: destinationType.CustomHttps, label: 'Custom HTTPS', @@ -25,7 +26,7 @@ export const destinationTypeOptions: LabelValueOption[] = [ }, ]; -export const streamTypeOptions: LabelValueOption[] = [ +export const streamTypeOptions: AutocompleteOption[] = [ { value: streamType.AuditLogs, label: 'Audit Logs', @@ -36,14 +37,16 @@ export const streamTypeOptions: LabelValueOption[] = [ }, ]; -export const streamStatusOptions: LabelValueOption[] = [ +export const streamStatusOptions: AutocompleteOption[] = [ { value: streamStatus.Active, label: 'Active', + pendoId: 'Logs Delivery Streams-Status Active', }, { value: streamStatus.Inactive, label: 'Inactive', + pendoId: 'Logs Delivery Streams-Status Inactive', }, ]; diff --git a/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx b/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx index 0bf28a7f0e6..e6fcc19809a 100644 --- a/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx +++ b/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx @@ -54,8 +54,13 @@ export const DeleteStreamDialog = React.memo((props: Props) => { loading: isPending, disabled: false, onClick: handleDelete, + 'data-pendo-id': 'Logs Delivery Streams Delete-Delete', + }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: onClose, + 'data-pendo-id': 'Logs Delivery Streams Delete-Cancel', }} - secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} style={{ padding: 0 }} /> ); diff --git a/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx index 7484d82d32b..f976acd4096 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + export interface StreamHandlers { onDelete: (stream: Stream) => void; onDisableOrEnable: (stream: Stream) => void; @@ -16,24 +18,27 @@ interface StreamActionMenuProps extends StreamHandlers { export const StreamActionMenu = (props: StreamActionMenuProps) => { const { stream, onDelete, onDisableOrEnable, onEdit } = props; - const menuActions = [ + const menuActions: Action[] = [ { onClick: () => { onEdit(stream); }, title: 'Edit', + pendoId: 'Logs Delivery Streams-Edit', }, { onClick: () => { onDisableOrEnable(stream); }, title: stream.status === streamStatus.Active ? 'Deactivate' : 'Activate', + pendoId: `Logs Delivery Streams-${stream.status === streamStatus.Active ? 'Deactivate' : 'Activate'}`, }, { onClick: () => { onDelete(stream); }, title: 'Delete', + pendoId: 'Logs Delivery Streams-Delete', }, ]; @@ -41,6 +46,7 @@ export const StreamActionMenu = (props: StreamActionMenuProps) => { ); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx index bc3d35d352e..315d63f3c7c 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx @@ -58,7 +58,7 @@ const renderComponentWithoutSelectedClusters = async () => { ); const utils = renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { @@ -181,7 +181,7 @@ describe('StreamFormClusters', () => { ); renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx index 0a861adeb76..e4c828ad92f 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -8,6 +8,7 @@ import { Paper, Typography, } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import React, { useEffect, useState } from 'react'; import { useWatch } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form'; @@ -19,6 +20,7 @@ import { Table } from 'src/components/Table'; import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; +import type { FormMode } from 'src/features/Delivery/Shared/types'; import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; @@ -28,7 +30,12 @@ const controlPaths = { clusterIds: 'stream.details.cluster_ids', } as const; -export const StreamFormClusters = () => { +interface StreamFormClustersProps { + mode: FormMode; +} + +export const StreamFormClusters = (props: StreamFormClustersProps) => { + const { mode } = props; const { control, setValue, formState, trigger } = useFormContext(); @@ -109,6 +116,7 @@ export const StreamFormClusters = () => { render={({ field }) => ( { field.onChange(checked); if (checked) { @@ -135,6 +143,9 @@ export const StreamFormClusters = () => { debounceTime={250} errorText={searchParseError?.message} hideLabel + inputProps={{ + 'data-pendo-id': `Logs Delivery Streams ${capitalize(mode)}-Clusters-Search`, + }} label="Search" onSearch={(value) => setSearchText(value)} placeholder="Search" diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index e9a6efec52e..13c18086d6e 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -30,6 +30,7 @@ describe('StreamFormDelivery', () => { renderWithThemeAndHookFormContext({ component: ( ), @@ -57,6 +58,7 @@ describe('StreamFormDelivery', () => { renderWithThemeAndHookFormContext({ component: ( ), @@ -91,6 +93,7 @@ describe('StreamFormDelivery', () => { renderWithThemeAndHookFormContext({ component: ( ), diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index db40ce4120d..8dfe9f7c400 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -8,6 +8,7 @@ import { Paper, Typography, } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { createFilterOptions } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import React, { useEffect, useState } from 'react'; @@ -22,12 +23,14 @@ import type { AkamaiObjectStorageDetails, DestinationType, } from '@linode/api-v4'; +import type { FormMode } from 'src/features/Delivery/Shared/types'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; interface DestinationName { create?: boolean; id?: number; label: string; + pendoId?: string; type?: DestinationType; } @@ -40,17 +43,20 @@ const controlPaths = { } as const; interface StreamFormDeliveryProps { + mode: FormMode; setDisableTestConnection: (disable: boolean) => void; } export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { - const { setDisableTestConnection } = props; + const { mode, setDisableTestConnection } = props; const theme = useTheme(); const { control, setValue, clearErrors } = useFormContext(); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); + const capitalizedMode = capitalize(mode); + const [creatingNewDestination, setCreatingNewDestination] = useState(false); @@ -105,6 +111,11 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { field.onChange(value); }} options={destinationTypeOptions} + textFieldProps={{ + inputProps: { + 'data-pendo-id': `Logs Delivery Streams ${capitalizedMode}-Destination Type`, + }, + }} value={getDestinationTypeOption(field.value)} /> )} @@ -127,6 +138,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { create: true, label: inputValue, type: selectedDestinationType, + pendoId: `Logs Delivery Streams ${capitalizedMode}-Destination Name-New`, }); } @@ -163,7 +175,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { renderOption={(props, option) => { const { id, ...optionProps } = props; return ( -
  • +
  • {option.create ? ( <> Create  "{option.label}" @@ -174,6 +186,11 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => {
  • ); }} + textFieldProps={{ + inputProps: { + 'data-pendo-id': `Logs Delivery Streams ${capitalizedMode}-Destination Name`, + }, + }} value={field.value ? { label: field.value } : null} /> )} @@ -183,6 +200,8 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { {creatingNewDestination && !selectedDestinations?.length && ( )} {selectedDestinations?.[0] && ( diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx index c8b2edb073c..580b5590109 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -83,7 +83,7 @@ describe('StreamEdit', () => { { timeout: 10000 }, () => { const testConnectionButtonText = 'Test Connection'; - const editStreamButtonText = 'Edit Stream'; + const saveStreamButtonText = 'Save'; const fillOutNewDestinationForm = async () => { const destinationNameInput = screen.getByLabelText('Destination Name'); @@ -147,21 +147,21 @@ describe('StreamEdit', () => { const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, }); - const editStreamButton = screen.getByRole('button', { - name: editStreamButtonText, + const saveStreamButton = screen.getByRole('button', { + name: saveStreamButtonText, }); - expect(editStreamButton).toBeDisabled(); + expect(saveStreamButton).toBeDisabled(); // Test connection await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); await waitFor(() => { - expect(editStreamButton).toBeEnabled(); + expect(saveStreamButton).toBeEnabled(); }); // Edit stream - await userEvent.click(editStreamButton); + await userEvent.click(saveStreamButton); expect(createDestinationSpy).toHaveBeenCalled(); await waitFor(() => { @@ -206,7 +206,7 @@ describe('StreamEdit', () => { name: testConnectionButtonText, }); const editStreamButton = screen.getByRole('button', { - name: editStreamButtonText, + name: saveStreamButtonText, }); // Edit stream button should not be disabled with existing destination selected @@ -253,18 +253,18 @@ describe('StreamEdit', () => { const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, }); - const editStreamButton = screen.getByRole('button', { - name: editStreamButtonText, + const saveStreamButton = screen.getByRole('button', { + name: saveStreamButtonText, }); await fillOutNewDestinationForm(); - expect(editStreamButton).toBeDisabled(); + expect(saveStreamButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); - expect(editStreamButton).toBeDisabled(); + expect(saveStreamButton).toBeDisabled(); }); }); } diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx index 8baaff88cd9..62ebd97048c 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -180,9 +180,10 @@ export const StreamForm = (props: StreamFormProps) => { {selectedStreamType === streamType.LKEAuditLogs && ( - + )} diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx index d48a955c83e..bc586e7d8d4 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -1,5 +1,13 @@ import { streamType } from '@linode/api-v4'; -import { Autocomplete, Paper, TextField, Typography } from '@linode/ui'; +import { + Autocomplete, + Box, + Paper, + SelectedIcon, + TextField, + Typography, +} from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -11,7 +19,11 @@ import { import { streamTypeOptions } from 'src/features/Delivery/Shared/types'; import type { StreamAndDestinationFormType } from './types'; -import type { FormMode } from 'src/features/Delivery/Shared/types'; +import type { StreamType } from '@linode/api-v4'; +import type { + AutocompleteOption, + FormMode, +} from 'src/features/Delivery/Shared/types'; interface StreamFormGeneralInfoProps { mode: FormMode; @@ -24,12 +36,22 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { const theme = useTheme(); const { control, setValue } = useFormContext(); + const capitalizedMode = capitalize(mode); const description = { audit_logs: 'Configuration and authentication audit logs that capture state-changing operations (mutations) on Linode cloud infrastructure resources and IAM authentication events. Delivered in cloudevents.io JSON format.', lke_audit_logs: 'Kubernetes API server audit logs that capture state-changing operations (mutations) on LKE-E cluster resources.', }; + const pendoIds = { + audit_logs: `Logs Delivery Streams ${capitalizedMode}-Audit Logs`, + lke_audit_logs: `Logs Delivery Streams ${capitalizedMode}-Kubernetes Audit Logs`, + }; + const streamTypeOptionsWithPendo: AutocompleteOption[] = + streamTypeOptions.map((option) => ({ + ...option, + pendoId: pendoIds[option.value as StreamType], + })); const selectedStreamType = useWatch({ control, @@ -54,6 +76,9 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { { @@ -78,7 +103,31 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { field.onChange(value); updateStreamDetails(value); }} - options={streamTypeOptions} + options={streamTypeOptionsWithPendo} + renderOption={(props, option, { selected }) => { + return ( +
  • + + {option.label} + + +
  • + ); + }} + textFieldProps={{ + inputProps: { + 'data-pendo-id': `Logs Delivery Streams ${capitalizedMode}-Stream Type`, + }, + }} value={getStreamTypeOption(field.value)} /> )} diff --git a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx index ee4fd0b74de..891c62cd830 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx @@ -26,7 +26,12 @@ export const StreamTableRow = React.memo((props: StreamTableRowProps) => { return ( - {stream.label} + + {stream.label} + {getStreamTypeOption(stream.type)?.label} diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyState.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyState.tsx index e967690eb89..343b78dcc0e 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyState.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyState.tsx @@ -35,6 +35,7 @@ export const StreamsLandingEmptyState = ( }); navigateToCreate(); }, + 'data-pendo-id': 'Logs Delivery Streams Empty-Create Stream', }, ]} gettingStartedGuidesData={gettingStartedGuides} diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts index 4b37661e046..4710be9407e 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -7,27 +7,56 @@ import { type StreamType, streamType, } from '@linode/api-v4'; +import { useAccount } from '@linode/queries'; import { omitProps } from '@linode/ui'; +import { isFeatureEnabledV2 } from '@linode/utilities'; import { destinationTypeOptions, streamTypeOptions, } from 'src/features/Delivery/Shared/types'; +import { useFlags } from 'src/hooks/useFlags'; import type { + AutocompleteOption, DestinationDetailsForm, FormMode, - LabelValueOption, } from 'src/features/Delivery/Shared/types'; +/** + * Hook to determine if the ACLP Logs feature is enabled for the current user. + + * @returns {{ isACLPLogsEnabled: boolean, isACLPLogsBeta: boolean }} An object indicating if the feature is enabled and if it is in beta. + */ +export const useIsACLPLogsEnabled = (): { + isACLPLogsBeta: boolean; + isACLPLogsEnabled: boolean; +} => { + const { data: account } = useAccount(); + const flags = useFlags(); + + const isACLPLogsEnabled = + (flags.aclpLogs?.enabled && flags.aclpLogs?.bypassAccountCapabilities) || + isFeatureEnabledV2( + 'Akamai Cloud Pulse Logs', + !!flags.aclpLogs?.enabled, + account?.capabilities ?? [] + ); + + return { + isACLPLogsBeta: !!flags.aclpLogs?.beta, + isACLPLogsEnabled, + }; +}; + export const getDestinationTypeOption = ( destinationTypeValue: string -): LabelValueOption | undefined => +): AutocompleteOption | undefined => destinationTypeOptions.find(({ value }) => value === destinationTypeValue); export const getStreamTypeOption = ( streamTypeValue: string -): LabelValueOption | undefined => +): AutocompleteOption | undefined => streamTypeOptions.find(({ value }) => value === streamTypeValue); export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; diff --git a/packages/manager/src/features/Firewalls/shared.test.ts b/packages/manager/src/features/Firewalls/shared.test.ts index 463cbdfaa62..6597c23d93b 100644 --- a/packages/manager/src/features/Firewalls/shared.test.ts +++ b/packages/manager/src/features/Firewalls/shared.test.ts @@ -1,8 +1,13 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { wrapWithTheme } from 'src/utilities/testHelpers'; + import { allIPv4, allIPv6, generateAddressesLabel, predefinedFirewallFromRule, + useIsFirewallRulesetsPrefixlistsEnabled, } from './shared'; import type { FirewallRuleType } from '@linode/api-v4/lib/firewalls/types'; @@ -144,3 +149,35 @@ describe('generateAddressLabel', () => { ); }); }); + +describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { + it('returns true if the feature is enabled', async () => { + const options = { flags: { firewallRulesetsPrefixlists: true } }; + + const { result } = renderHook( + () => useIsFirewallRulesetsPrefixlistsEnabled(), + { + wrapper: (ui) => wrapWithTheme(ui, options), + } + ); + + await waitFor(() => { + expect(result.current.isFirewallRulesetsPrefixlistsEnabled).toBe(true); + }); + }); + + it('returns false if the feature is NOT enabled', async () => { + const options = { flags: { firewallRulesetsPrefixlists: false } }; + + const { result } = renderHook( + () => useIsFirewallRulesetsPrefixlistsEnabled(), + { + wrapper: (ui) => wrapWithTheme(ui, options), + } + ); + + await waitFor(() => { + expect(result.current.isFirewallRulesetsPrefixlistsEnabled).toBe(false); + }); + }); +}); diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts index 754451074bd..8e9199827b7 100644 --- a/packages/manager/src/features/Firewalls/shared.ts +++ b/packages/manager/src/features/Firewalls/shared.ts @@ -1,6 +1,8 @@ import { truncateAndJoinList } from '@linode/utilities'; import { capitalize } from '@linode/utilities'; +import { useFlags } from 'src/hooks/useFlags'; + import type { PORT_PRESETS } from './FirewallDetail/Rules/shared'; import type { Firewall, @@ -255,3 +257,20 @@ export const getFirewallDescription = (firewall: Firewall) => { ]; return description.join(', '); }; + +/** + * Returns whether or not features related to the Firewall Rulesets & Prefixlists project + * should be enabled. + * + * Note: Currently, this just uses the `firewallRulesetsPrefixlists` feature flag as a source of truth, + * but will eventually also look at account capabilities if available. + */ +export const useIsFirewallRulesetsPrefixlistsEnabled = () => { + const flags = useFlags(); + + // @TODO: Firewall Rulesets & Prefixlists - check for customer tag/account capability when it exists + return { + isFirewallRulesetsPrefixlistsEnabled: + flags.firewallRulesetsPrefixlists ?? false, + }; +}; diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx index 0c4350b4ecb..8873a1156cb 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx @@ -5,6 +5,7 @@ import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuActi import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow/TableRow'; +import { usePermissions } from '../hooks/usePermissions'; import { TruncatedList } from '../Shared/TruncatedList'; import { UpdateDelegationsDrawer } from './UpdateDelegationsDrawer'; @@ -19,6 +20,9 @@ export const AccountDelegationsTableRow = ({ delegation, index }: Props) => { const theme = useTheme(); const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + const { data: permissions } = usePermissions('account', [ + 'update_delegate_users', + ]); const handleUpdateDelegations = () => { setIsDrawerOpen(true); }; @@ -118,11 +122,17 @@ export const AccountDelegationsTableRow = ({ delegation, index }: Props) => { )} - + ({ @@ -48,6 +49,15 @@ vi.mock('@tanstack/react-router', async () => { describe('DefaultEntityAccess', () => { it('should render', async () => { + queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ + data: { + entity_access: [ + { id: 1, type: 'linode', label: 'Linode 1' }, + { id: 2, type: 'volume', label: 'Volume 1' }, + ], + }, + isLoading: false, + }); renderWithTheme(); expect( @@ -57,4 +67,14 @@ describe('DefaultEntityAccess', () => { expect(screen.getByPlaceholderText('All Entities')).toBeVisible(); expect(screen.getByRole('table')).toBeVisible(); }); + it('should render empty state', async () => { + queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ + data: { entity_access: [] }, + isLoading: false, + }); + + renderWithTheme(); + + expect(screen.getByText(NO_ASSIGNED_DEFAULT_ENTITIES_TEXT)).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx index 6f5ce3ba3ca..2d02a4a1bf3 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx @@ -1,23 +1,46 @@ -import { Paper, Stack, Typography } from '@linode/ui'; +import { useGetDefaultDelegationAccessQuery } from '@linode/queries'; +import { CircleProgress, Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; +import { NO_ASSIGNED_DEFAULT_ENTITIES_TEXT } from '../../Shared/constants'; +import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const DefaultEntityAccess = () => { + const { data: defaultAccess, isLoading: defaultAccessLoading } = + useGetDefaultDelegationAccessQuery({ enabled: true }); + + const hasAssignedEntities = defaultAccess + ? defaultAccess.entity_access.length > 0 + : false; + + if (defaultAccessLoading) { + return ; + } + return ( - - - Default Entity Access for Delegate Users - - - View and update entities assigned to delegate users by default. Note - that changes implemented here will apply only to new delegate users. - For existing delegate users, use their Assigned Roles page to update - the assignment. - - - + {hasAssignedEntities ? ( + <> + + + Default Entity Access for Delegate Users + + + View and update entities assigned to delegate users by default. + Note that changes implemented here will apply only to new delegate + users. For existing delegate users, use their Assigned Roles page + to update the assignment. + + + + + ) : ( + + )} ); }; diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx new file mode 100644 index 00000000000..27fdf9f4600 --- /dev/null +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx @@ -0,0 +1,72 @@ +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NO_ASSIGNED_DEFAULT_ROLES_TEXT } from '../../Shared/constants'; +import { DefaultRoles } from './DefaultRoles'; + +const loadingTestId = 'circle-progress'; + +const queryMocks = vi.hoisted(() => ({ + useGetDefaultDelegationAccessQuery: vi.fn().mockReturnValue({}), + useLocation: vi.fn().mockReturnValue({}), + useIsDefaultDelegationRolesForChildAccount: vi + .fn() + .mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useLocation: queryMocks.useLocation, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useGetDefaultDelegationAccessQuery: + queryMocks.useGetDefaultDelegationAccessQuery, + }; +}); +vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ + useIsDefaultDelegationRolesForChildAccount: + queryMocks.useIsDefaultDelegationRolesForChildAccount, +})); +describe('DefaultRoles', () => { + it('should render', async () => { + queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ + data: { + account_access: [ + 'account_linode_admin', + 'account_linode_creator', + 'account_firewall_creator', + ], + entity_access: [], + }, + isLoading: false, + }); + const { queryByTestId } = renderWithTheme(); + + await waitForElementToBeRemoved(queryByTestId(loadingTestId)); + expect(screen.getByText('Default Roles for Delegate Users')).toBeVisible(); + expect(screen.getByRole('table')).toBeVisible(); + }); + it('should render empty state', async () => { + queryMocks.useLocation.mockReturnValue({ + pathname: '/iam/roles/defaults/roles', + }); + queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ + data: { account_access: [], entity_access: [] }, + isLoading: false, + }); + + renderWithTheme(); + + expect(screen.getByText(NO_ASSIGNED_DEFAULT_ROLES_TEXT)).toBeVisible(); + expect(screen.getByText('Add New Default Roles')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx index 20aafb5a5f7..3f9430a447f 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx @@ -1,21 +1,44 @@ -import { Paper, Typography } from '@linode/ui'; +import { useGetDefaultDelegationAccessQuery } from '@linode/queries'; +import { CircleProgress, Paper, Typography } from '@linode/ui'; import * as React from 'react'; import { AssignedRolesTable } from '../../Shared/AssignedRolesTable/AssignedRolesTable'; +import { NO_ASSIGNED_DEFAULT_ROLES_TEXT } from '../../Shared/constants'; +import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const DefaultRoles = () => { + const { data: defaultRolesData, isLoading: defaultRolesLoading } = + useGetDefaultDelegationAccessQuery({ enabled: true }); + const hasAssignedRoles = defaultRolesData + ? defaultRolesData.account_access.length > 0 || + defaultRolesData.entity_access.length > 0 + : false; + + if (defaultRolesLoading) { + return ; + } return ( - Default Roles for Delegate Users - - View and manage roles to be assigned to delegate users by default. Note - that changes implemented here will apply to only new delegate users. - - - For existing delegate users, use their Assigned Roles page to update the - assignment. - - + {hasAssignedRoles ? ( + <> + Default Roles for Delegate Users + + View and manage roles to be assigned to delegate users by default. + Note that changes implemented here will apply to only new delegate + users. + + + For existing delegate users, use their Assigned Roles page to update + the assignment. + + + + ) : ( + + )} ); }; diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 9af9359809a..787b68ed03f 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -11,12 +11,15 @@ import { usePermissions } from '../hooks/usePermissions'; import { DefaultRolesPanel } from './Defaults/DefaultRolesPanel'; export const RolesLanding = () => { - const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( + 'account', + ['is_account_admin'] + ); const { data: accountRoles, isLoading } = useAccountRoles( permissions?.is_account_admin ); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { isChildAccount } = useDelegationRole(); + const { isChildAccount, isProfileLoading } = useDelegationRole(); const { roles } = React.useMemo(() => { if (!accountRoles) { @@ -26,7 +29,7 @@ export const RolesLanding = () => { return { roles }; }, [accountRoles]); - if (isLoading) { + if (isLoading || isPermissionsLoading || isProfileLoading) { return ; } diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx index 40fec42a1ea..9194cfd11b1 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx @@ -179,7 +179,7 @@ export const AssignSelectedRolesDrawer = ({ })} > - Users + User ({ usePermissions: vi.fn(), -}; + useSearch: vi.fn(), +})); vi.mock('src/features/IAM/Shared/utilities', async () => { - const actual = await vi.importActual( - 'src/features/IAM/Shared/utilities' - ); + const actual = await vi.importActual('src/features/IAM/Shared/utilities'); return { ...actual, mapAccountPermissionsToRoles: vi.fn(), @@ -22,15 +21,21 @@ vi.mock('src/features/IAM/Shared/utilities', async () => { }); vi.mock('src/features/IAM/hooks/usePermissions', async () => { - const actual = await vi.importActual( - 'src/features/IAM/hooks/usePermissions' - ); + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); return { ...actual, usePermissions: vi.fn().mockReturnValue({}), }; }); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useSearch: queryMocks.useSearch, + }; +}); + const mockRoles: RoleView[] = [ { access: 'account_access', @@ -63,52 +68,54 @@ beforeEach(() => { }); describe('RolesTable', () => { + beforeEach(() => { + queryMocks.useSearch.mockReturnValue({ + query: '', + }); + }); + it('renders no roles when roles array is empty', async () => { - const { getByText, getByTestId } = renderWithTheme( - - ); + renderWithTheme(); - expect(getByTestId('roles-table')).toBeInTheDocument(); - expect(getByText('No items to display.')).toBeInTheDocument(); + screen.getByTestId('roles-table'); + screen.getByText('No items to display.'); }); it('renders roles correctly when roles array is provided', async () => { - const { getByText, getByTestId, getAllByRole } = renderWithTheme( - - ); + const { getAllByRole } = renderWithTheme(); - expect(getByTestId('roles-table')).toBeInTheDocument(); + screen.getByTestId('roles-table'); expect(getAllByRole('combobox').length).toEqual(1); - expect(getByText('Account linode admin')).toBeInTheDocument(); + screen.getByText('Account linode admin'); }); it('filters roles to warranted results based on search input', async () => { + queryMocks.useSearch.mockReturnValue({ + query: 'Account', + }); + renderWithTheme(); + const searchInput: HTMLInputElement = screen.getByPlaceholderText('Search'); - fireEvent.change(searchInput, { target: { value: 'Account' } }); - - await waitFor(() => { - expect(screen.getByTestId('roles-table')).toBeInTheDocument(); - expect(searchInput.value).toBe('Account'); - // TODO - if there is a way to pierce the shadow DOM, we can check these results, but these tests fail currently - // expect(screen.getByText('Account')).toBeInTheDocument(); - // expect(screen.queryByText('Database')).not.toBeInTheDocument(); - // expect(screen.getByText('No items to display.')).not.toBeInTheDocument(); - }); + + screen.getByTestId('roles-table'); + + expect(searchInput.value).toBe('Account'); + expect(screen.queryByText('Database')).not.toBeInTheDocument(); + expect(screen.queryByText('No items to display.')).not.toBeInTheDocument(); }); it('filters roles to no results based on search input if warranted', async () => { + queryMocks.useSearch.mockReturnValue({ + query: 'NonsenseThatWontMatchAnything', + }); + renderWithTheme(); const searchInput: HTMLInputElement = screen.getByPlaceholderText('Search'); - fireEvent.change(searchInput, { - target: { value: 'NonsenseThatWontMatchAnything' }, - }); - await waitFor(() => { - expect(screen.getByTestId('roles-table')).toBeInTheDocument(); - expect(searchInput.value).toBe('NonsenseThatWontMatchAnything'); - expect(screen.getByText('No items to display.')).toBeInTheDocument(); - }); + screen.getByTestId('roles-table'); + expect(searchInput.value).toBe('NonsenseThatWontMatchAnything'); + screen.getByText('No items to display.'); }); }); diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index 29abe650844..c5698da5077 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -3,6 +3,7 @@ import { capitalizeAllWords } from '@linode/utilities'; import { useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'; import { Pagination } from 'akamai-cds-react-components/Pagination'; import { sortRows, @@ -59,8 +60,12 @@ const DEFAULT_PAGE_SIZE = 10; export const RolesTable = ({ roles = [] }: Props) => { const theme = useTheme(); - // Filter string for the search bar - const [filterString, setFilterString] = React.useState(''); + const navigate = useNavigate(); + const location = useLocation(); + const { query } = useSearch({ + strict: false, + }); + const [filterableEntityType, setFilterableEntityType] = useState(ALL_ROLES_OPTION); const [sort, setSort] = useState< @@ -94,10 +99,11 @@ export const RolesTable = ({ roles = [] }: Props) => { ); }; - const filteredRows = React.useMemo( - () => getFilteredRows(filterString, filterableEntityType?.value), - [roles, filterString, filterableEntityType] - ); + const filteredRows = React.useMemo(() => { + if (!query) return roles; + + return getFilteredRows(query, filterableEntityType?.value); + }, [roles, query, filterableEntityType]); // Get just the list of entity types from this list of roles, to be used in the selection filter const filterableOptions = React.useMemo(() => { @@ -137,7 +143,10 @@ export const RolesTable = ({ roles = [] }: Props) => { }; const handleTextFilter = (fs: string) => { - setFilterString(fs); + navigate({ + to: location.pathname, + search: { query: fs !== '' ? fs : undefined }, + }); pagination.handlePageChange(1); }; @@ -199,7 +208,7 @@ export const RolesTable = ({ roles = [] }: Props) => { label="Search" onSearch={handleTextFilter} placeholder="Search" - value={filterString} + value={query ?? ''} />