From e77c6773cbf2b08b26c41c9584898f67f99b931b Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:07:56 +0530 Subject: [PATCH 01/58] upcoming: [DI-29113] - Support for User Channel Details in Alert flows (#13246) * upcoming: [DI-29113] - Support for User Channel Details in Alert flows * add changeset * add support for new alerts object in Notification Channel * changesets --------- Co-authored-by: Ankita --- .../pr-13246-changed-1767779941145.md | 5 ++ packages/api-v4/src/cloudpulse/types.ts | 7 +-- .../pr-13246-added-1767776466714.md | 5 ++ .../alert-notification-channel-list.spec.ts | 39 ++++++------ .../src/factories/cloudpulse/channels.ts | 13 ++-- .../RenderChannelDetails.test.tsx | 18 ++++++ .../RenderChannelDetails.tsx | 20 ++++++- .../NotificationChannelListTable.test.tsx | 8 +-- .../NotificationChannelTableRow.test.tsx | 11 ++-- .../NotificationChannelTableRow.tsx | 2 +- .../NotificationsChannelsListing/constants.ts | 4 +- .../CloudPulse/Alerts/Utils/utils.test.ts | 60 ++++++++++++++++++- .../features/CloudPulse/Alerts/Utils/utils.ts | 26 +++++++- packages/manager/src/mocks/serverHandlers.ts | 6 ++ 14 files changed, 173 insertions(+), 51 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13246-changed-1767779941145.md create mode 100644 packages/manager/.changeset/pr-13246-added-1767776466714.md diff --git a/packages/api-v4/.changeset/pr-13246-changed-1767779941145.md b/packages/api-v4/.changeset/pr-13246-changed-1767779941145.md new file mode 100644 index 00000000000..cb42e3fdae7 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13246-changed-1767779941145.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +NotificationChannelAlerts to NotificationChannelAlertInfo object to match latest API specification ([#13246](https://github.com/linode/manager/pull/13246)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index f9895f6cd46..43f725b6c7d 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -282,14 +282,13 @@ export interface Alert { updated_by: string; } -interface NotificationChannelAlerts { - id: number; - label: string; +interface NotificationChannelAlertInfo { + alert_count: number; type: 'alerts-definitions'; url: string; } interface NotificationChannelBase { - alerts: NotificationChannelAlerts[]; + alerts: NotificationChannelAlertInfo; channel_type: ChannelType; created: string; created_by: string; diff --git a/packages/manager/.changeset/pr-13246-added-1767776466714.md b/packages/manager/.changeset/pr-13246-added-1767776466714.md new file mode 100644 index 00000000000..bc6fa85ca97 --- /dev/null +++ b/packages/manager/.changeset/pr-13246-added-1767776466714.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Support for user-channel details in ACLP-Alerts and Notification Channel flow ([#13246](https://github.com/linode/manager/pull/13246)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts index c508f1cf3ee..1cc30cc7f6e 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -41,12 +41,11 @@ const notificationChannels = notificationChannelFactory .buildList(26) .map((ch, i) => { const isEmail = i % 2 === 0; - const alerts = Array.from({ length: isEmail ? 5 : 3 }).map((_, idx) => ({ - id: idx + 1, - label: `Alert-${idx + 1}`, + const alerts = { + alert_count: isEmail ? 5 : 3, + url: `monitor/alert-channels/${i + 1}/alerts`, type: 'alerts-definitions', - url: 'Sample', - })); + }; if (isEmail) { return { @@ -147,7 +146,7 @@ const VerifyChannelSortingParams = ( ); const order = sortOrderMap[sortOrder]; - const orderBy = LabelLookup[columnLabel]; + const orderBy = encodeURIComponent(LabelLookup[columnLabel]); cy.url().should( 'endWith', @@ -215,16 +214,7 @@ describe('Notification Channel Listing Page', () => { } // Alerts list - expect(item.alerts.length).to.eq(expected.alerts.length); - - item.alerts.forEach((alert, aIndex) => { - const expAlert = expected.alerts[aIndex]; - - expect(alert.id).to.eq(expAlert.id); - expect(alert.label).to.eq(expAlert.label); - expect(alert.type).to.eq(expAlert.type); - expect(alert.url).to.eq(expAlert.url); - }); + expect(item.alerts.alert_count).to.eq(expected.alerts.alert_count); }); }); }); @@ -254,7 +244,9 @@ describe('Notification Channel Listing Page', () => { cy.wrap($row).within(() => { cy.findByText(expected.label).should('be.visible'); - cy.findByText(String(expected.alerts.length)).should('be.visible'); + cy.findByText(String(expected.alerts.alert_count)).should( + 'be.visible' + ); cy.findByText('Email').should('be.visible'); cy.get('td').eq(3).should('have.text', expected.created_by); cy.findByText( @@ -284,11 +276,20 @@ describe('Notification Channel Listing Page', () => { { column: 'Alerts', ascending: [...notificationChannels] - .sort((a, b) => a.alerts.length - b.alerts.length) + .sort( + // Primary sort by `alert_count`. When two items have the same + // alert count, fall back to `a.id - b.id` as a deterministic + // tie-breaker so test expectations remain stable. + (a, b) => a.alerts.alert_count - b.alerts.alert_count + ) .map((ch) => ch.id), descending: [...notificationChannels] - .sort((a, b) => b.alerts.length - a.alerts.length) + .sort( + // Primary sort by `alert_count` (desc). Tie-break with `a.id - b.id` + // to keep ordering deterministic for assertions. + (a, b) => b.alerts.alert_count - a.alerts.alert_count + ) .map((ch) => ch.id), }, diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts index 42285f5d831..ee361063bb0 100644 --- a/packages/manager/src/factories/cloudpulse/channels.ts +++ b/packages/manager/src/factories/cloudpulse/channels.ts @@ -4,14 +4,11 @@ import type { NotificationChannel } from '@linode/api-v4'; export const notificationChannelFactory = Factory.Sync.makeFactory({ - alerts: [ - { - id: Number(Factory.each((i) => i)), - label: String(Factory.each((id) => `Alert-${id}`)), - type: 'alerts-definitions', - url: 'Sample', - }, - ], + alerts: { + type: 'alerts-definitions', + alert_count: 1, + url: 'monitor/alert-channels/{id}/alerts', + }, channel_type: 'email', content: { email: { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx index 37f4160d13d..255c5b42aba 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx @@ -1,3 +1,4 @@ +import { screen } from '@testing-library/react'; import * as React from 'react'; import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; @@ -21,4 +22,21 @@ describe('RenderChannelDetails component', () => { expect(container.getByText(emailAddresses[0])).toBeVisible(); expect(container.getByText(emailAddresses[1])).toBeVisible(); }); + it('should render the email channel with usernames if details is present', () => { + const usernames = ['user1', 'user2']; + const mockDataWithDetails: NotificationChannel = + notificationChannelFactory.build({ + channel_type: 'email', + content: {}, + details: { + email: { + usernames, + recipient_type: 'user', + }, + }, + }); + renderWithTheme(); + expect(screen.getByText(usernames[0])).toBeVisible(); + expect(screen.getByText(usernames[1])).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx index 373724fc164..90eb2973df4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx @@ -1,6 +1,8 @@ import { Chip } from '@mui/material'; import * as React from 'react'; +import { shouldUseContentsForEmail } from '../../Utils/utils'; + import type { NotificationChannel } from '@linode/api-v4'; interface RenderChannelDetailProps { @@ -12,9 +14,21 @@ interface RenderChannelDetailProps { export const RenderChannelDetails = (props: RenderChannelDetailProps) => { const { template } = props; if (template.channel_type === 'email') { - return template.content?.email.email_addresses.map((value, index) => ( - - )); + const contentEmail = template.content?.email; + const detailEmail = template.details?.email; + const useContents = shouldUseContentsForEmail(template); + + const recipients = useContents + ? (contentEmail?.email_addresses ?? []) + : (detailEmail?.usernames ?? []); + + return ( + <> + {recipients.map((value) => ( + + ))} + + ); } return null; }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx index 57be8d91987..6f74321299f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx @@ -10,8 +10,6 @@ import { NotificationChannelListTable } from './NotificationChannelListTable'; const mockScrollToElement = vi.fn(); -const ALERT_TYPE = 'alerts-definitions'; - describe('NotificationChannelListTable', () => { it('should render the notification channel table headers', () => { renderWithTheme( @@ -127,11 +125,7 @@ describe('NotificationChannelListTable', () => { it('should display correct alerts count', () => { const channel = notificationChannelFactory.build({ - alerts: [ - { id: 1, label: 'Alert 1', type: ALERT_TYPE, url: 'url1' }, - { id: 2, label: 'Alert 2', type: ALERT_TYPE, url: 'url2' }, - { id: 3, label: 'Alert 3', type: ALERT_TYPE, url: 'url3' }, - ], + alerts: { alert_count: 3 }, }); renderWithTheme( diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx index a014bad71bb..2218fe4dbdb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx @@ -18,10 +18,11 @@ describe('NotificationChannelTableRow', () => { it('should render a notification channel row with all fields', () => { const updated = new Date().toISOString(); const channel = notificationChannelFactory.build({ - alerts: [ - { id: 1, label: 'Alert 1', type: 'alerts-definitions', url: 'url1' }, - { id: 2, label: 'Alert 2', type: 'alerts-definitions', url: 'url2' }, - ], + alerts: { + type: 'alerts-definitions', + alert_count: 2, + url: 'monitor/alert-channels/{id}/alerts', + }, channel_type: 'email', created_by: 'user1', label: 'Test Channel', @@ -142,7 +143,7 @@ describe('NotificationChannelTableRow', () => { it('should render zero alerts count when no alerts are associated', () => { const channel = notificationChannelFactory.build({ - alerts: [], + alerts: { alert_count: 0 }, }); renderWithTheme( diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx index 6a2eae0d288..bb396d6bafd 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx @@ -52,7 +52,7 @@ export const NotificationChannelTableRow = ( {label} - {alerts.length} + {alerts.alert_count} {channelTypeMap[channel_type]} {created_by} diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts index 070d4165f23..b9172283c5b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts @@ -2,7 +2,7 @@ import type { NotificationChannel } from '@linode/api-v4'; type ChannelListingTableLabel = { colName: string; - label: keyof NotificationChannel; + label: `${keyof NotificationChannel}:${string}` | keyof NotificationChannel; }; export const ChannelListingTableLabelMap: ChannelListingTableLabel[] = [ @@ -12,7 +12,7 @@ export const ChannelListingTableLabelMap: ChannelListingTableLabel[] = [ }, { colName: 'Alerts', - label: 'alerts', + label: 'alerts:alert_count', }, { colName: 'Channel Type', diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index 4cb7b76999c..e7a229816fc 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -1,7 +1,7 @@ import { regionFactory } from '@linode/utilities'; import { act, renderHook } from '@testing-library/react'; -import { alertFactory, serviceTypesFactory } from 'src/factories'; +import { alertFactory, notificationChannelFactory, serviceTypesFactory } from 'src/factories'; import { useContextualAlertsState } from '../../Utils/utils'; import { transformDimensionValue } from '../CreateAlert/Criteria/DimensionFilterValue/utils'; @@ -17,6 +17,7 @@ import { getSchemaWithEntityIdValidation, getServiceTypeLabel, handleMultipleError, + shouldUseContentsForEmail, } from './utils'; import type { AlertValidationSchemaProps } from './utils'; @@ -497,3 +498,60 @@ describe('transformDimensionValue', () => { ).toBe('Test_value'); }); }); + +describe('shouldUseContentsForEmail', () => { + it('should return false for email channel with valid usernames in details', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: { + usernames: ['user1', 'user2'], + }, + }, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(false); + }); + + it('should return true for email channel with undefined details', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: undefined, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); + }); + + it('should return true for email channel with undefined details.email', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: undefined, + }, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); + }); + + it('should return true for email channel with undefined usernames', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: { + usernames: undefined, + }, + }, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); + }); + + it('should return true for email channel with empty usernames array', () => { + const notificationChannel = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: { + usernames: [], + recipient_type: 'admin_users', + }, + }, + }); + expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 78e9c495eb2..2fa962fa5ed 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -231,6 +231,23 @@ export const getAlertChipBorderRadius = ( return '0'; }; +/** + * Determines whether to use details.email.usernames (newer API) or content.email.email_addresses (older API) + * for displaying email recipients in notification channels. + * + * @param channel The notification channel to check + * @returns true if we should use content.email.email_addresses, false if we should use details.email.usernames + */ +export const shouldUseContentsForEmail = ( + channel: NotificationChannel +): boolean => { + // Use content if: details is missing, details is empty, details.email is empty or details.email.usernames is empty + return !( + channel.channel_type === 'email' && // ensuring it's an email channel to avoid the type error with email property + channel.details?.email?.usernames?.length + ); +}; + /** * @param value The notification channel object for which we need to display the chips * @returns The label and the values that needs to be displayed based on channel type @@ -239,9 +256,16 @@ export const getChipLabels = ( value: NotificationChannel ): AlertDimensionsProp => { if (value.channel_type === 'email') { + const contentEmail = value.content?.email; + const useContent = shouldUseContentsForEmail(value); + + const recipients = useContent + ? (contentEmail?.email_addresses ?? []) + : (value.details?.email?.usernames ?? []); + return { label: 'To', - values: value.content?.email.email_addresses ?? [], + values: recipients, }; } else if (value.channel_type === 'slack') { return { diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index d5a1defaee9..6399ab7d1e5 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3626,6 +3626,12 @@ export const handlers = [ updated: '2023-11-05T04:00:00', updated_by: 'user3', created_by: 'admin', + details: { + email: { + usernames: ['user1', 'user2'], + recipient_type: 'user', + }, + }, }) ); notificationChannels.push( From b026e5f69370a8082265aea3cac08b7279ebc3a4 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:02:12 +0530 Subject: [PATCH 02/58] =?UTF-8?q?Revert=20"upcoming:=20[DI-29113]=20-=20Su?= =?UTF-8?q?pport=20for=20User=20Channel=20Details=20in=20Alert=20flow?= =?UTF-8?q?=E2=80=A6"=20(#13252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e77c6773cbf2b08b26c41c9584898f67f99b931b. --- .../pr-13246-changed-1767779941145.md | 5 -- packages/api-v4/src/cloudpulse/types.ts | 7 ++- .../pr-13246-added-1767776466714.md | 5 -- .../alert-notification-channel-list.spec.ts | 39 ++++++------ .../src/factories/cloudpulse/channels.ts | 13 ++-- .../RenderChannelDetails.test.tsx | 18 ------ .../RenderChannelDetails.tsx | 20 +------ .../NotificationChannelListTable.test.tsx | 8 ++- .../NotificationChannelTableRow.test.tsx | 11 ++-- .../NotificationChannelTableRow.tsx | 2 +- .../NotificationsChannelsListing/constants.ts | 4 +- .../CloudPulse/Alerts/Utils/utils.test.ts | 60 +------------------ .../features/CloudPulse/Alerts/Utils/utils.ts | 26 +------- packages/manager/src/mocks/serverHandlers.ts | 6 -- 14 files changed, 51 insertions(+), 173 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-13246-changed-1767779941145.md delete mode 100644 packages/manager/.changeset/pr-13246-added-1767776466714.md diff --git a/packages/api-v4/.changeset/pr-13246-changed-1767779941145.md b/packages/api-v4/.changeset/pr-13246-changed-1767779941145.md deleted file mode 100644 index cb42e3fdae7..00000000000 --- a/packages/api-v4/.changeset/pr-13246-changed-1767779941145.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Changed ---- - -NotificationChannelAlerts to NotificationChannelAlertInfo object to match latest API specification ([#13246](https://github.com/linode/manager/pull/13246)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 43f725b6c7d..f9895f6cd46 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -282,13 +282,14 @@ export interface Alert { updated_by: string; } -interface NotificationChannelAlertInfo { - alert_count: number; +interface NotificationChannelAlerts { + id: number; + label: string; type: 'alerts-definitions'; url: string; } interface NotificationChannelBase { - alerts: NotificationChannelAlertInfo; + alerts: NotificationChannelAlerts[]; channel_type: ChannelType; created: string; created_by: string; diff --git a/packages/manager/.changeset/pr-13246-added-1767776466714.md b/packages/manager/.changeset/pr-13246-added-1767776466714.md deleted file mode 100644 index bc6fa85ca97..00000000000 --- a/packages/manager/.changeset/pr-13246-added-1767776466714.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Support for user-channel details in ACLP-Alerts and Notification Channel flow ([#13246](https://github.com/linode/manager/pull/13246)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts index 1cc30cc7f6e..c508f1cf3ee 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -41,11 +41,12 @@ const notificationChannels = notificationChannelFactory .buildList(26) .map((ch, i) => { const isEmail = i % 2 === 0; - const alerts = { - alert_count: isEmail ? 5 : 3, - url: `monitor/alert-channels/${i + 1}/alerts`, + const alerts = Array.from({ length: isEmail ? 5 : 3 }).map((_, idx) => ({ + id: idx + 1, + label: `Alert-${idx + 1}`, type: 'alerts-definitions', - }; + url: 'Sample', + })); if (isEmail) { return { @@ -146,7 +147,7 @@ const VerifyChannelSortingParams = ( ); const order = sortOrderMap[sortOrder]; - const orderBy = encodeURIComponent(LabelLookup[columnLabel]); + const orderBy = LabelLookup[columnLabel]; cy.url().should( 'endWith', @@ -214,7 +215,16 @@ describe('Notification Channel Listing Page', () => { } // Alerts list - expect(item.alerts.alert_count).to.eq(expected.alerts.alert_count); + expect(item.alerts.length).to.eq(expected.alerts.length); + + item.alerts.forEach((alert, aIndex) => { + const expAlert = expected.alerts[aIndex]; + + expect(alert.id).to.eq(expAlert.id); + expect(alert.label).to.eq(expAlert.label); + expect(alert.type).to.eq(expAlert.type); + expect(alert.url).to.eq(expAlert.url); + }); }); }); }); @@ -244,9 +254,7 @@ describe('Notification Channel Listing Page', () => { cy.wrap($row).within(() => { cy.findByText(expected.label).should('be.visible'); - cy.findByText(String(expected.alerts.alert_count)).should( - 'be.visible' - ); + cy.findByText(String(expected.alerts.length)).should('be.visible'); cy.findByText('Email').should('be.visible'); cy.get('td').eq(3).should('have.text', expected.created_by); cy.findByText( @@ -276,20 +284,11 @@ describe('Notification Channel Listing Page', () => { { column: 'Alerts', ascending: [...notificationChannels] - .sort( - // Primary sort by `alert_count`. When two items have the same - // alert count, fall back to `a.id - b.id` as a deterministic - // tie-breaker so test expectations remain stable. - (a, b) => a.alerts.alert_count - b.alerts.alert_count - ) + .sort((a, b) => a.alerts.length - b.alerts.length) .map((ch) => ch.id), descending: [...notificationChannels] - .sort( - // Primary sort by `alert_count` (desc). Tie-break with `a.id - b.id` - // to keep ordering deterministic for assertions. - (a, b) => b.alerts.alert_count - a.alerts.alert_count - ) + .sort((a, b) => b.alerts.length - a.alerts.length) .map((ch) => ch.id), }, diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts index ee361063bb0..42285f5d831 100644 --- a/packages/manager/src/factories/cloudpulse/channels.ts +++ b/packages/manager/src/factories/cloudpulse/channels.ts @@ -4,11 +4,14 @@ import type { NotificationChannel } from '@linode/api-v4'; export const notificationChannelFactory = Factory.Sync.makeFactory({ - alerts: { - type: 'alerts-definitions', - alert_count: 1, - url: 'monitor/alert-channels/{id}/alerts', - }, + alerts: [ + { + id: Number(Factory.each((i) => i)), + label: String(Factory.each((id) => `Alert-${id}`)), + type: 'alerts-definitions', + url: 'Sample', + }, + ], channel_type: 'email', content: { email: { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx index 255c5b42aba..37f4160d13d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx @@ -1,4 +1,3 @@ -import { screen } from '@testing-library/react'; import * as React from 'react'; import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; @@ -22,21 +21,4 @@ describe('RenderChannelDetails component', () => { expect(container.getByText(emailAddresses[0])).toBeVisible(); expect(container.getByText(emailAddresses[1])).toBeVisible(); }); - it('should render the email channel with usernames if details is present', () => { - const usernames = ['user1', 'user2']; - const mockDataWithDetails: NotificationChannel = - notificationChannelFactory.build({ - channel_type: 'email', - content: {}, - details: { - email: { - usernames, - recipient_type: 'user', - }, - }, - }); - renderWithTheme(); - expect(screen.getByText(usernames[0])).toBeVisible(); - expect(screen.getByText(usernames[1])).toBeVisible(); - }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx index 90eb2973df4..373724fc164 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx @@ -1,8 +1,6 @@ import { Chip } from '@mui/material'; import * as React from 'react'; -import { shouldUseContentsForEmail } from '../../Utils/utils'; - import type { NotificationChannel } from '@linode/api-v4'; interface RenderChannelDetailProps { @@ -14,21 +12,9 @@ interface RenderChannelDetailProps { export const RenderChannelDetails = (props: RenderChannelDetailProps) => { const { template } = props; if (template.channel_type === 'email') { - const contentEmail = template.content?.email; - const detailEmail = template.details?.email; - const useContents = shouldUseContentsForEmail(template); - - const recipients = useContents - ? (contentEmail?.email_addresses ?? []) - : (detailEmail?.usernames ?? []); - - return ( - <> - {recipients.map((value) => ( - - ))} - - ); + return template.content?.email.email_addresses.map((value, index) => ( + + )); } return null; }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx index 6f74321299f..57be8d91987 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx @@ -10,6 +10,8 @@ import { NotificationChannelListTable } from './NotificationChannelListTable'; const mockScrollToElement = vi.fn(); +const ALERT_TYPE = 'alerts-definitions'; + describe('NotificationChannelListTable', () => { it('should render the notification channel table headers', () => { renderWithTheme( @@ -125,7 +127,11 @@ describe('NotificationChannelListTable', () => { it('should display correct alerts count', () => { const channel = notificationChannelFactory.build({ - alerts: { alert_count: 3 }, + alerts: [ + { id: 1, label: 'Alert 1', type: ALERT_TYPE, url: 'url1' }, + { id: 2, label: 'Alert 2', type: ALERT_TYPE, url: 'url2' }, + { id: 3, label: 'Alert 3', type: ALERT_TYPE, url: 'url3' }, + ], }); renderWithTheme( diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx index 2218fe4dbdb..a014bad71bb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx @@ -18,11 +18,10 @@ describe('NotificationChannelTableRow', () => { it('should render a notification channel row with all fields', () => { const updated = new Date().toISOString(); const channel = notificationChannelFactory.build({ - alerts: { - type: 'alerts-definitions', - alert_count: 2, - url: 'monitor/alert-channels/{id}/alerts', - }, + alerts: [ + { id: 1, label: 'Alert 1', type: 'alerts-definitions', url: 'url1' }, + { id: 2, label: 'Alert 2', type: 'alerts-definitions', url: 'url2' }, + ], channel_type: 'email', created_by: 'user1', label: 'Test Channel', @@ -143,7 +142,7 @@ describe('NotificationChannelTableRow', () => { it('should render zero alerts count when no alerts are associated', () => { const channel = notificationChannelFactory.build({ - alerts: { alert_count: 0 }, + alerts: [], }); renderWithTheme( diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx index bb396d6bafd..6a2eae0d288 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx @@ -52,7 +52,7 @@ export const NotificationChannelTableRow = ( {label} - {alerts.alert_count} + {alerts.length} {channelTypeMap[channel_type]} {created_by} diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts index b9172283c5b..070d4165f23 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts @@ -2,7 +2,7 @@ import type { NotificationChannel } from '@linode/api-v4'; type ChannelListingTableLabel = { colName: string; - label: `${keyof NotificationChannel}:${string}` | keyof NotificationChannel; + label: keyof NotificationChannel; }; export const ChannelListingTableLabelMap: ChannelListingTableLabel[] = [ @@ -12,7 +12,7 @@ export const ChannelListingTableLabelMap: ChannelListingTableLabel[] = [ }, { colName: 'Alerts', - label: 'alerts:alert_count', + label: 'alerts', }, { colName: 'Channel Type', diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index e7a229816fc..4cb7b76999c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -1,7 +1,7 @@ import { regionFactory } from '@linode/utilities'; import { act, renderHook } from '@testing-library/react'; -import { alertFactory, notificationChannelFactory, serviceTypesFactory } from 'src/factories'; +import { alertFactory, serviceTypesFactory } from 'src/factories'; import { useContextualAlertsState } from '../../Utils/utils'; import { transformDimensionValue } from '../CreateAlert/Criteria/DimensionFilterValue/utils'; @@ -17,7 +17,6 @@ import { getSchemaWithEntityIdValidation, getServiceTypeLabel, handleMultipleError, - shouldUseContentsForEmail, } from './utils'; import type { AlertValidationSchemaProps } from './utils'; @@ -498,60 +497,3 @@ describe('transformDimensionValue', () => { ).toBe('Test_value'); }); }); - -describe('shouldUseContentsForEmail', () => { - it('should return false for email channel with valid usernames in details', () => { - const notificationChannel = notificationChannelFactory.build({ - channel_type: 'email', - details: { - email: { - usernames: ['user1', 'user2'], - }, - }, - }); - expect(shouldUseContentsForEmail(notificationChannel)).toBe(false); - }); - - it('should return true for email channel with undefined details', () => { - const notificationChannel = notificationChannelFactory.build({ - channel_type: 'email', - details: undefined, - }); - expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); - }); - - it('should return true for email channel with undefined details.email', () => { - const notificationChannel = notificationChannelFactory.build({ - channel_type: 'email', - details: { - email: undefined, - }, - }); - expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); - }); - - it('should return true for email channel with undefined usernames', () => { - const notificationChannel = notificationChannelFactory.build({ - channel_type: 'email', - details: { - email: { - usernames: undefined, - }, - }, - }); - expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); - }); - - it('should return true for email channel with empty usernames array', () => { - const notificationChannel = notificationChannelFactory.build({ - channel_type: 'email', - details: { - email: { - usernames: [], - recipient_type: 'admin_users', - }, - }, - }); - expect(shouldUseContentsForEmail(notificationChannel)).toBe(true); - }); -}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 2fa962fa5ed..78e9c495eb2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -231,23 +231,6 @@ export const getAlertChipBorderRadius = ( return '0'; }; -/** - * Determines whether to use details.email.usernames (newer API) or content.email.email_addresses (older API) - * for displaying email recipients in notification channels. - * - * @param channel The notification channel to check - * @returns true if we should use content.email.email_addresses, false if we should use details.email.usernames - */ -export const shouldUseContentsForEmail = ( - channel: NotificationChannel -): boolean => { - // Use content if: details is missing, details is empty, details.email is empty or details.email.usernames is empty - return !( - channel.channel_type === 'email' && // ensuring it's an email channel to avoid the type error with email property - channel.details?.email?.usernames?.length - ); -}; - /** * @param value The notification channel object for which we need to display the chips * @returns The label and the values that needs to be displayed based on channel type @@ -256,16 +239,9 @@ export const getChipLabels = ( value: NotificationChannel ): AlertDimensionsProp => { if (value.channel_type === 'email') { - const contentEmail = value.content?.email; - const useContent = shouldUseContentsForEmail(value); - - const recipients = useContent - ? (contentEmail?.email_addresses ?? []) - : (value.details?.email?.usernames ?? []); - return { label: 'To', - values: recipients, + values: value.content?.email.email_addresses ?? [], }; } else if (value.channel_type === 'slack') { return { diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6399ab7d1e5..d5a1defaee9 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3626,12 +3626,6 @@ export const handlers = [ updated: '2023-11-05T04:00:00', updated_by: 'user3', created_by: 'admin', - details: { - email: { - usernames: ['user1', 'user2'], - recipient_type: 'user', - }, - }, }) ); notificationChannels.push( From d423815cf56a299fe69d4cf0014cf48057bb3533 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Thu, 8 Jan 2026 13:36:42 +0530 Subject: [PATCH 03/58] change: [UIE-9919] - Address post-demo feedback for Network Load Balancers (#13250) * change: [UIE-9919] - Address post-demo feedback(points 1-3) * Added changeset: NLB post-demo feedback-fix empty state title casing,rename LKE-E to Cluster and adjust column visibility for smaller screens to prioritize IPv6 --- .../pr-13250-changed-1767855983742.md | 5 +++++ .../NetworkLoadBalancerDetailBody.tsx | 4 ++-- .../NetworkLoadBalancersDetail.test.tsx | 4 ++-- .../NetworkLoadBalancerTableRow.tsx | 18 +++++++++--------- .../NetworkLoadBalancersLanding.tsx | 6 +++--- .../NetworkLoadBalancersLandingEmptyState.tsx | 2 +- 6 files changed, 22 insertions(+), 17 deletions(-) create mode 100644 packages/manager/.changeset/pr-13250-changed-1767855983742.md diff --git a/packages/manager/.changeset/pr-13250-changed-1767855983742.md b/packages/manager/.changeset/pr-13250-changed-1767855983742.md new file mode 100644 index 00000000000..6cc47996514 --- /dev/null +++ b/packages/manager/.changeset/pr-13250-changed-1767855983742.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +NLB post-demo feedback-fix empty state title casing,rename LKE-E to Cluster and adjust column visibility for smaller screens to prioritize IPv6 ([#13250](https://github.com/linode/manager/pull/13250)) diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailBody.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailBody.tsx index 54ecf7dfb44..c8a165c1390 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailBody.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailBody.tsx @@ -59,11 +59,11 @@ export const NetworkLoadBalancerDetailBody = ( {regionLabel} - LKE-E Cluster + Cluster {lkeCluster ? ( <> diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.test.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.test.tsx index db1d46b2caf..3f985331e02 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.test.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.test.tsx @@ -97,7 +97,7 @@ describe('NetworkLoadBalancersDetail', () => { expect(getByText('Region')).toBeVisible(); expect(getByText('US, Newark, NJ')).toBeVisible(); - expect(getByText('LKE-E Cluster')).toBeVisible(); + expect(getByText('Cluster')).toBeVisible(); expect(getByText('None')).toBeVisible(); expect(getByText('Network Load Balancer ID')).toBeVisible(); @@ -121,7 +121,7 @@ describe('NetworkLoadBalancersDetail', () => { const { getByText } = renderWithTheme(); - expect(getByText('LKE-E Cluster')).toBeVisible(); + expect(getByText('Cluster')).toBeVisible(); expect(getByText(nlbFactory.lke_cluster!.label)).toBeVisible(); expect( getByText(`(ID: ${nlbFactory.lke_cluster!.id})`, { diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx index baaa244831c..0e35ccac349 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx @@ -67,18 +67,18 @@ export const NetworkLoadBalancerTableRow = (props: NetworkLoadBalancer) => { - - - - - {address_v6 ? ( - - ) : ( - 'None' - )} + + + + {address_v6 ? ( + + ) : ( + 'None' + )} + {lke_cluster ? ( { ID Listener Ports - Virtual IP (IPv4) - Virtual IP (IPv6) + Virtual IP (IPv4) - LKE-E Cluster + Virtual IP (IPv6) + Cluster Region diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx index 7a9e6cacc19..7ee49704861 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx @@ -30,7 +30,7 @@ export const NetworkLoadBalancersLandingEmptyState = () => { ]} icon={NetworkIcon} isEntity - subtitle="High Capacity load balancing service" + subtitle="High capacity network load balancing service" title="Network Load Balancer" > From 5a108c315ecc5aa285960dd0693e0b011dda96f1 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Thu, 8 Jan 2026 14:00:39 +0530 Subject: [PATCH 04/58] change: [UIE-9919] - Address post-demo feedback(points 4,5) (#13251) * change: [UIE-9919] - Address post-demo feedback(points 4,5) * Added changeset: NLB post-demo feedback-Resolve special character filtering issue in Nodes table IPv6 column and add NLB to GoTo quick navigation --- .../pr-13251-changed-1767856277321.md | 5 +++ packages/manager/src/GoTo.tsx | 8 ++++ .../NodesTable/NodesTable.tsx | 37 ++++++++++++------- 3 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 packages/manager/.changeset/pr-13251-changed-1767856277321.md diff --git a/packages/manager/.changeset/pr-13251-changed-1767856277321.md b/packages/manager/.changeset/pr-13251-changed-1767856277321.md new file mode 100644 index 00000000000..8416d3db1e9 --- /dev/null +++ b/packages/manager/.changeset/pr-13251-changed-1767856277321.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +NLB post-demo feedback-Resolve special character filtering issue in Nodes table IPv6 column and add NLB to GoTo quick navigation ([#13251](https://github.com/linode/manager/pull/13251)) diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index 93a0bddf0b7..f6221f94afb 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -6,6 +6,7 @@ import * as React from 'react'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { usePermissions } from './features/IAM/hooks/usePermissions'; import { useIsMarketplaceV2Enabled } from './features/Marketplace/utils'; +import { useIsNetworkLoadBalancerEnabled } from './features/NetworkLoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useFlags } from './hooks/useFlags'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; @@ -26,6 +27,7 @@ export const GoTo = React.memo(() => { const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); + const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled(); const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); @@ -58,6 +60,11 @@ export const GoTo = React.memo(() => { display: 'VPC', href: '/vpcs', }, + { + display: 'Network Load Balancer', + hide: !isNetworkLoadBalancerEnabled, + href: '/netloadbalancers', + }, { display: 'NodeBalancers', href: '/nodebalancers', @@ -138,6 +145,7 @@ export const GoTo = React.memo(() => { isDatabasesEnabled, isManagedAccount, isMarketplaceV2FeatureEnabled, + isNetworkLoadBalancerEnabled, isPlacementGroupsEnabled, iamRbacPrimaryNavChanges, ] diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NodesTable/NodesTable.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NodesTable/NodesTable.tsx index e85c120b410..f3d584e59da 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NodesTable/NodesTable.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NodesTable/NodesTable.tsx @@ -1,5 +1,4 @@ import { useNetworkLoadBalancerNodesQuery } from '@linode/queries'; -import { getAPIFilterFromQuery } from '@linode/search'; import { CircleProgress, Hidden, @@ -47,13 +46,14 @@ export const NodesTable = (props: NodesTableProps) => { from: NLB_NODES_ROUTE, shouldThrow: false, }); + const query = search?.query; const pagination = usePaginationV2({ currentRoute: NLB_NODES_ROUTE, preferenceKey, searchParams: (prev) => ({ ...prev, - query: search?.query, + query, }), }); @@ -68,17 +68,29 @@ export const NodesTable = (props: NodesTableProps) => { preferenceKey, }); - const { filter: searchFilter, error: searchError } = getAPIFilterFromQuery( - search?.query, - { - searchableFieldsWithoutOperator: ['id', 'linode_id', 'address_v6'], - } - ); - const filter = { ['+order']: order, ['+order_by']: orderBy, - ...searchFilter, + }; + + const generateNodesXFilter = (searchText: string) => { + if (searchText === '') { + return filter; + } + return { + '+or': [ + { + id: { '+contains': searchText }, + }, + { + linode_id: { '+contains': searchText }, + }, + { + address_v6: { '+contains': searchText }, + }, + ], + ...filter, + }; }; const { @@ -93,7 +105,7 @@ export const NodesTable = (props: NodesTableProps) => { page: pagination.page, page_size: pagination.pageSize, }, - filter + generateNodesXFilter(query ?? '') ); const onSearch = (query: string) => { @@ -101,7 +113,7 @@ export const NodesTable = (props: NodesTableProps) => { search: (prev) => ({ ...prev, page: undefined, - query: query ? query : undefined, + query: query ?? undefined, }), to: `/netloadbalancers/${nlbId}/listeners/${listenerId}/nodes`, }); @@ -136,7 +148,6 @@ export const NodesTable = (props: NodesTableProps) => { Date: Thu, 8 Jan 2026 12:22:00 +0100 Subject: [PATCH 05/58] chore: [UIE-9913] - IAM: Cleanup `iamRbacPrimaryNavChanges` feature flag (#13232) * Save progress * wrap up initial cleanup * fix tests * fix tests * Added changeset: IAM: Cleanup `iamRbacPrimaryNavChanges` feature flag * feedback @bnussman-akamai * small work break fix --- .../pr-13232-tech-stories-1767359015030.md | 5 + .../core/account/account-maintenance.spec.ts | 2 - .../e2e/core/account/quotas-nav.spec.ts | 82 ------------ .../restricted-user-details-pages.spec.ts | 2 - .../e2e/core/account/service-transfer.spec.ts | 9 -- .../e2e/core/account/user-permissions.spec.ts | 2 - .../core/account/users-landing-page.spec.ts | 2 - .../e2e/core/billing/billing-invoices.spec.ts | 7 - .../credit-card-expired-banner.spec.ts | 5 - .../e2e/core/billing/google-pay.spec.ts | 8 -- .../billing/restricted-user-billing.spec.ts | 8 -- .../billing/smoke-billing-activity.spec.ts | 2 - .../e2e/core/general/smoke-deep-link.spec.ts | 5 - .../maintenance-policy-region-support.spec.ts | 6 - .../smoke-linode-landing-table.spec.ts | 10 -- packages/manager/src/GoTo.tsx | 30 +---- .../MaskableText/MaskableTextArea.tsx | 14 +- .../PaymentMethodRow.test.tsx | 61 +++++---- .../PaymentMethodRow/PaymentMethodRow.tsx | 4 +- .../components/PrimaryNav/PrimaryNav.test.tsx | 45 +------ .../src/components/PrimaryNav/PrimaryNav.tsx | 52 +++----- .../TypeToConfirm/TypeToConfirm.test.tsx | 2 +- .../TypeToConfirm/TypeToConfirm.tsx | 15 +-- .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 - packages/manager/src/featureFlags.ts | 1 - .../src/features/Account/AccountLanding.tsx | 12 +- .../src/features/Account/AccountLogins.tsx | 10 +- .../Account/Maintenance/MaintenanceTable.tsx | 8 +- .../src/features/Account/Quotas/Quotas.tsx | 6 +- .../features/Account/Quotas/QuotasTable.tsx | 6 +- .../AccountSettingsLanding.tsx | 12 -- .../features/Billing/BillingDetail.test.tsx | 34 ++++- .../Billing/BillingLanding/BillingLanding.tsx | 13 +- .../BillingActivityPanel.tsx | 18 +-- .../BillingSummary/BillingSummary.test.tsx | 26 ++-- .../BillingSummary/BillingSummary.tsx | 10 +- .../ContactInfoPanel/ContactInformation.tsx | 10 +- .../PaymentInformation.test.tsx | 14 +- .../PaymentInfoPanel/PaymentInformation.tsx | 16 +-- .../Billing/InvoiceDetail/InvoiceDetail.tsx | 10 +- .../EntityTransfersCreate.tsx | 8 +- .../LinodeTransferTable.tsx | 6 +- .../EntityTransfersLanding.tsx | 14 +- .../TransferControls.tsx | 7 +- .../CreditCardExpiredBanner.tsx | 6 +- .../GlobalNotifications/EmailBounce.tsx | 7 +- .../TaxCollectionBanner.tsx | 2 +- .../LoginHistory/LoginHistoryLanding.tsx | 12 -- .../Maintenance/MaintenanceLanding.tsx | 12 -- .../hooks/useGetObjUsagePerEndpoint.ts | 7 +- .../manager/src/features/Profile/Profile.tsx | 13 +- .../features/Profile/Settings/Settings.tsx | 14 +- .../Profile/Settings/settingsLazyRoute.ts | 8 -- .../src/features/Quotas/QuotasLanding.tsx | 14 -- .../ServiceTransfersLanding.tsx | 12 -- .../TopMenu/UserMenu/UserMenuPopover.tsx | 62 +++------ .../src/features/Users/CreateUserDrawer.tsx | 18 +-- .../manager/src/features/Users/UserDetail.tsx | 14 +- .../Users/UserPermissionsEntitySection.tsx | 7 +- .../Users/UserProfile/DeleteUserPanel.tsx | 5 +- .../Users/UserProfile/UserProfile.tsx | 7 +- .../Users/UserProfile/UsernamePanel.tsx | 6 +- .../src/features/Users/UsersActionMenu.tsx | 14 +- .../src/features/Users/UsersLanding.tsx | 7 +- .../UsersAndGrants/UsersAndGrants.tsx | 14 -- packages/manager/src/routes/account/index.ts | 122 +++++++----------- .../src/routes/accountSettings/index.ts | 8 -- packages/manager/src/routes/billing/index.ts | 19 +-- .../manager/src/routes/loginHistory/index.ts | 8 -- .../manager/src/routes/maintenance/index.ts | 8 -- packages/manager/src/routes/profile/index.ts | 22 +--- packages/manager/src/routes/quotas/index.ts | 8 -- .../src/routes/serviceTransfers/index.ts | 16 --- .../usersAndGrants/UsersAndGrantsRoute.tsx | 5 +- 74 files changed, 261 insertions(+), 836 deletions(-) create mode 100644 packages/manager/.changeset/pr-13232-tech-stories-1767359015030.md diff --git a/packages/manager/.changeset/pr-13232-tech-stories-1767359015030.md b/packages/manager/.changeset/pr-13232-tech-stories-1767359015030.md new file mode 100644 index 00000000000..9726cba0b55 --- /dev/null +++ b/packages/manager/.changeset/pr-13232-tech-stories-1767359015030.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +IAM: Cleanup `iamRbacPrimaryNavChanges` feature flag ([#13232](https://github.com/linode/manager/pull/13232)) diff --git a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts index 14e4ada60c6..9890547fdbc 100644 --- a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts @@ -27,8 +27,6 @@ describe('Maintenance', () => { // TODO When the Host & VM Maintenance feature rolls out, we want to enable the feature flag and update the test. mockAppendFeatureFlags({ - // TODO M3-10491 - Remove "iamRbacPrimaryNavChanges" feature flag mock once feature flag is deleted. - iamRbacPrimaryNavChanges: true, vmHostMaintenance: { enabled: false, }, diff --git a/packages/manager/cypress/e2e/core/account/quotas-nav.spec.ts b/packages/manager/cypress/e2e/core/account/quotas-nav.spec.ts index 6d972eb36b9..bfaf543c4a2 100644 --- a/packages/manager/cypress/e2e/core/account/quotas-nav.spec.ts +++ b/packages/manager/cypress/e2e/core/account/quotas-nav.spec.ts @@ -1,94 +1,12 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; -describe('Quotas accessible when limitsEvolution feature flag enabled', () => { - // TODO M3-10491 - Remove `describe` block and move tests to parent scope once `iamRbacPrimaryNavChanges` feature flag is removed. - describe('When IAM RBAC account navigation feature flag is enabled', () => { - beforeEach(() => { - // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. - mockAppendFeatureFlags({ - limitsEvolution: { - enabled: true, - }, - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` mock once feature flag is removed. - iamRbacPrimaryNavChanges: true, - }).as('getFeatureFlags'); - }); - - it('can navigate directly to Quotas page', () => { - cy.visitWithLogin('/quotas'); - cy.wait('@getFeatureFlags'); - cy.url().should('endWith', '/quotas'); - cy.contains( - 'View your Object Storage quotas by applying the endpoint filter below' - ).should('be.visible'); - }); - - it('can navigate to the Quotas page via the User Menu', () => { - cy.visitWithLogin('/'); - cy.wait('@getFeatureFlags'); - // Open user menu - ui.userMenuButton.find().click(); - ui.userMenu.find().within(() => { - cy.get('[data-testid="menu-item-Quotas"]').should('be.visible').click(); - cy.url().should('endWith', '/quotas'); - }); - }); - }); - - // TODO M3-10491 - Remove `describe` block and tests once "iamRbacPrimaryNavChanges" feature flag is removed. - describe('When IAM RBAC account navigation feature flag is disabled', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - limitsEvolution: { - enabled: true, - }, - iamRbacPrimaryNavChanges: false, - }).as('getFeatureFlags'); - }); - - it('can navigate directly to Quotas page', () => { - cy.visitWithLogin('/account/quotas'); - cy.wait('@getFeatureFlags'); - cy.url().should('endWith', '/account/quotas'); - cy.contains( - 'View your Object Storage quotas by applying the endpoint filter below' - ).should('be.visible'); - }); - - it('can navigate to the Quotas page via the User Menu', () => { - cy.visitWithLogin('/'); - cy.wait('@getFeatureFlags'); - // Open user menu - ui.userMenuButton.find().click(); - ui.userMenu.find().within(() => { - cy.get('[data-testid="menu-item-Quotas"]').should('be.visible').click(); - cy.url().should('endWith', '/quotas'); - }); - }); - - it('Quotas tab is visible from all other tabs in Account tablist', () => { - cy.visitWithLogin('/account/billing'); - cy.wait('@getFeatureFlags'); - ui.tabList.find().within(() => { - cy.get('a').each(($link) => { - cy.wrap($link).click(); - cy.get('[data-testid="Quotas"]').should('be.visible'); - }); - }); - cy.get('[data-testid="Quotas"]').should('be.visible').click(); - cy.url().should('endWith', '/quotas'); - }); - }); -}); - describe('Quotas inaccessible when limitsEvolution feature flag disabled', () => { beforeEach(() => { mockAppendFeatureFlags({ limitsEvolution: { enabled: false, }, - iamRbacPrimaryNavChanges: true, }).as('getFeatureFlags'); }); diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index 61c93e1f830..a03280a344b 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -106,8 +106,6 @@ describe('restricted user details pages', () => { mockAppendFeatureFlags({ apl: false, dbaasV2: { beta: false, enabled: false }, - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - iamRbacPrimaryNavChanges: true, }); }); diff --git a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts index 6d0d8e4649e..16d14933dab 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -14,7 +14,6 @@ import { mockInitiateEntityTransferError, mockReceiveEntityTransfer, } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -127,14 +126,6 @@ describe('Account service transfers', () => { cleanUp(['service-transfers', 'linodes', 'lke-clusters']); }); - beforeEach(() => { - // Mock the iamRbacPrimaryNavChanges feature flag to be disabled. - mockAppendFeatureFlags({ - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - iamRbacPrimaryNavChanges: true, - }).as('getFeatureFlags'); - }); - /* * - Confirms user can navigate to service transfer page via user menu. */ diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index 8efd2e884d0..3a392ef5f89 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -170,9 +170,7 @@ const assertBillingAccessSelected = ( describe('User permission management', () => { beforeEach(() => { - // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. mockAppendFeatureFlags({ - iamRbacPrimaryNavChanges: true, iam: { enabled: false, }, diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index b343ac638d5..91afe8661f6 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -81,9 +81,7 @@ const initTestUsers = (profile: Profile, enableChildAccountAccess: boolean) => { describe('Users landing page', () => { beforeEach(() => { - // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. mockAppendFeatureFlags({ - iamRbacPrimaryNavChanges: true, iam: { enabled: false, }, diff --git a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts index 90de20fa000..774e8461400 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts @@ -9,7 +9,6 @@ import { mockGetInvoice, mockGetInvoiceItems, } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; import { formatUsd } from 'support/util/currency'; @@ -31,12 +30,6 @@ const getRegionLabel = (regionId: string) => { }; describe('Account invoices', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - iamRbacPrimaryNavChanges: true, - }); - }); /* * - Confirms that invoice items are listed on invoice details page using mock API data. * - Confirms that each invoice item is displayed with correct accompanying info. diff --git a/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts b/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts index 4ebaf70e8be..758d517e18d 100644 --- a/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts @@ -1,5 +1,4 @@ import { mockGetAccount } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { ui } from 'support/ui'; @@ -13,10 +12,6 @@ describe('Credit Card Expired Banner', () => { mockGetUserPreferences({ dismissed_notifications: {}, }); - mockAppendFeatureFlags({ - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - iamRbacPrimaryNavChanges: true, - }).as('getFeatureFlags'); }); it('appears when the expiration date is in the past', () => { diff --git a/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts b/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts index b13bfd3f8c9..4b164903407 100644 --- a/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts @@ -1,5 +1,4 @@ import { mockGetPaymentMethods } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import type { CreditCardData, PaymentMethod } from '@linode/api-v4'; @@ -57,13 +56,6 @@ const braintreeURL = 'https://+(payments.braintree-api.com|payments.sandbox.braintree-api.com)/*'; describe('Google Pay', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - iamRbacPrimaryNavChanges: true, - }); - }); - it('adds google pay method', () => { cy.intercept(braintreeURL).as('braintree'); mockGetPaymentMethods(mockPaymentMethods).as('getPaymentMethods'); diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index febde9e4661..5787d446ce4 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -6,7 +6,6 @@ import { grantsFactory, profileFactory } from '@linode/utilities'; import { paymentMethodFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, @@ -224,13 +223,6 @@ const assertMakeAPaymentEnabled = () => { describe('restricted user billing flows', () => { beforeEach(() => { mockGetPaymentMethods(mockPaymentMethods); - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - mockAppendFeatureFlags({ - iamRbacPrimaryNavChanges: true, - iam: { - enabled: false, - }, - }); }); /* diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index d130776eb56..7d5925f57a8 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -118,8 +118,6 @@ authenticate(); describe('Billing Activity Feed', () => { beforeEach(() => { mockAppendFeatureFlags({ - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - iamRbacPrimaryNavChanges: true, iam: { enabled: false, }, diff --git a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts index 183dc7dd047..821ea4a8103 100644 --- a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts +++ b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts @@ -1,4 +1,3 @@ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { pages } from 'support/ui/constants'; import type { Page } from 'support/ui/constants'; @@ -9,10 +8,6 @@ beforeEach(() => { describe('smoke - deep links', () => { beforeEach(() => { cy.visitWithLogin('/null'); - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - mockAppendFeatureFlags({ - iamRbacPrimaryNavChanges: true, - }).as('getFeatureFlags'); }); it('Go to each route and validate deep links', () => { diff --git a/packages/manager/cypress/e2e/core/linodes/maintenance-policy-region-support.spec.ts b/packages/manager/cypress/e2e/core/linodes/maintenance-policy-region-support.spec.ts index 8126eac56bf..3481b60fa99 100644 --- a/packages/manager/cypress/e2e/core/linodes/maintenance-policy-region-support.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/maintenance-policy-region-support.spec.ts @@ -123,12 +123,6 @@ describe('maintenance policy region support - Linode Details > Settings', () => cleanUp(['linodes', 'lke-clusters']); }); - beforeEach(() => { - mockAppendFeatureFlags({ - iamRbacPrimaryNavChanges: false, - }).as('getFeatureFlags'); - }); - it('disables maintenance policy selector when region does not support it', () => { // Mock a linode in a region that doesn't support maintenance policies const mockRegion = regionFactory.build({ diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index 3cacea66f7f..efaa1174406 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -69,9 +69,7 @@ const preferenceOverrides = { authenticate(); describe('linode landing checks', () => { beforeEach(() => { - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. mockAppendFeatureFlags({ - iamRbacPrimaryNavChanges: true, iam: { enabled: false, }, @@ -478,10 +476,6 @@ describe('linode landing checks', () => { describe('linode landing checks for empty state', () => { beforeEach(() => { - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - mockAppendFeatureFlags({ - iamRbacPrimaryNavChanges: true, - }); // Mock setup to display the Linode landing page in an empty state mockGetLinodes([]).as('getLinodes'); }); @@ -584,10 +578,6 @@ describe('linode landing checks for empty state', () => { describe('linode landing checks for non-empty state with restricted user', () => { beforeEach(() => { - // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. - mockAppendFeatureFlags({ - iamRbacPrimaryNavChanges: true, - }); // Mock setup to display the Linode landing page in an non-empty state const mockLinodes: Linode[] = new Array(1) .fill(null) diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index f6221f94afb..791cb6d675c 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -4,11 +4,9 @@ import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; -import { usePermissions } from './features/IAM/hooks/usePermissions'; import { useIsMarketplaceV2Enabled } from './features/Marketplace/utils'; import { useIsNetworkLoadBalancerEnabled } from './features/NetworkLoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; -import { useFlags } from './hooks/useFlags'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; import type { SelectOption } from '@linode/ui'; @@ -18,12 +16,8 @@ export const GoTo = React.memo(() => { const { data: accountSettings } = useAccountSettings(); - const { iamRbacPrimaryNavChanges } = useFlags(); - const isManagedAccount = accountSettings?.managed ?? false; - const { data: permissions } = usePermissions('account', ['is_account_admin']); - const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); @@ -115,22 +109,12 @@ export const GoTo = React.memo(() => { : 'Quick Deploy Apps', href: '/linodes/create/marketplace', }, - ...(iamRbacPrimaryNavChanges - ? [ - { display: 'Billing', href: '/billing' }, - { display: 'Identity & Access', href: '/iam' }, - { display: 'Login History', href: '/login-history' }, - { display: 'Service Transfers', href: '/service-transfers' }, - { display: 'Maintenance', href: '/maintenance' }, - { display: 'Settings', href: '/settings' }, - ] - : [ - { - display: 'Account', - hide: !permissions.is_account_admin, - href: '/account/billing', - }, - ]), + { display: 'Billing', href: '/billing' }, + { display: 'Identity & Access', href: '/iam' }, + { display: 'Login History', href: '/login-history' }, + { display: 'Service Transfers', href: '/service-transfers' }, + { display: 'Maintenance', href: '/maintenance' }, + { display: 'Settings', href: '/settings' }, { display: 'Help & Support', href: '/support', @@ -141,13 +125,11 @@ export const GoTo = React.memo(() => { }, ], [ - permissions.is_account_admin, isDatabasesEnabled, isManagedAccount, isMarketplaceV2FeatureEnabled, isNetworkLoadBalancerEnabled, isPlacementGroupsEnabled, - iamRbacPrimaryNavChanges, ] ); diff --git a/packages/manager/src/components/MaskableText/MaskableTextArea.tsx b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx index 5075bc68dc9..921e7181ef6 100644 --- a/packages/manager/src/components/MaskableText/MaskableTextArea.tsx +++ b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx @@ -1,8 +1,6 @@ import { Typography } from '@linode/ui'; import React from 'react'; -import { useFlags } from 'src/hooks/useFlags'; - import { Link } from '../Link'; /** @@ -10,21 +8,11 @@ import { Link } from '../Link'; * Example: Billing Contact info, rather than masking many individual fields */ export const MaskableTextAreaCopy = () => { - const { iamRbacPrimaryNavChanges } = useFlags(); return ( This data is sensitive and hidden for privacy. To unmask all sensitive data by default, go to{' '} - - profile settings - - . + profile settings. ); }; diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx index 62810bba634..f4b47bbceff 100644 --- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx +++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx @@ -259,6 +259,13 @@ describe('Payment Method Row', () => { }); it('Opens "Make a Payment" drawer with the payment method preselected if "Make a Payment" action is clicked', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + make_billing_payment: true, + set_default_payment_method: false, + delete_payment_method: false, + }, + }); const paymentMethods = [ paymentMethodFactory.build({ type: 'paypal', @@ -274,23 +281,22 @@ describe('Payment Method Row', () => { * The component is responsible for rendering the "Make a Payment" drawer, * and is required for this test. We may want to consider decoupling these components in the future. */ - const { getByLabelText, getByTestId, getByText, getAllByTestId } = - renderWithTheme( - - - - , - { - initialRoute: '/account/billing', - } - ); + const { getByLabelText, getByText, router } = renderWithTheme( + + + + , + { + initialRoute: '/billing', + } + ); const actionMenu = getByLabelText('Action menu for card ending in 1881'); await userEvent.click(actionMenu); @@ -300,21 +306,12 @@ describe('Payment Method Row', () => { await userEvent.click(makePaymentButton); await waitFor(() => { - expect(getByTestId('drawer')).toBeVisible(); + expect(router.state.location.pathname).toBe('/billing'); + }); + await waitFor(() => { + expect(router.state.location.searchStr).toBe( + '?action=make-payment&paymentMethodId=12' + ); }); - - expect(getByTestId('drawer-title')).toHaveTextContent('Make a Payment'); - - const expectedSelectionCard = getAllByTestId('selection-card')[1]; - - expect(expectedSelectionCard).toBeVisible(); - expect(expectedSelectionCard).toHaveTextContent('1881'); - expect(expectedSelectionCard).toHaveAttribute( - 'data-qa-selection-card-checked', - 'true' - ); - - // In the future, if we have access to the router's state, - // we can assert the search params. }); }); diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx index f01ad45292e..40134cfb8d4 100644 --- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx +++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx @@ -8,7 +8,6 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import CreditCard from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; -import { useFlags } from 'src/hooks/useFlags'; import { ThirdPartyPayment } from './ThirdPartyPayment'; @@ -40,7 +39,6 @@ export const PaymentMethodRow = (props: Props) => { const { is_default, type } = paymentMethod; const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); - const flags = useFlags(); const { mutateAsync: makePaymentMethodDefault } = useMakeDefaultPaymentMethodMutation(props.paymentMethod.id); @@ -65,7 +63,7 @@ export const PaymentMethodRow = (props: Props) => { disabled: isChildUser || !permissions.make_billing_payment, onClick: () => { navigate({ - to: flags?.iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: '/billing', search: (prev) => ({ ...prev, action: 'make-payment', diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 7c078ccc248..de1aa70d4ec 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -494,9 +494,8 @@ describe('PrimaryNav', () => { expect(queryByTestId('menu-item-Logs')).toBeNull(); }); - it('should show Administration links if iamRbacPrimaryNavChanges flag is enabled', async () => { + it('should show Administration links', async () => { const flags: Partial = { - iamRbacPrimaryNavChanges: true, iam: { beta: true, enabled: true, @@ -544,7 +543,6 @@ describe('PrimaryNav', () => { it('should hide Identity & Access link for non beta users', async () => { const flags: Partial = { - iamRbacPrimaryNavChanges: true, iam: { beta: true, enabled: false, @@ -570,47 +568,6 @@ describe('PrimaryNav', () => { }); }); - it('should show Account link and hide Administration if iamRbacPrimaryNavChanges flag is disabled', async () => { - const flags: Partial = { - iamRbacPrimaryNavChanges: false, - iam: { - beta: true, - enabled: true, - }, - }; - - queryMocks.useIsIAMEnabled.mockReturnValue({ - isIAMBeta: true, - isIAMEnabled: true, - }); - - renderWithTheme(, { - flags, - }); - - const adminLink = screen.queryByRole('button', { name: 'Administration' }); - expect(adminLink).toBeNull(); - - await waitFor(() => { - expect(screen.queryByRole('link', { name: 'Billing' })).toBeNull(); - expect(screen.queryByRole('link', { name: 'Quotas' })).toBeNull(); - expect(screen.queryByRole('link', { name: 'Login History' })).toBeNull(); - expect( - screen.queryByRole('link', { name: 'Service Transfers' }) - ).toBeNull(); - expect(screen.queryByRole('link', { name: 'Maintenance' })).toBeNull(); - expect( - screen.queryByRole('link', { name: 'Account' }) - ).toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'Identity & Access' }) - ).toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'Account Settings' }) - ).toBeNull(); - }); - }); - it('should show Network Load Balancers menu item if the user has the account capability and the flag is enabled', async () => { const account = accountFactory.build({ capabilities: ['Network LoadBalancer'], diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 1a4d0499baa..1aa77fc982f 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -114,7 +114,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { flags.aclpAlerting?.recentActivity || flags.aclpAlerting?.notificationChannels); - const { iamRbacPrimaryNavChanges, limitsEvolution } = flags; + const { limitsEvolution } = flags; const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); @@ -282,36 +282,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { name: 'Monitor', }, { - icon: , - links: [ - { - display: 'Betas', - hide: !flags.selfServeBetas, - to: '/betas', - }, - { - display: 'Identity & Access', - hide: !isIAMEnabled || iamRbacPrimaryNavChanges, - to: '/iam', - isBeta: isIAMBeta, - isNew: !isIAMBeta && showLimitedAvailabilityBadges, - }, - { - display: 'Account', - hide: iamRbacPrimaryNavChanges, - to: '/account', - }, - { - display: 'Help & Support', - to: '/support', - }, - ], - name: 'More', - }, - ]; - - if (iamRbacPrimaryNavChanges) { - groups.splice(groups.length - 1, 0, { icon: , links: [ { @@ -353,8 +323,23 @@ export const PrimaryNav = (props: PrimaryNavProps) => { }, ], name: 'Administration', - }); - } + }, + { + icon: , + links: [ + { + display: 'Betas', + hide: !flags.selfServeBetas, + to: '/betas', + }, + { + display: 'Help & Support', + to: '/support', + }, + ], + name: 'More', + }, + ]; return groups; }, @@ -369,7 +354,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isACLPLogsEnabled, isIAMBeta, isIAMEnabled, - iamRbacPrimaryNavChanges, isMarketplaceV2FeatureEnabled, isNetworkLoadBalancerEnabled, limitsEvolution, diff --git a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.test.tsx b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.test.tsx index 2c18dc04d40..c21a5742fb9 100644 --- a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.test.tsx +++ b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.test.tsx @@ -72,7 +72,7 @@ describe('TypeToConfirm Component', () => { expect( queryByTestId('instructions-to-enable-or-disable') ).toBeInTheDocument(); - expect(getByRole('link')).toHaveAttribute('href', '/profile/settings'); + expect(getByRole('link')).toHaveAttribute('href', '/profile/preferences'); }); it('Should not display instructions when toggled to hidden', () => { diff --git a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx index 29bb8314cee..389401f1698 100644 --- a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx +++ b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx @@ -5,7 +5,6 @@ import type { JSX } from 'react'; import { FormGroup } from 'src/components/FormGroup'; import { Link } from 'src/components/Link'; -import { useFlags } from 'src/hooks/useFlags'; import type { TextFieldProps, TypographyProps } from '@linode/ui'; import type { Theme } from '@mui/material'; @@ -53,8 +52,6 @@ export const TypeToConfirm = (props: TypeToConfirmProps) => { (preferences) => preferences?.type_to_confirm ?? true ); - const { iamRbacPrimaryNavChanges } = useFlags(); - /* There is an edge case where preferences?.type_to_confirm is undefined when the user has not yet set a preference as seen in /profile/settings?preferenceEditor=true. @@ -118,17 +115,7 @@ export const TypeToConfirm = (props: TypeToConfirmProps) => { sx={{ marginTop: 1 }} > To {disableOrEnable} type-to-confirm, go to the Type-to-Confirm - section of{' '} - - {iamRbacPrimaryNavChanges ? 'Preferences' : 'My Settings'} - - . + section of Preferences. ) : null} diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index a3b79b4945d..0e6d5373daf 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -68,7 +68,6 @@ const options: { flag: keyof Flags; label: string }[] = [ label: 'IAM Limited Availability Badges', }, { flag: 'iamDelegation', label: 'IAM Delegation (Parent/Child)' }, - { flag: 'iamRbacPrimaryNavChanges', label: 'IAM Primary Nav Changes' }, { flag: 'linodeCloneFirewall', label: 'Linode Clone Firewall', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index c0bfac6e2fa..b0445bf0f18 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -226,7 +226,6 @@ export interface Flags { iam: BetaFeatureFlag; iamDelegation: BaseFeatureFlag; iamLimitedAvailabilityBadges: boolean; - iamRbacPrimaryNavChanges: boolean; ipv6Sharing: boolean; kubernetesBlackwellPlans: boolean; limitsEvolution: LimitsEvolution; diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 0f626cc9850..9e87f756a9b 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -1,5 +1,4 @@ import { useAccount, useProfile } from '@linode/queries'; -import { NotFound } from '@linode/ui'; import { Outlet, useLocation, @@ -39,7 +38,7 @@ export const AccountLanding = () => { }); const { data: account } = useAccount(); const { data: profile } = useProfile(); - const { iamRbacPrimaryNavChanges, limitsEvolution } = useFlags(); + const { limitsEvolution } = useFlags(); const { data: permissions } = usePermissions('account', [ 'make_billing_payment', @@ -101,16 +100,13 @@ export const AccountLanding = () => { React.useEffect(() => { if (match.routeId === '/account/quotas' && !showQuotasTab) { navigate({ - to: iamRbacPrimaryNavChanges ? '/quotas' : '/account/billing', + to: '/quotas', }); } - }, [match.routeId, showQuotasTab, navigate, iamRbacPrimaryNavChanges]); + }, [match.routeId, showQuotasTab, navigate]); // This is the default route for the account route, so we need to redirect to the billing tab but keep /account as legacy if (location.pathname === '/account') { - if (iamRbacPrimaryNavChanges) { - return ; - } navigate({ to: '/account/billing', }); @@ -153,7 +149,7 @@ export const AccountLanding = () => { if (!isAkamaiAccount) { landingHeaderProps.onButtonClick = () => navigate({ - to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: '/billing', search: { action: 'make-payment' }, }); } diff --git a/packages/manager/src/features/Account/AccountLogins.tsx b/packages/manager/src/features/Account/AccountLogins.tsx index 232a99f4f13..79cdae2ec1c 100644 --- a/packages/manager/src/features/Account/AccountLogins.tsx +++ b/packages/manager/src/features/Account/AccountLogins.tsx @@ -15,7 +15,6 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; -import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -44,14 +43,11 @@ const useStyles = makeStyles()((theme: Theme) => ({ const AccountLogins = () => { const { classes } = useStyles(); - const flags = useFlags(); const { data: permissions } = usePermissions('account', [ 'list_account_logins', ]); const pagination = usePaginationV2({ - currentRoute: flags?.iamRbacPrimaryNavChanges - ? '/login-history' - : '/account/login-history', + currentRoute: '/login-history', preferenceKey: 'account-logins-pagination', }); @@ -61,9 +57,7 @@ const AccountLogins = () => { order: 'desc', orderBy: 'datetime', }, - from: flags?.iamRbacPrimaryNavChanges - ? '/login-history' - : '/account/login-history', + from: '/login-history', }, preferenceKey: `${preferenceKey}-order`, }); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx index 8a283a565d7..ec2c6cb9bb7 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx @@ -73,9 +73,7 @@ export const MaintenanceTable = ({ type }: Props) => { const flags = useFlags(); const pagination = usePaginationV2({ - currentRoute: flags?.iamRbacPrimaryNavChanges - ? `/maintenance` - : `/account/maintenance`, + currentRoute: '/maintenance', preferenceKey: `${preferenceKey}-${type}`, queryParamsPrefix: type, }); @@ -86,9 +84,7 @@ export const MaintenanceTable = ({ type }: Props) => { order: 'desc', orderBy: 'status', }, - from: flags?.iamRbacPrimaryNavChanges - ? `/maintenance` - : `/account/maintenance`, + from: '/maintenance', }, preferenceKey: `${preferenceKey}-order-${type}`, prefix: type, diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index b8517a138a6..c8134935c23 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -12,7 +12,6 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; -import { useFlags } from 'src/hooks/useFlags'; import { QuotasTable } from './QuotasTable'; import { useGetLocationsForQuotaService } from './utils'; @@ -23,7 +22,6 @@ import type { Theme } from '@mui/material'; export const Quotas = () => { const navigate = useNavigate(); - const flags = useFlags(); const [selectedLocation, setSelectedLocation] = React.useState>(null); @@ -72,9 +70,7 @@ export const Quotas = () => { value: value?.value, }); navigate({ - to: flags?.iamRbacPrimaryNavChanges - ? '/quotas' - : '/account/quotas', + to: '/quotas', }); }} options={ diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx index c07e4b77882..1f69998f8c7 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx @@ -12,7 +12,6 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; -import { useFlags } from 'src/hooks/useFlags'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { QuotasIncreaseForm } from './QuotasIncreaseForm'; @@ -32,12 +31,9 @@ interface QuotasTableProps { export const QuotasTable = (props: QuotasTableProps) => { const { selectedLocation, selectedService } = props; - const flags = useFlags(); const navigate = useNavigate(); const pagination = usePaginationV2({ - currentRoute: flags?.iamRbacPrimaryNavChanges - ? '/quotas' - : '/account/quotas', + currentRoute: '/quotas', initialPage: 1, preferenceKey: 'quotas-table', }); diff --git a/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx b/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx index 4873963450c..2ca48dee716 100644 --- a/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx +++ b/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx @@ -1,27 +1,15 @@ -import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; -import { useFlags } from 'src/hooks/useFlags'; import GlobalSettings from '../Account/GlobalSettings'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; export const AccountSettingsLanding = () => { - const flags = useFlags(); - const location = useLocation(); - - if ( - !flags?.iamRbacPrimaryNavChanges && - location.pathname !== '/account/settings' - ) { - return ; - } - const landingHeaderProps: LandingHeaderProps = { title: 'Account Settings', }; diff --git a/packages/manager/src/features/Billing/BillingDetail.test.tsx b/packages/manager/src/features/Billing/BillingDetail.test.tsx index 493a6cd871d..39cd1e25b72 100644 --- a/packages/manager/src/features/Billing/BillingDetail.test.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.test.tsx @@ -1,13 +1,43 @@ import * as React from 'react'; +import { accountFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { BillingDetail } from './BillingDetail'; -describe('Account Landing', () => { +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + list_billing_payments: true, + }, + })), + useAccount: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccount: queryMocks.useAccount, + }; +}); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + +describe('Billing Detail', () => { it('should render', async () => { + const account = accountFactory.build({}); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + const { findByTestId, findByText } = renderWithTheme(, { - initialRoute: '/account/billing', + initialRoute: '/billing', }); await findByTestId('billing-detail'); await findByText('Account Balance'); diff --git a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx index 62baffaf790..a79a7d21935 100644 --- a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx +++ b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx @@ -1,5 +1,5 @@ import { useAccount, useProfile } from '@linode/queries'; -import { Navigate, useLocation, useNavigate } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -9,7 +9,6 @@ import { switchAccountSessionContext } from 'src/context/switchAccountSessionCon import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; @@ -22,9 +21,7 @@ import { BillingDetail } from '../BillingDetail'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; export const BillingLanding = () => { - const flags = useFlags(); const navigate = useNavigate(); - const location = useLocation(); const { data: account } = useAccount(); const { data: profile } = useProfile(); @@ -36,7 +33,6 @@ export const BillingLanding = () => { const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const sessionContext = React.useContext(switchAccountSessionContext); - const isIAMRbacPrimaryNavChangesEnabled = flags?.iamRbacPrimaryNavChanges; const isAkamaiAccount = account?.billing_source === 'akamai'; const isProxyUser = profile?.user_type === 'proxy'; const isChildUser = profile?.user_type === 'child'; @@ -52,13 +48,6 @@ export const BillingLanding = () => { const isReadOnly = !permissions.make_billing_payment || isChildUser; - if ( - !isIAMRbacPrimaryNavChangesEnabled && - location.pathname !== '/account/billing' - ) { - return ; - } - const canSwitchBetweenParentOrProxyAccount = isIAMDelegationEnabled ? isParentUser : (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index 15a423c635c..b979abf6117 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -187,9 +187,7 @@ export const BillingActivityPanel = React.memo((props: Props) => { const flags = useFlags(); const pagination = usePaginationV2({ - currentRoute: flags?.iamRbacPrimaryNavChanges - ? '/billing' - : '/account/billing', + currentRoute: '/billing', preferenceKey: 'billing-activity-pagination', }); const { handleOrderChange, order, orderBy } = useOrderV2({ @@ -198,7 +196,7 @@ export const BillingActivityPanel = React.memo((props: Props) => { order: 'desc', orderBy: 'amount', }, - from: flags?.iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + from: '/billing', }, preferenceKey: 'billing-activity-order', }); @@ -560,8 +558,6 @@ interface ActivityFeedItemProps extends ActivityFeedItem { export const ActivityFeedItem = React.memo((props: ActivityFeedItemProps) => { const { classes } = useStyles(); - const { iamRbacPrimaryNavChanges } = useFlags(); - const { canViewInvoiceDetails, date, @@ -594,15 +590,7 @@ export const ActivityFeedItem = React.memo((props: ActivityFeedItemProps) => { {type === 'invoice' && canViewInvoiceDetails ? ( - - {label} - + {label} ) : ( label )} diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx index 51ec9c4a297..550e4b0a934 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx @@ -39,7 +39,7 @@ describe('BillingSummary', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); within(screen.getByTestId(accountBalanceText)).getByText(/no balance/i); @@ -56,7 +56,7 @@ describe('BillingSummary', () => { /> , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); within(screen.getByTestId(accountBalanceText)).getByText(/credit/i); @@ -73,7 +73,7 @@ describe('BillingSummary', () => { /> , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); within(screen.getByTestId(accountBalanceText)).getByText(/Balance/i); @@ -86,7 +86,7 @@ describe('BillingSummary', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); expect(screen.queryByText('Promotions')).not.toBeInTheDocument(); @@ -119,7 +119,7 @@ describe('BillingSummary', () => { /> , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); const getByTextWithMarkup = withMarkup(screen.getByText); @@ -142,7 +142,7 @@ describe('BillingSummary', () => { promotions={promotions} />, { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); expect(screen.queryByText('Applies to: All')).not.toBeInTheDocument(); @@ -155,27 +155,27 @@ describe('BillingSummary', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); within(screen.getByTestId('accrued-charges-value')).getByText('$5.00'); }); it('opens "Make a Payment" drawer when "Make a payment." is clicked', async () => { - const { getByTestId, getByText } = renderWithTheme( + const { getByText, router } = renderWithTheme( , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); const paymentButton = getByText('Make a payment', { exact: false }); await userEvent.click(paymentButton); - expect(getByTestId('drawer')).toBeVisible(); - expect(getByTestId('drawer-title').textContent).toEqual('Make a Payment'); + expect(router.state.location.pathname).toBe('/billing'); + expect(router.state.location.searchStr).toBe('?action=make-payment'); }); it('does not display the "Add a promo code" button if user does not have create_promo_code permission', async () => { @@ -184,7 +184,7 @@ describe('BillingSummary', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); expect(queryByText('Add a promo code')).not.toBeInTheDocument(); @@ -206,7 +206,7 @@ describe('BillingSummary', () => { /> , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); expect(queryByText('Add a promo code')).toBeInTheDocument(); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx index c6c827abbfc..c8815233282 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx @@ -7,7 +7,6 @@ import * as React from 'react'; import { Currency } from 'src/components/Currency'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; -import { useFlags } from 'src/hooks/useFlags'; import { isWithinDays } from 'src/utilities/date'; import { BillingPaper } from '../../BillingDetail'; @@ -17,6 +16,7 @@ import { PromoDisplay } from './PromoDisplay'; import type { PaymentMethod } from '@linode/api-v4'; import type { ActivePromotion } from '@linode/api-v4/lib/account/types'; +import type { BillingSearch } from 'src/routes/billing'; interface BillingSummaryProps { balance: number; @@ -35,15 +35,13 @@ export const BillingSummary = (props: BillingSummaryProps) => { 'create_promo_code', ]); - const { iamRbacPrimaryNavChanges } = useFlags(); - const [isPromoDialogOpen, setIsPromoDialogOpen] = React.useState(false); const { data: grants } = useGrants(); const accountAccessGrant = grants?.global?.account_access; const readOnlyAccountAccess = accountAccessGrant === 'read_only'; - const url = iamRbacPrimaryNavChanges ? '/billing' : '/account/billing'; + const url = '/billing'; // If a user has a payment_due notification with a severity of critical, it indicates that they are outside of any grace period they may have and payment is due immediately. const isBalanceOutsideGracePeriod = notifications?.some( @@ -82,6 +80,10 @@ export const BillingSummary = (props: BillingSummaryProps) => { setSelectedPaymentMethod(undefined); navigate({ to: url, + search: (prev: BillingSearch) => ({ + ...prev, + action: prev.action === 'make-payment' ? undefined : prev.action, + }), }); }, [navigate]); diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx index 22887d2b41e..c11c952ed0a 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx @@ -11,7 +11,6 @@ import { getRestrictedResourceText } from 'src/features/Account/utils'; import { EDIT_BILLING_CONTACT } from 'src/features/Billing/constants'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { StyledAutorenewIcon } from 'src/features/TopMenu/NotificationMenu/NotificationMenu'; -import { useFlags } from 'src/hooks/useFlags'; import { BillingActionButton, @@ -60,7 +59,6 @@ export const ContactInformation = React.memo((props: Props) => { zip, } = props; - const { iamRbacPrimaryNavChanges } = useFlags(); const navigate = useNavigate(); const { contactDrawerOpen, focusEmail } = useSearch({ strict: false, @@ -84,7 +82,7 @@ export const ContactInformation = React.memo((props: Props) => { const handleEditDrawerOpen = () => { navigate({ - to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: '/billing', search: (prev) => ({ ...prev, action: 'edit', @@ -191,7 +189,7 @@ export const ContactInformation = React.memo((props: Props) => { {(firstName || lastName) && ( {firstName} {lastName} @@ -199,7 +197,7 @@ export const ContactInformation = React.memo((props: Props) => { {company && ( {company} @@ -259,7 +257,7 @@ export const ContactInformation = React.memo((props: Props) => { focusEmail={Boolean(focusEmail)} onClose={() => { navigate({ - to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: '/billing', search: undefined, }); }} diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx index 5b200eae0d7..53ba61167c6 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx @@ -68,7 +68,7 @@ describe('Payment Info Panel', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); @@ -81,7 +81,7 @@ describe('Payment Info Panel', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); @@ -105,7 +105,7 @@ describe('Payment Info Panel', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); @@ -122,7 +122,7 @@ describe('Payment Info Panel', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); @@ -139,7 +139,7 @@ describe('Payment Info Panel', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); @@ -168,7 +168,7 @@ describe('Payment Info Panel', () => { /> , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); @@ -195,7 +195,7 @@ describe('Payment Info Panel', () => { , { - initialRoute: '/account/billing', + initialRoute: '/billing', } ); diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx index c7919c408bb..7d1faa87877 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx @@ -11,7 +11,6 @@ import { getRestrictedResourceText } from 'src/features/Account/utils'; import { PaymentMethods } from 'src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods'; import { ADD_PAYMENT_METHOD } from 'src/features/Billing/constants'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; -import { useFlags } from 'src/hooks/useFlags'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { @@ -24,7 +23,7 @@ import { AddPaymentMethodDrawer } from './AddPaymentMethodDrawer/AddPaymentMetho import type { Profile } from '@linode/api-v4'; import type { PaymentMethod } from '@linode/api-v4/lib/account'; import type { APIError } from '@linode/api-v4/lib/types'; - +import type { BillingSearch } from 'src/routes/billing'; interface Props { error?: APIError[] | null; isAkamaiCustomer: boolean; @@ -35,9 +34,8 @@ interface Props { const PaymentInformation = (props: Props) => { const { error, isAkamaiCustomer, loading, paymentMethods, profile } = props; - const { iamRbacPrimaryNavChanges } = useFlags(); const search = useSearch({ - from: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + from: '/billing', }); const [addDrawerOpen, setAddDrawerOpen] = React.useState(false); const navigate = useNavigate(); @@ -81,7 +79,11 @@ const PaymentInformation = (props: Props) => { const closeAddDrawer = React.useCallback(() => { setAddDrawerOpen(false); navigate({ - to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: '/billing', + search: (prev: BillingSearch) => ({ + ...prev, + action: prev.action === 'add-payment-method' ? undefined : prev.action, + }), }); }, [navigate]); @@ -122,9 +124,7 @@ const PaymentInformation = (props: Props) => { disableTouchRipple onClick={() => navigate({ - to: iamRbacPrimaryNavChanges - ? '/billing' - : '/account/billing', + to: '/billing', search: (prev) => ({ ...prev, action: 'add-payment-method', diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx index 2f960224287..26d484a8dec 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx @@ -37,9 +37,7 @@ export const InvoiceDetail = () => { const flags = useFlags(); const { invoiceId } = useParams({ - from: flags?.iamRbacPrimaryNavChanges - ? '/billing/invoices/$invoiceId' - : '/account/billing/invoices/$invoiceId', + from: '/billing/invoices/$invoiceId', }); const theme = useTheme(); const { data: permissions } = usePermissions('account', [ @@ -150,11 +148,7 @@ export const InvoiceDetail = () => { { const navigate = useNavigate(); - const flags = useFlags(); const { error, isPending, mutateAsync: createTransfer } = useCreateTransfer(); const queryClient = useQueryClient(); @@ -79,9 +77,7 @@ export const EntityTransfersCreate = () => { queryKey: [entityTransfersQueryKey], }); navigate({ - to: flags?.iamRbacPrimaryNavChanges - ? '/service-transfers' - : '/account/service-transfers', + to: '/service-transfers', state: (prev) => ({ ...prev, transfer }), }); }, @@ -96,7 +92,7 @@ export const EntityTransfersCreate = () => { crumbOverrides: [ { label: 'Service Transfers', - position: flags?.iamRbacPrimaryNavChanges ? 1 : 2, + position: 1, }, ], labelOptions: { noCap: true }, diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx index 5fba61fe051..e3bab7c04ed 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx @@ -11,7 +11,6 @@ import * as React from 'react'; import { SelectableTableRow } from 'src/components/SelectableTableRow/SelectableTableRow'; import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; -import { useFlags } from 'src/hooks/useFlags'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { extendType } from 'src/utilities/extendType'; @@ -37,14 +36,11 @@ export const LinodeTransferTable = React.memo((props: Props) => { selectedLinodes, disabled, } = props; - const flags = useFlags(); const [searchText, setSearchText] = React.useState(''); const pagination = usePaginationV2({ - currentRoute: flags?.iamRbacPrimaryNavChanges - ? '/service-transfers/create' - : '/account/service-transfers/create', + currentRoute: '/service-transfers/create', initialPage: 1, preferenceKey: 'linode-transfer-table', }); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx index c25ead2b8d2..1d8222b5564 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/EntityTransfersLanding.tsx @@ -4,7 +4,6 @@ import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; -import { useFlags } from 'src/hooks/useFlags'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { TransfersTable } from '../TransfersTable'; @@ -21,17 +20,14 @@ export const EntityTransfersLanding = () => { const location = useLocation(); const navigate = useNavigate(); - const flags = useFlags(); - const url = flags?.iamRbacPrimaryNavChanges - ? '/service-transfers' - : '/account/service-transfers'; + const URL = '/service-transfers'; const handleCloseSuccessDialog = () => { setSuccessDialogOpen(false); setTransfer(undefined); navigate({ - to: url, + to: URL, state: (prev) => ({ ...prev, transfer: undefined }), }); }; @@ -53,19 +49,19 @@ export const EntityTransfersLanding = () => { const paginationPendingTransfers = usePaginationV2({ initialPage, - currentRoute: url, + currentRoute: URL, preferenceKey: pendingTransfersTablePreferenceKey, queryParamsPrefix: pendingTransfersTablePreferenceKey, }); const paginationReceivedTransfers = usePaginationV2({ initialPage, - currentRoute: url, + currentRoute: URL, preferenceKey: receivedTransfersTablePreferenceKey, queryParamsPrefix: receivedTransfersTablePreferenceKey, }); const paginationSentTransfers = usePaginationV2({ initialPage, - currentRoute: url, + currentRoute: URL, preferenceKey: sentTransfersTablePreferenceKey, queryParamsPrefix: sentTransfersTablePreferenceKey, }); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx index 4171b98e4e3..42a768f3616 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.tsx @@ -1,8 +1,6 @@ import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useFlags } from 'src/hooks/useFlags'; - import { ConfirmTransferDialog } from './ConfirmTransferDialog'; import { StyledLabelWrapperGrid, @@ -24,7 +22,6 @@ interface Props { export const TransferControls = React.memo((props: Props) => { const { permissions } = props; - const flags = useFlags(); const [token, setToken] = React.useState(''); const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false); @@ -42,9 +39,7 @@ export const TransferControls = React.memo((props: Props) => { const handleCreateTransfer = () => navigate({ - to: flags?.iamRbacPrimaryNavChanges - ? '/service-transfers/create' - : '/account/service-transfers/create', + to: '/service-transfers/create', }); return ( diff --git a/packages/manager/src/features/GlobalNotifications/CreditCardExpiredBanner.tsx b/packages/manager/src/features/GlobalNotifications/CreditCardExpiredBanner.tsx index 2439451324f..e319110e15a 100644 --- a/packages/manager/src/features/GlobalNotifications/CreditCardExpiredBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/CreditCardExpiredBanner.tsx @@ -4,12 +4,10 @@ import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; -import { useFlags } from 'src/hooks/useFlags'; import { isCreditCardExpired } from 'src/utilities/creditCard'; export const CreditCardExpiredBanner = () => { const navigate = useNavigate(); - const flags = useFlags(); const { data: account } = useAccount(); @@ -33,9 +31,7 @@ export const CreditCardExpiredBanner = () => { buttonType="primary" onClick={() => navigate({ - to: flags?.iamRbacPrimaryNavChanges - ? '/billing' - : '/account/billing', + to: '/billing', }) } > diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index a31a827bcf6..f75a6513c98 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -13,8 +13,6 @@ import { useNavigate } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useFlags } from 'src/hooks/useFlags'; - import { StyledGrid } from './EmailBounce.styles'; import type { Theme } from '@mui/material/styles'; @@ -28,7 +26,6 @@ export const EmailBounceNotificationSection = React.memo(() => { const { data: profile } = useProfile(); const { mutateAsync: updateProfile } = useMutateProfile(); const { data: notifications } = useNotificationsQuery(); - const flags = useFlags(); const navigate = useNavigate(); @@ -57,9 +54,7 @@ export const EmailBounceNotificationSection = React.memo(() => { navigate({ - to: flags?.iamRbacPrimaryNavChanges - ? '/billing' - : '/account/billing', + to: '/billing', search: { contactDrawerOpen: true, focusEmail: true }, }) } diff --git a/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx b/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx index daf7e336b1c..f46f027477a 100644 --- a/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/TaxCollectionBanner.tsx @@ -56,7 +56,7 @@ export const TaxCollectionBanner = () => { buttonType="primary" onClick={() => navigate({ - to: flags?.iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: '/billing', search: { action: 'edit' }, }) } diff --git a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx index 64c56618cd6..1ca9509c0ca 100644 --- a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx +++ b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx @@ -1,26 +1,14 @@ -import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; -import { useFlags } from 'src/hooks/useFlags'; import AccountLogins from '../Account/AccountLogins'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; export const LoginHistoryLanding = () => { - const flags = useFlags(); - const location = useLocation(); - - if ( - !flags?.iamRbacPrimaryNavChanges && - location.pathname !== '/account/login-history' - ) { - return ; - } - const landingHeaderProps: LandingHeaderProps = { title: 'Login History', }; diff --git a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx index 5e8625fd98e..deff1eba5a5 100644 --- a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx +++ b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx @@ -1,26 +1,14 @@ -import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; -import { useFlags } from 'src/hooks/useFlags'; import { default as AccountMaintenanceLanding } from '../Account/Maintenance/MaintenanceLanding'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; export const MaintenanceLanding = () => { - const flags = useFlags(); - const location = useLocation(); - - if ( - !flags?.iamRbacPrimaryNavChanges && - location.pathname !== '/account/maintenance' - ) { - return ; - } - const landingHeaderProps: LandingHeaderProps = { title: 'Maintenance', }; diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/hooks/useGetObjUsagePerEndpoint.ts b/packages/manager/src/features/ObjectStorage/SummaryLanding/hooks/useGetObjUsagePerEndpoint.ts index b0a56513c5d..ead2923c296 100644 --- a/packages/manager/src/features/ObjectStorage/SummaryLanding/hooks/useGetObjUsagePerEndpoint.ts +++ b/packages/manager/src/features/ObjectStorage/SummaryLanding/hooks/useGetObjUsagePerEndpoint.ts @@ -1,5 +1,4 @@ import { quotaQueries, useQueries, useQuotasQuery } from '@linode/queries'; -import { useFlags } from 'launchdarkly-react-client-sdk'; import * as React from 'react'; import { getQuotasFilters } from 'src/features/Account/Quotas/utils'; @@ -10,12 +9,8 @@ import type { Filter } from '@linode/api-v4'; const SERVICE = 'object-storage'; export const useGetObjUsagePerEndpoint = (selectedLocation: string) => { - const flags = useFlags(); - const pagination = usePaginationV2({ - currentRoute: flags?.iamRbacPrimaryNavChanges - ? '/quotas' - : '/account/quotas', + currentRoute: '/quotas', initialPage: 1, preferenceKey: 'quotas-table', }); diff --git a/packages/manager/src/features/Profile/Profile.tsx b/packages/manager/src/features/Profile/Profile.tsx index 1825a7b8929..30440e9bd9c 100644 --- a/packages/manager/src/features/Profile/Profile.tsx +++ b/packages/manager/src/features/Profile/Profile.tsx @@ -8,13 +8,11 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; -import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; export const Profile = () => { const location = useLocation(); const navigate = useNavigate(); - const { iamRbacPrimaryNavChanges } = useFlags(); const { tabs, handleTabChange, tabIndex } = useTabs([ { @@ -42,14 +40,13 @@ export const Profile = () => { title: 'OAuth Apps', }, { - to: iamRbacPrimaryNavChanges - ? `/profile/preferences` - : `/profile/referrals`, - title: iamRbacPrimaryNavChanges ? 'Preferences' : 'Referrals', + to: `/profile/preferences`, + + title: 'Preferences', }, { - to: iamRbacPrimaryNavChanges ? `/profile/referrals` : `/profile/settings`, - title: iamRbacPrimaryNavChanges ? 'Referrals' : 'My Settings', + to: `/profile/referrals`, + title: 'Referrals', }, ]); diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index 6174d15904e..7d18fcbe4d7 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -3,7 +3,6 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { useFlags } from 'src/hooks/useFlags'; import { MaskSensitiveData } from './MaskSensitiveData'; import { Notifications } from './Notifications'; @@ -14,29 +13,22 @@ import { TypeToConfirm } from './TypeToConfirm'; export const ProfileSettings = () => { const navigate = useNavigate(); - const { iamRbacPrimaryNavChanges } = useFlags(); const { preferenceEditor } = useSearch({ - from: iamRbacPrimaryNavChanges - ? '/profile/preferences' - : '/profile/settings', + from: '/profile/preferences', }); const isPreferenceEditorOpen = !!preferenceEditor; const handleClosePreferenceEditor = () => { navigate({ - to: iamRbacPrimaryNavChanges - ? '/profile/preferences' - : '/profile/settings', + to: '/profile/preferences', search: { preferenceEditor: undefined }, }); }; return ( <> - + diff --git a/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts b/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts index 33e561dae36..90039af7e7e 100644 --- a/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts +++ b/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts @@ -2,14 +2,6 @@ import { createLazyRoute } from '@tanstack/react-router'; import { ProfileSettings } from './Settings'; -export const settingsLazyRoute = createLazyRoute('/profile/settings')({ - component: ProfileSettings, -}); - -/** - * @todo As part of the IAM Primary Nav flag (iamRbacPrimaryNavChanges) cleanup, /profile/settings will be removed. - * Adding the lazy route in this file will also require the necessary cleanup work, such as renaming the file and removing settingsLazyRoute(/profile/settings), as part of the flag cleanup. - */ export const preferencesLazyRoute = createLazyRoute('/profile/preferences')({ component: ProfileSettings, }); diff --git a/packages/manager/src/features/Quotas/QuotasLanding.tsx b/packages/manager/src/features/Quotas/QuotasLanding.tsx index 75aa29dcdb2..f695d761c3a 100644 --- a/packages/manager/src/features/Quotas/QuotasLanding.tsx +++ b/packages/manager/src/features/Quotas/QuotasLanding.tsx @@ -1,28 +1,14 @@ -import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; -import { useFlags } from 'src/hooks/useFlags'; import { Quotas } from '../Account/Quotas/Quotas'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; export const QuotasLanding = () => { - const flags = useFlags(); - const location = useLocation(); - - const isIAMRbacPrimaryNavChangesEnabled = flags?.iamRbacPrimaryNavChanges; - - if ( - !isIAMRbacPrimaryNavChangesEnabled && - location.pathname !== '/account/quotas' - ) { - return ; - } - const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { pathname: '/quotas', diff --git a/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx b/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx index 1dfef2dcfe1..ebd8a905a66 100644 --- a/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx +++ b/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx @@ -1,27 +1,15 @@ -import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; -import { useFlags } from 'src/hooks/useFlags'; import { EntityTransfersLanding } from '../EntityTransfers/EntityTransfersLanding/EntityTransfersLanding'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; export const ServiceTransfersLanding = () => { - const flags = useFlags(); - const location = useLocation(); - - if ( - !flags?.iamRbacPrimaryNavChanges && - location.pathname !== '/account/service-transfers' - ) { - return ; - } - const landingHeaderProps: LandingHeaderProps = { title: 'Service Transfers', }; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index 0ee7f5b8ef1..d1a3e9035bd 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -46,11 +46,7 @@ interface MenuLink { export const UserMenuPopover = (props: UserMenuPopoverProps) => { const { anchorEl, isDrawerOpen, onClose, onDrawerOpen } = props; const sessionContext = React.useContext(switchAccountSessionContext); - const { - iamRbacPrimaryNavChanges, - limitsEvolution, - iamLimitedAvailabilityBadges, - } = useFlags(); + const { limitsEvolution, iamLimitedAvailabilityBadges } = useFlags(); const theme = useTheme(); const { data: account } = useAccount(); @@ -87,14 +83,12 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { }, { display: 'OAuth Apps', to: '/profile/clients' }, { - display: iamRbacPrimaryNavChanges ? 'Preferences' : 'Referrals', - to: iamRbacPrimaryNavChanges - ? '/profile/preferences' - : '/profile/referrals', + display: 'Preferences', + to: '/profile/preferences', }, { - display: iamRbacPrimaryNavChanges ? 'Referrals' : 'My Settings', - to: iamRbacPrimaryNavChanges ? '/profile/referrals' : '/profile/settings', + display: 'Referrals', + to: '/profile/referrals', }, { display: 'Log Out', to: '/logout' }, ]; @@ -119,57 +113,37 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { () => [ { display: 'Billing', - to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: '/billing', }, { - display: - iamRbacPrimaryNavChanges && isIAMEnabled - ? 'Identity & Access' - : 'Users & Grants', - to: - iamRbacPrimaryNavChanges && isIAMEnabled - ? '/iam' - : iamRbacPrimaryNavChanges && !isIAMEnabled - ? '/users' - : '/account/users', - isBeta: iamRbacPrimaryNavChanges && isIAMEnabled && isIAMBeta, + display: isIAMEnabled ? 'Identity & Access' : 'Users & Grants', + to: isIAMEnabled ? '/iam' : '/users', + isBeta: isIAMEnabled && isIAMBeta, isNew: isIAMEnabled && !isIAMBeta && iamLimitedAvailabilityBadges, }, { display: 'Quotas', hide: !limitsEvolution?.enabled, - to: iamRbacPrimaryNavChanges ? '/quotas' : '/account/quotas', + to: '/quotas', }, { display: 'Login History', - to: iamRbacPrimaryNavChanges - ? '/login-history' - : '/account/login-history', + to: '/login-history', }, { display: 'Service Transfers', - to: iamRbacPrimaryNavChanges - ? '/service-transfers' - : '/account/service-transfers', + to: '/service-transfers', }, { display: 'Maintenance', - to: iamRbacPrimaryNavChanges ? '/maintenance' : '/account/maintenance', + to: '/maintenance', }, { - display: iamRbacPrimaryNavChanges ? 'Account Settings' : 'Settings', - to: iamRbacPrimaryNavChanges - ? '/account-settings' - : '/account/settings', + display: 'Account Settings', + to: '/account-settings', }, ], - [ - isIAMEnabled, - iamRbacPrimaryNavChanges, - limitsEvolution, - iamLimitedAvailabilityBadges, - isIAMBeta, - ] + [isIAMEnabled, limitsEvolution, iamLimitedAvailabilityBadges, isIAMBeta] ); const renderLink = (link: MenuLink) => { @@ -278,9 +252,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { - - {iamRbacPrimaryNavChanges ? 'Administration' : 'Account'} - + Administration theme.tokens.spacing.S8} diff --git a/packages/manager/src/features/Users/CreateUserDrawer.tsx b/packages/manager/src/features/Users/CreateUserDrawer.tsx index aefbe6d8ab6..f94075cd4c7 100644 --- a/packages/manager/src/features/Users/CreateUserDrawer.tsx +++ b/packages/manager/src/features/Users/CreateUserDrawer.tsx @@ -10,7 +10,6 @@ import { import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useFlags } from 'src/hooks/useFlags'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -19,7 +18,6 @@ import type { APIError } from '@linode/api-v4/lib/types'; import type { UseNavigateResult } from '@tanstack/react-router'; interface CreateUserDrawerProps { - iamRbacPrimaryNavChanges?: boolean; navigate: UseNavigateResult<'/account/users'>; onClose: () => void; open: boolean; @@ -39,14 +37,8 @@ const withNavigation = ( ) => { return (props: CreateUserDrawerProps) => { const navigate = useNavigate(); - const { iamRbacPrimaryNavChanges } = useFlags(); - return ( - - ); + + return ; }; }; @@ -97,7 +89,7 @@ class CreateUserDrawerComponent extends React.Component< }; onSubmit = () => { - const { onClose, refetch, navigate, iamRbacPrimaryNavChanges } = this.props; + const { onClose, refetch, navigate } = this.props; const { email, restricted, username } = this.state; this.setState({ errors: [], submitting: true }); createUser({ email, restricted, username }) @@ -106,9 +98,7 @@ class CreateUserDrawerComponent extends React.Component< onClose(); if (user.restricted) { navigate({ - to: iamRbacPrimaryNavChanges - ? '/users/$username/permissions' - : '/account/users/$username/permissions', + to: '/users/$username/permissions', params: { username: user.username }, }); } diff --git a/packages/manager/src/features/Users/UserDetail.tsx b/packages/manager/src/features/Users/UserDetail.tsx index 8f0e04fdb3e..fd902ba5d3a 100644 --- a/packages/manager/src/features/Users/UserDetail.tsx +++ b/packages/manager/src/features/Users/UserDetail.tsx @@ -9,18 +9,14 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; -import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import UserPermissions from './UserPermissions'; import { UserProfile } from './UserProfile/UserProfile'; export const UserDetail = () => { - const { iamRbacPrimaryNavChanges } = useFlags(); const { username } = useParams({ - from: iamRbacPrimaryNavChanges - ? '/users/$username' - : '/account/users/$username', + from: '/users/$username', }); const location = useLocation(); @@ -32,15 +28,11 @@ export const UserDetail = () => { const { tabs, handleTabChange, tabIndex } = useTabs([ { - to: iamRbacPrimaryNavChanges - ? '/users/$username/profile' - : '/account/users/$username/profile', + to: '/users/$username/profile', title: 'User Profile', }, { - to: iamRbacPrimaryNavChanges - ? '/users/$username/permissions' - : '/account/users/$username/permissions', + to: '/users/$username/permissions', title: 'User Permissions', }, ]); diff --git a/packages/manager/src/features/Users/UserPermissionsEntitySection.tsx b/packages/manager/src/features/Users/UserPermissionsEntitySection.tsx index b1a3f0e7230..64e481971fc 100644 --- a/packages/manager/src/features/Users/UserPermissionsEntitySection.tsx +++ b/packages/manager/src/features/Users/UserPermissionsEntitySection.tsx @@ -18,7 +18,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { grantTypeMap } from 'src/features/Account/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import type { Grant, GrantLevel, GrantType } from '@linode/api-v4/lib/account'; @@ -35,12 +34,8 @@ interface Props { export const UserPermissionsEntitySection = React.memo( ({ entity, entitySetAllTo, grants, setGrantTo, showHeading }: Props) => { const theme: Theme = useTheme(); - const { iamRbacPrimaryNavChanges } = useFlags(); - const pagination = usePaginationV2({ - currentRoute: iamRbacPrimaryNavChanges - ? '/users/$username/permissions' - : '/account/users/$username/permissions', + currentRoute: '/users/$username/permissions', initialPage: 1, preferenceKey: 'user-permissions-entity-section', }); diff --git a/packages/manager/src/features/Users/UserProfile/DeleteUserPanel.tsx b/packages/manager/src/features/Users/UserProfile/DeleteUserPanel.tsx index 1ad3d11b9d3..78683e086d8 100644 --- a/packages/manager/src/features/Users/UserProfile/DeleteUserPanel.tsx +++ b/packages/manager/src/features/Users/UserProfile/DeleteUserPanel.tsx @@ -4,7 +4,6 @@ import { useNavigate } from '@tanstack/react-router'; import React, { useState } from 'react'; import { PARENT_USER } from 'src/features/Account/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { UserDeleteConfirmationDialog } from '../UserDeleteConfirmationDialog'; @@ -20,8 +19,6 @@ export const DeleteUserPanel = ({ user }: Props) => { const { data: profile } = useProfile(); - const { iamRbacPrimaryNavChanges } = useFlags(); - const isProxyUserProfile = user.user_type === 'proxy'; const tooltipText = @@ -52,7 +49,7 @@ export const DeleteUserPanel = ({ user }: Props) => { onClose={() => setIsDeleteDialogOpen(false)} onSuccess={() => navigate({ - to: iamRbacPrimaryNavChanges ? '/users' : '/account/users', + to: '/users', }) } open={isDeleteDialogOpen} diff --git a/packages/manager/src/features/Users/UserProfile/UserProfile.tsx b/packages/manager/src/features/Users/UserProfile/UserProfile.tsx index cdd4eb87c82..d30937d52a3 100644 --- a/packages/manager/src/features/Users/UserProfile/UserProfile.tsx +++ b/packages/manager/src/features/Users/UserProfile/UserProfile.tsx @@ -4,7 +4,6 @@ import { useParams } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { useFlags } from 'src/hooks/useFlags'; import { DeleteUserPanel } from './DeleteUserPanel'; import { UserDetailsPanel } from './UserDetailsPanel'; @@ -12,12 +11,8 @@ import { UserEmailPanel } from './UserEmailPanel'; import { UsernamePanel } from './UsernamePanel'; export const UserProfile = () => { - const { iamRbacPrimaryNavChanges } = useFlags(); - const { username } = useParams({ - from: iamRbacPrimaryNavChanges - ? '/users/$username' - : '/account/users/$username', + from: '/users/$username', }); const { data: user, error, isLoading } = useAccountUser(username ?? ''); diff --git a/packages/manager/src/features/Users/UserProfile/UsernamePanel.tsx b/packages/manager/src/features/Users/UserProfile/UsernamePanel.tsx index 316f4b026ad..8e40745e246 100644 --- a/packages/manager/src/features/Users/UserProfile/UsernamePanel.tsx +++ b/packages/manager/src/features/Users/UserProfile/UsernamePanel.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; -import { useFlags } from 'src/hooks/useFlags'; import type { User } from '@linode/api-v4'; @@ -19,7 +18,6 @@ interface Props { export const UsernamePanel = ({ user }: Props) => { const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); - const { iamRbacPrimaryNavChanges } = useFlags(); const isProxyUserProfile = user?.user_type === 'proxy'; @@ -42,9 +40,7 @@ export const UsernamePanel = ({ user }: Props) => { // Because the username changed, we need to update the username in the URL navigate({ - to: iamRbacPrimaryNavChanges - ? '/users/$username' - : '/account/users/$username', + to: '/users/$username', params: { username: user.username }, }); diff --git a/packages/manager/src/features/Users/UsersActionMenu.tsx b/packages/manager/src/features/Users/UsersActionMenu.tsx index 6b73fc616cd..b4ae0409786 100644 --- a/packages/manager/src/features/Users/UsersActionMenu.tsx +++ b/packages/manager/src/features/Users/UsersActionMenu.tsx @@ -6,7 +6,6 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useFlags } from 'src/hooks/useFlags'; import type { Theme } from '@mui/material/styles'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -21,7 +20,6 @@ export const UsersActionMenu = ({ isProxyUser, onDelete, username }: Props) => { const navigate = useNavigate(); const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); - const { iamRbacPrimaryNavChanges } = useFlags(); const { data: profile } = useProfile(); const profileUsername = profile?.username; @@ -30,9 +28,7 @@ export const UsersActionMenu = ({ isProxyUser, onDelete, username }: Props) => { { onClick: () => { navigate({ - to: iamRbacPrimaryNavChanges - ? '/users/$username/permissions' - : '/account/users/$username/permissions', + to: '/users/$username/permissions', params: { username }, }); }, @@ -44,9 +40,7 @@ export const UsersActionMenu = ({ isProxyUser, onDelete, username }: Props) => { { onClick: () => { navigate({ - to: iamRbacPrimaryNavChanges - ? '/users/$username' - : '/account/users/$username', + to: '/users/$username', params: { username }, }); }, @@ -55,9 +49,7 @@ export const UsersActionMenu = ({ isProxyUser, onDelete, username }: Props) => { { onClick: () => { navigate({ - to: iamRbacPrimaryNavChanges - ? '/users/$username/permissions' - : '/account/users/$username/permissions', + to: '/users/$username/permissions', params: { username }, }); }, diff --git a/packages/manager/src/features/Users/UsersLanding.tsx b/packages/manager/src/features/Users/UsersLanding.tsx index 88980591137..3609d727771 100644 --- a/packages/manager/src/features/Users/UsersLanding.tsx +++ b/packages/manager/src/features/Users/UsersLanding.tsx @@ -10,7 +10,6 @@ import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFoot import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { PARENT_USER } from 'src/features/Account/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; @@ -34,11 +33,9 @@ export const UsersLanding = () => { const matchesSmDown = useMediaQuery(theme.breakpoints.down('sm')); const matchesLgUp = useMediaQuery(theme.breakpoints.up('lg')); - const { iamRbacPrimaryNavChanges } = useFlags(); - const pagination = usePaginationV2({ initialPage: 1, - currentRoute: iamRbacPrimaryNavChanges ? '/users' : '/account/users', + currentRoute: '/users', preferenceKey: 'account-users-pagination', }); const order = useOrderV2({ @@ -47,7 +44,7 @@ export const UsersLanding = () => { order: 'desc', orderBy: 'username', }, - from: iamRbacPrimaryNavChanges ? '/users' : '/account/users', + from: '/users', }, preferenceKey: 'account-users-order', }); diff --git a/packages/manager/src/features/UsersAndGrants/UsersAndGrants.tsx b/packages/manager/src/features/UsersAndGrants/UsersAndGrants.tsx index 2730b045886..cafeb74b62b 100644 --- a/packages/manager/src/features/UsersAndGrants/UsersAndGrants.tsx +++ b/packages/manager/src/features/UsersAndGrants/UsersAndGrants.tsx @@ -1,28 +1,14 @@ -import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; -import { useFlags } from 'src/hooks/useFlags'; import { UsersLanding } from '../Users/UsersLanding'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; export const UsersAndGrants = () => { - const flags = useFlags(); - const location = useLocation(); - - const isIAMRbacPrimaryNavChangesEnabled = flags?.iamRbacPrimaryNavChanges; - - if ( - !isIAMRbacPrimaryNavChangesEnabled && - location.pathname !== '/account/users' - ) { - return ; - } - const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { pathname: '/users', diff --git a/packages/manager/src/routes/account/index.ts b/packages/manager/src/routes/account/index.ts index 49f8f0b1f9b..460b844465b 100644 --- a/packages/manager/src/routes/account/index.ts +++ b/packages/manager/src/routes/account/index.ts @@ -31,13 +31,11 @@ const accountBillingRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: 'billing', validateSearch: (search: AccountBillingSearch) => search, - beforeLoad: ({ context, params }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/billing`, - replace: true, - }); - } + beforeLoad: () => { + throw redirect({ + to: `/billing`, + replace: true, + }); }, }).lazy(() => import('src/features/Billing/billingDetailLazyRoute').then( @@ -58,10 +56,6 @@ const accountUsersRoute = createRoute({ if (isIAMEnabled) { throw redirect({ to: '/iam/users' }); } - - if (context?.flags?.iamRbacPrimaryNavChanges && !isIAMEnabled) { - throw redirect({ to: '/users' }); - } }, }).lazy(() => import('src/features/Users/usersLandingLazyRoute').then( @@ -72,13 +66,11 @@ const accountUsersRoute = createRoute({ const accountQuotasRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/quotas', - beforeLoad: ({ context }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/quotas`, - replace: true, - }); - } + beforeLoad: () => { + throw redirect({ + to: `/quotas`, + replace: true, + }); }, }).lazy(() => import('src/features/Account/Quotas/quotasLazyRoute').then( @@ -90,12 +82,10 @@ const accountLoginHistoryRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/login-history', beforeLoad: ({ context }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/login-history`, - replace: true, - }); - } + throw redirect({ + to: `/login-history`, + replace: true, + }); }, }).lazy(() => import('src/features/Account/accountLoginsLazyRoute').then( @@ -106,13 +96,11 @@ const accountLoginHistoryRoute = createRoute({ const accountServiceTransfersRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/service-transfers', - beforeLoad: ({ context }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/service-transfers`, - replace: true, - }); - } + beforeLoad: () => { + throw redirect({ + to: `/service-transfers`, + replace: true, + }); }, }).lazy(() => import( @@ -123,13 +111,11 @@ const accountServiceTransfersRoute = createRoute({ const accountMaintenanceRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/maintenance', - beforeLoad: ({ context }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/maintenance`, - replace: true, - }); - } + beforeLoad: () => { + throw redirect({ + to: `/maintenance`, + replace: true, + }); }, }).lazy(() => import('src/features/Account/Maintenance/maintenanceLandingLazyRoute').then( @@ -140,13 +126,11 @@ const accountMaintenanceRoute = createRoute({ const accountSettingsRoute = createRoute({ getParentRoute: () => accountTabsRoute, path: '/settings', - beforeLoad: ({ context }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account-settings`, - replace: true, - }); - } + beforeLoad: () => { + throw redirect({ + to: `/account-settings`, + replace: true, + }); }, }).lazy(() => import('src/features/Account/globalSettingsLazyRoute').then( @@ -182,18 +166,16 @@ const accountUsersUsernameRoute = createRoute({ }); } - if (context?.flags?.iamRbacPrimaryNavChanges && !isIAMEnabled) { - const url = location.pathname.endsWith('/profile') - ? '/users/$username/profile' - : location.pathname.endsWith('/permissions') - ? '/users/$username/permissions' - : '/users/$username'; - throw redirect({ - to: url, - params: { username }, - replace: true, - }); - } + const url = location.pathname.endsWith('/profile') + ? '/users/$username/profile' + : location.pathname.endsWith('/permissions') + ? '/users/$username/permissions' + : '/users/$username'; + throw redirect({ + to: url, + params: { username }, + replace: true, + }); }, }).lazy(() => import('src/features/Users/userDetailLazyRoute').then( @@ -225,14 +207,12 @@ const accountInvoiceDetailsRoute = createRoute({ invoiceId: Number(params.invoiceId), }), path: 'billing/invoices/$invoiceId', - beforeLoad: ({ context, params }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/billing/invoices/$invoiceId`, - params: { invoiceId: params.invoiceId }, - replace: true, - }); - } + beforeLoad: ({ params }) => { + throw redirect({ + to: `/billing/invoices/$invoiceId`, + params: { invoiceId: params.invoiceId }, + replace: true, + }); }, }).lazy(() => import('src/features/Billing/InvoiceDetail/InvoiceDetail').then( @@ -243,13 +223,11 @@ const accountInvoiceDetailsRoute = createRoute({ const accountEntityTransfersCreateRoute = createRoute({ getParentRoute: () => accountRoute, path: 'service-transfers/create', - beforeLoad: ({ context }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/service-transfers/create`, - replace: true, - }); - } + beforeLoad: () => { + throw redirect({ + to: `/service-transfers/create`, + replace: true, + }); }, }).lazy(() => import( diff --git a/packages/manager/src/routes/accountSettings/index.ts b/packages/manager/src/routes/accountSettings/index.ts index 4660ed2b201..deef8ecb18f 100644 --- a/packages/manager/src/routes/accountSettings/index.ts +++ b/packages/manager/src/routes/accountSettings/index.ts @@ -22,14 +22,6 @@ const accountSettingsCatchAllRoute = createRoute({ const accountSettingsIndexRoute = createRoute({ getParentRoute: () => accountSettingsRoute, path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/settings`, - replace: true, - }); - } - }, }).lazy(() => import('src/features/AccountSettings/accountSettingsLandingLazyRoute').then( (m) => m.accountSettingsLandingLazyRoute diff --git a/packages/manager/src/routes/billing/index.ts b/packages/manager/src/routes/billing/index.ts index 78705a53a5b..54fcbd6347b 100644 --- a/packages/manager/src/routes/billing/index.ts +++ b/packages/manager/src/routes/billing/index.ts @@ -3,7 +3,7 @@ import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { BillingRoute } from './BillingRoute'; -interface BillingSearch { +export interface BillingSearch { action?: 'add-payment-method' | 'edit' | 'make-payment'; contactDrawerOpen?: boolean; focusEmail?: boolean; @@ -30,14 +30,6 @@ const billingCatchAllRoute = createRoute({ const billingIndexRoute = createRoute({ getParentRoute: () => billingRoute, path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/billing`, - replace: true, - }); - } - }, }).lazy(() => import('src/features/Billing/BillingLanding/billingLandingLazyRoute').then( (m) => m.billingLandingLazyRoute @@ -50,15 +42,6 @@ const billingInvoiceDetailsRoute = createRoute({ invoiceId: Number(params.invoiceId), }), path: 'invoices/$invoiceId', - beforeLoad: ({ context, params }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/billing/invoices/$invoiceId`, - params: { invoiceId: params.invoiceId }, - replace: true, - }); - } - }, }).lazy(() => import('src/features/Billing/InvoiceDetail/InvoiceDetail').then( (m) => m.invoiceDetailLazyRoute diff --git a/packages/manager/src/routes/loginHistory/index.ts b/packages/manager/src/routes/loginHistory/index.ts index 4fcb408ba49..a99921f68b0 100644 --- a/packages/manager/src/routes/loginHistory/index.ts +++ b/packages/manager/src/routes/loginHistory/index.ts @@ -22,14 +22,6 @@ const loginHistoryCatchAllRoute = createRoute({ const loginHistoryIndexRoute = createRoute({ getParentRoute: () => loginHistoryRoute, path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/login-history`, - replace: true, - }); - } - }, }).lazy(() => import('src/features/LoginHistory/loginHistoryLandingLazyRoute').then( (m) => m.loginHistoryLandingLazyRoute diff --git a/packages/manager/src/routes/maintenance/index.ts b/packages/manager/src/routes/maintenance/index.ts index 7f565775e35..4c5b841aafe 100644 --- a/packages/manager/src/routes/maintenance/index.ts +++ b/packages/manager/src/routes/maintenance/index.ts @@ -22,14 +22,6 @@ const maintenanceCatchAllRoute = createRoute({ const maintenanceIndexRoute = createRoute({ getParentRoute: () => maintenanceRoute, path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/maintenance`, - replace: true, - }); - } - }, }).lazy(() => import('src/features/Maintenance/maintenanceLandingLazyRoute').then( (m) => m.maintenanceLandingLandingLazyRoute diff --git a/packages/manager/src/routes/profile/index.ts b/packages/manager/src/routes/profile/index.ts index 879b6950833..72be9adfed9 100644 --- a/packages/manager/src/routes/profile/index.ts +++ b/packages/manager/src/routes/profile/index.ts @@ -98,14 +98,6 @@ const profileReferralsRoute = createRoute({ */ const profilePreferencesRoute = createRoute({ - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/profile/settings`, - replace: true, - }); - } - }, getParentRoute: () => profileRoute, path: 'preferences', validateSearch: (search: ProfileSettingsSearchParams) => search, @@ -116,20 +108,18 @@ const profilePreferencesRoute = createRoute({ ); const profileSettingsRoute = createRoute({ - beforeLoad: ({ context }) => { - if (context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/profile/preferences`, - replace: true, - }); - } + beforeLoad: () => { + throw redirect({ + to: `/profile/preferences`, + replace: true, + }); }, getParentRoute: () => profileRoute, path: 'settings', validateSearch: (search: ProfileSettingsSearchParams) => search, }).lazy(() => import('src/features/Profile/Settings/settingsLazyRoute').then( - (m) => m.settingsLazyRoute + (m) => m.preferencesLazyRoute ) ); diff --git a/packages/manager/src/routes/quotas/index.ts b/packages/manager/src/routes/quotas/index.ts index a8c7c0f8ecc..12567a1b236 100644 --- a/packages/manager/src/routes/quotas/index.ts +++ b/packages/manager/src/routes/quotas/index.ts @@ -22,14 +22,6 @@ const quotasCatchAllRoute = createRoute({ const quotasIndexRoute = createRoute({ getParentRoute: () => quotasRoute, path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/quotas`, - replace: true, - }); - } - }, }).lazy(() => import('src/features/Quotas//quotasLandingLazyRoute').then( (m) => m.quotasLandingLazyRoute diff --git a/packages/manager/src/routes/serviceTransfers/index.ts b/packages/manager/src/routes/serviceTransfers/index.ts index 62056c773d9..6198a415d7e 100644 --- a/packages/manager/src/routes/serviceTransfers/index.ts +++ b/packages/manager/src/routes/serviceTransfers/index.ts @@ -22,14 +22,6 @@ const serviceTransfersCatchAllRoute = createRoute({ const serviceTransfersIndexRoute = createRoute({ getParentRoute: () => serviceTransfersRoute, path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/service-transfers`, - replace: true, - }); - } - }, }).lazy(() => import('src/features/ServiceTransfers/serviceTransfersLandingLazyRoute').then( (m) => m.serviceTransfersLandingLazyRoute @@ -39,14 +31,6 @@ const serviceTransfersIndexRoute = createRoute({ const serviceTransfersCreateRoute = createRoute({ getParentRoute: () => serviceTransfersRoute, path: 'create', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/service-transfers/create`, - replace: true, - }); - } - }, }).lazy(() => import('src/features/ServiceTransfers/serviceTransfersCreateLazyRoute').then( (m) => m.serviceTransfersCreateLazyRoute diff --git a/packages/manager/src/routes/usersAndGrants/UsersAndGrantsRoute.tsx b/packages/manager/src/routes/usersAndGrants/UsersAndGrantsRoute.tsx index 386e658183a..9ae03ca7990 100644 --- a/packages/manager/src/routes/usersAndGrants/UsersAndGrantsRoute.tsx +++ b/packages/manager/src/routes/usersAndGrants/UsersAndGrantsRoute.tsx @@ -1,17 +1,14 @@ -import { NotFound } from '@linode/ui'; import { Outlet } from '@tanstack/react-router'; import React from 'react'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { useFlags } from 'src/hooks/useFlags'; export const UsersAndGrantsRoute = () => { - const { iamRbacPrimaryNavChanges } = useFlags(); return ( }> - {iamRbacPrimaryNavChanges ? : } + ); }; From 921d95616fbd290f05138b387e5e2bc968068846 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:25:15 +0530 Subject: [PATCH 06/58] fix: [UIE-9841] - Hide dual stack option if no IPv6 prefixes available in create VPC flow (#13245) --- .../pr-13245-tech-stories-1767723985438.md | 5 + packages/api-v4/src/account/types.ts | 1 - .../pr-13245-fixed-1767723908908.md | 5 + packages/manager/src/factories/account.ts | 1 + .../src/factories/userAccountPermissions.ts | 1 + .../VPCTopSectionContent.test.tsx | 15 ++- .../FormComponents/VPCTopSectionContent.tsx | 103 +++++++++--------- packages/manager/src/hooks/useVPCDualStack.ts | 7 -- packages/manager/src/mocks/serverHandlers.ts | 24 ++++ 9 files changed, 96 insertions(+), 66 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13245-tech-stories-1767723985438.md create mode 100644 packages/manager/.changeset/pr-13245-fixed-1767723908908.md diff --git a/packages/api-v4/.changeset/pr-13245-tech-stories-1767723985438.md b/packages/api-v4/.changeset/pr-13245-tech-stories-1767723985438.md new file mode 100644 index 00000000000..f0f1aebf187 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13245-tech-stories-1767723985438.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Tech Stories +--- + +Clean up unused VPC IPv6 Large Prefixes tag ([#13245](https://github.com/linode/manager/pull/13245)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 9c952bd2dd7..533dbadf752 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -93,7 +93,6 @@ export const accountCapabilities = [ 'Vlans', 'VPCs', 'VPC Dual Stack', - 'VPC IPv6 Large Prefixes', ] as const; export type AccountCapability = (typeof accountCapabilities)[number]; diff --git a/packages/manager/.changeset/pr-13245-fixed-1767723908908.md b/packages/manager/.changeset/pr-13245-fixed-1767723908908.md new file mode 100644 index 00000000000..84b545f8629 --- /dev/null +++ b/packages/manager/.changeset/pr-13245-fixed-1767723908908.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Hide dual stack option if no IPv6 prefixes available in create VPC flow ([#13245](https://github.com/linode/manager/pull/13245)) diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index f463fe6f83c..de029b12345 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -56,6 +56,7 @@ export const accountFactory = Factory.Sync.makeFactory({ 'Placement Group', 'Vlans', 'Kubernetes Enterprise', + 'VPC Dual Stack', ], city: 'Philadelphia', company: Factory.each((i) => `company-${i}`), diff --git a/packages/manager/src/factories/userAccountPermissions.ts b/packages/manager/src/factories/userAccountPermissions.ts index 1aa5ed98ca6..db1385b263a 100644 --- a/packages/manager/src/factories/userAccountPermissions.ts +++ b/packages/manager/src/factories/userAccountPermissions.ts @@ -3,4 +3,5 @@ import type { PermissionType } from '@linode/api-v4'; export const userAccountPermissionsFactory: PermissionType[] = [ 'create_linode', 'create_firewall', + 'create_vpc', ]; diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx index 97345c9b573..2af63fac8fd 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx @@ -73,12 +73,11 @@ describe('VPC Top Section form content', () => { const NetworkingIPStackRadios = screen.getAllByRole('radio'); expect(NetworkingIPStackRadios[0]).toBeChecked(); // IPv4 - expect(NetworkingIPStackRadios[1]).not.toBeChecked(); // Dual Stack }); it('renders VPC IPv6 Prefix Length options with /52 selected if the selected region has multiple prefix lengths available', async () => { const account = accountFactory.build({ - capabilities: ['VPC Dual Stack', 'VPC IPv6 Large Prefixes'], + capabilities: ['VPC Dual Stack'], }); server.use(http.get('*/account', () => HttpResponse.json(account))); @@ -148,7 +147,7 @@ describe('VPC Top Section form content', () => { expect(IPv6CIDRRadios[3]).toBeChecked(); // /52 }); - it('does not render VPC IPv6 Prefix Length options if there are none available or only /52 available', async () => { + it('does not show dual stack option and does not render VPC IPv6 Prefix Length options if there are none available', async () => { const account = accountFactory.build({ capabilities: ['VPC Dual Stack'], }); @@ -160,7 +159,7 @@ describe('VPC Top Section form content', () => { makeResourcePage([ regionVPCAvailabilityFactory.build({ region: 'us-east', - available_ipv6_prefix_lengths: [52], + available_ipv6_prefix_lengths: [], }), ]) ) @@ -189,10 +188,10 @@ describe('VPC Top Section form content', () => { expect(screen.getByText('IP Stack')).toBeVisible(); }); - const NetworkingIPStackRadios = screen.getAllByRole('radio'); - await userEvent.click(NetworkingIPStackRadios[1]); - expect(NetworkingIPStackRadios[0]).not.toBeChecked(); // IPv4 - expect(NetworkingIPStackRadios[1]).toBeChecked(); // Dual Stack + expect(screen.getByText('IPv4')).toBeVisible(); // IPv4 + expect( + screen.queryByText('IPv4 + IPv6 (Dual Stack)') + ).not.toBeInTheDocument(); // Dual Stack expect( screen.queryByText('VPC IPv6 Prefix Length') diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index 1303dcdcd83..0a43b00fe54 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -205,61 +205,64 @@ export const VPCTopSectionContent = (props: Props) => { sxCardBase={{ gap: 0 }} sxCardBaseIcon={{ svg: { fontSize: '20px' } }} /> - { - field.onChange([ - { - range: '/52', - }, - ]); - subnets?.forEach((subnet, idx) => - update(idx, { - ...subnet, - ipv6: subnet.ipv6 ?? [{ range: '/56' }], - }) - ); - }} - renderIcon={() => ( - 0 && ( + - )} - renderVariant={() => ( - - - The VPC supports both IPv4 and IPv6 addresses. - - - For IPv4, {RFC1918HelperText} - - - For IPv6, the VPC is assigned an IPv6 prefix - length of /52 by default. - - - } - width={250} + heading="IPv4 + IPv6 (Dual Stack)" + onClick={() => { + field.onChange([ + { + range: '/52', + }, + ]); + subnets?.forEach((subnet, idx) => + update(idx, { + ...subnet, + ipv6: subnet.ipv6 ?? [{ range: '/56' }], + }) + ); + }} + renderIcon={() => ( + + )} + renderVariant={() => ( + + + The VPC supports both IPv4 and IPv6 addresses. + + + For IPv4, {RFC1918HelperText} + + + For IPv6, the VPC is assigned an IPv6 prefix + length of /52 by default. + + + } + width={250} + /> + )} + subheadings={[]} + sxCardBase={{ gap: 0 }} + sxCardBaseIcon={{ svg: { fontSize: '20px' } }} /> )} - subheadings={[]} - sxCardBase={{ gap: 0 }} - sxCardBaseIcon={{ svg: { fontSize: '20px' } }} - /> )} diff --git a/packages/manager/src/hooks/useVPCDualStack.ts b/packages/manager/src/hooks/useVPCDualStack.ts index 862d4062bd5..9a3ed148737 100644 --- a/packages/manager/src/hooks/useVPCDualStack.ts +++ b/packages/manager/src/hooks/useVPCDualStack.ts @@ -17,12 +17,6 @@ export const useVPCDualStack = (ipv6?: VPCIPv6[]) => { account?.capabilities ?? [] ); - const isEnterpriseCustomer = isFeatureEnabledV2( - 'VPC IPv6 Large Prefixes', - Boolean(flags.vpcIpv6), - account?.capabilities ?? [] - ); - const shouldDisplayIPv6 = isDualStackEnabled && isDualStackSelected; const recommendedIPv6 = shouldDisplayIPv6 ? [ @@ -35,7 +29,6 @@ export const useVPCDualStack = (ipv6?: VPCIPv6[]) => { return { isDualStackEnabled, isDualStackSelected, - isEnterpriseCustomer, shouldDisplayIPv6, recommendedIPv6, }; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index d5a1defaee9..c0d19f90ace 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -524,6 +524,30 @@ const vpc = [ const subnet = subnetFactory.build({ ...(body as any) }); return HttpResponse.json(subnet); }), + http.get('*/v4beta/regions/vpc-availability', () => { + return HttpResponse.json({ + data: [ + { + region: 'ap-west', + available: true, + available_ipv6_prefix_lengths: [], + }, + { + region: 'in-maa', + available: true, + available_ipv6_prefix_lengths: [52], + }, + { + region: 'us-southeast', + available: true, + available_ipv6_prefix_lengths: [48, 52], + }, + ], + page: 1, + pages: 1, + results: 3, + }); + }), ]; const iam = [ From 34557c0ff2078a7fae3e9e0cc18d232ae033246d Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 9 Jan 2026 11:33:38 +0530 Subject: [PATCH 07/58] change: [UIE-9908] - Update Generational Plans default sort to show newest (G8) -> oldest (G6) (#13234) * Update GP default sort to show newest (G8) -> oldest (G6) * Minor comment updates * Added changeset: Update Generational Plans default sort to show newest (G8) -> oldest (G6) * Make generation plan ranking more scalable * Update some e2e tests * Update more e2e test cases * Update e2e tests from stackscripts and marketplace --- .../pr-13234-changed-1767172149210.md | 5 ++++ .../e2e/core/kubernetes/lke-update.spec.ts | 7 +++++ .../e2e/core/linodes/alerts-create.spec.ts | 18 ++++++++++++ .../e2e/core/linodes/create-linode.spec.ts | 10 +++++++ .../core/oneClickApps/one-click-apps.spec.ts | 5 ++++ .../stackscripts/create-stackscripts.spec.ts | 7 +++++ .../smoke-community-stackscripts.spec.ts | 7 +++++ .../PlansPanel/utils/planFilters.test.ts | 29 +++++++++++++++++++ .../PlansPanel/utils/planFilters.ts | 26 +++++++++++++++-- 9 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13234-changed-1767172149210.md diff --git a/packages/manager/.changeset/pr-13234-changed-1767172149210.md b/packages/manager/.changeset/pr-13234-changed-1767172149210.md new file mode 100644 index 00000000000..770e04ff88d --- /dev/null +++ b/packages/manager/.changeset/pr-13234-changed-1767172149210.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Update Generational Plans default sort to show newest (G8) -> oldest (G6) ([#13234](https://github.com/linode/manager/pull/13234)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 07bd5f6edd6..4623ef33e52 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1405,6 +1405,13 @@ describe('LKE cluster updates', () => { .findByTitle(`Add a Node Pool: ${mockCluster.label}`) .should('be.visible') .within(() => { + // For the "Dedicated 4 GB", use filter to select G6 Dedicated instead of relying on pagination + ui.autocomplete.findByLabel('Dedicated Plans').click(); + + ui.autocompletePopper.find().within(() => { + cy.findByText('G6 Dedicated').should('be.visible').click(); + }); + cy.findByText('Dedicated 4 GB') .should('be.visible') .closest('tr') diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts index dcb02fd5f66..43325319275 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts @@ -123,6 +123,12 @@ describe('Create flow when beta alerts enabled by region and feature flag', func cy.get('[data-qa-tp="Linode Plan"]') .should('be.visible') .within(() => { + // For the Dedicated 8 GB, Use filter to select G6 Dedicated instead of relying on pagination + ui.autocomplete.findByLabel('Dedicated Plans').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('G6 Dedicated').should('be.visible').click(); + }); + cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click(); }); cy.get('[type="password"]').should('be.visible').scrollIntoView(); @@ -283,6 +289,12 @@ describe('Create flow when beta alerts enabled by region and feature flag', func cy.get('[data-qa-tp="Linode Plan"]') .should('be.visible') .within(() => { + // For the Dedicated 8 GB, Use filter to select G6 Dedicated instead of relying on pagination + ui.autocomplete.findByLabel('Dedicated Plans').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('G6 Dedicated').should('be.visible').click(); + }); + cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click(); }); cy.get('[type="password"]').should('be.visible').scrollIntoView(); @@ -432,6 +444,12 @@ describe('Create flow when beta alerts enabled by region and feature flag', func cy.get('[data-qa-tp="Linode Plan"]') .should('be.visible') .within(() => { + // For the Dedicated 8 GB, Use filter to select G6 Dedicated instead of relying on pagination + ui.autocomplete.findByLabel('Dedicated Plans').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('G6 Dedicated').should('be.visible').click(); + }); + cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click(); }); cy.get('[type="password"]').should('be.visible').scrollIntoView(); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index ef7213c5fae..eb6eec87f92 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -93,6 +93,16 @@ describe('Create Linode', () => { linodeCreatePage.setLabel(linodeLabel); linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); + + // For the "Dedicated 4 GB" plan under the "Dedicated CPU" plan type, use filter to select G6 Dedicated instead of relying on pagination + if (planConfig.planType === 'Dedicated CPU') { + ui.autocomplete.findByLabel('Dedicated Plans').click(); + + ui.autocompletePopper.find().within(() => { + cy.findByText('G6 Dedicated').should('be.visible').click(); + }); + } + linodeCreatePage.selectPlan( planConfig.planType, planConfig.planLabel diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index bd5f13f4f28..8c1e15ab9bd 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -221,6 +221,11 @@ describe('OneClick Apps (OCA)', () => { cy.focused().type(`${region.id}{enter}`); // Choose a Linode plan + // For the Dedicated 8 GB, Use filter to select G6 Dedicated instead of relying on pagination + ui.autocomplete.findByLabel('Dedicated Plans').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('G6 Dedicated').should('be.visible').click(); + }); cy.get('[data-qa-plan-row="Dedicated 8 GB"]') .closest('tr') .within(() => { 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 01a7dbe891e..22048718330 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -114,6 +114,13 @@ const fillOutLinodeForm = (label: string, regionName: string) => { cy.focused().type(label); cy.findByText('Dedicated CPU').should('be.visible').click(); + + // Use filter to select G6 Dedicated instead of relying on pagination + ui.autocomplete.findByLabel('Dedicated Plans').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('G6 Dedicated').should('be.visible').click(); + }); + cy.get('[id="g6-dedicated-2"]').click(); cy.findByLabelText('Root Password').should('be.visible').type(password); }; diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts index 26b17237855..7e137192517 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts @@ -379,6 +379,13 @@ describe('Community Stackscripts integration tests', () => { // An error message shows up when no region is selected cy.contains('Plan is required.').should('be.visible'); + + // For the Dedicated 8 GB, Use filter to select G6 Dedicated instead of relying on pagination + ui.autocomplete.findByLabel('Dedicated Plans').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('G6 Dedicated').should('be.visible').click(); + }); + cy.get('[data-qa-plan-row="Dedicated 8 GB"]') .closest('tr') .within(() => { diff --git a/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts b/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts index 1c4e99cf5c5..4196e192eb0 100644 --- a/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts +++ b/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts @@ -11,6 +11,7 @@ import { filterPlansByGpuType, filterPlansByType, getAvailableTypes, + getGenerationRank, supportsTypeFiltering, } from './planFilters'; @@ -240,4 +241,32 @@ describe('planFilters utilities', () => { expect(result).toEqual(gpuPlans); }); }); + + describe('getGenerationRank', () => { + it('returns higher rank for newer generations', () => { + expect(getGenerationRank('g8-dedicated-4-2')).toBeGreaterThan( + getGenerationRank('g7-dedicated-4-2') + ); + expect(getGenerationRank('g7-dedicated-4-2')).toBeGreaterThan( + getGenerationRank('g6-dedicated-2') + ); + }); + + it('handles multi-digit generations correctly', () => { + expect(getGenerationRank('g10-dedicated-2')).toBeGreaterThan( + getGenerationRank('g8-dedicated-4-2') + ); + expect(getGenerationRank('g11-dedicated-2')).toBeGreaterThan( + getGenerationRank('g10-dedicated-2') + ); + }); + + it('returns 0 for unknown generations', () => { + expect(getGenerationRank('legacy-plan')).toBe(0); + expect(getGenerationRank('gg-xyz')).toBe(0); + expect(getGenerationRank('g-pqr')).toBe(0); + expect(getGenerationRank('x123')).toBe(0); + expect(getGenerationRank('')).toBe(0); + }); + }); }); diff --git a/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts b/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts index 9dd66d55a8d..89b8ca003f9 100644 --- a/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts +++ b/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts @@ -68,6 +68,26 @@ export const filterPlansByGeneration = ( // Type Filtering // ============================================================================ +/** + * Returns the numeric generation of a plan based on its ID. + * Higher generation number = newer plan (shown first). + * + * Example: + * - "g8-dedicated-4-2" -> 8 + * - "g1-accelerated-netint-vpu" -> 1 + * - "legacy-plan" -> 0 + */ +export const getGenerationRank = (planId: string): number => { + const generation = planId.split('-')[0]; // eg., "g8" or "legacy" + + // Safe fallback: must start with "g" + if (!generation.startsWith('g')) return 0; + + const num = Number(generation.slice(1)); + + return Number.isFinite(num) ? num : 0; +}; + /** * Filter plans by type within a generation * @@ -88,9 +108,11 @@ export const filterPlansByType = ( generation: PlanFilterGeneration, type: PlanFilterType ): PlanWithAvailability[] => { - // "All" returns all plans unchanged + // "All" returns all plans, sorted from newest to oldest generations if (type === PLAN_FILTER_ALL) { - return plans; + return [...plans].sort( + (a, b) => getGenerationRank(b.id) - getGenerationRank(a.id) + ); } // G7, G6, and "All" generation only have "All" option (no sub-types) From 312c07be9416481cd4e3c4bc04a5c1013ffb72a2 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:13:12 +0530 Subject: [PATCH 08/58] change: [UIE-9890] - changes related to private IP field in create linode flow (#13253) --- .../pr-13253-changed-1767795144176.md | 5 ++++ .../LinodeCreate/Addons/Addons.test.tsx | 30 +++++++++++++++++++ .../Linodes/LinodeCreate/Addons/Addons.tsx | 9 ++++-- .../Linodes/LinodeCreate/Addons/PrivateIP.tsx | 14 +++++++-- 4 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-13253-changed-1767795144176.md diff --git a/packages/manager/.changeset/pr-13253-changed-1767795144176.md b/packages/manager/.changeset/pr-13253-changed-1767795144176.md new file mode 100644 index 00000000000..e9cdb2c6786 --- /dev/null +++ b/packages/manager/.changeset/pr-13253-changed-1767795144176.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Changes related to private IP field in create linode flow ([#13253](https://github.com/linode/manager/pull/13253)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.test.tsx index e39c3fb7e70..7055b71c46b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.test.tsx @@ -37,4 +37,34 @@ describe('Linode Create Addons', () => { 'Backups and Private IP are not available for distributed regions.' ); }); + + it('should hide the Private IP addon if interface generation is "linode"', () => { + const { queryByText } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + values: { + interface_generation: 'linode', + }, + }, + }); + + const privateIPLabel = queryByText('Private IP'); + + expect(privateIPLabel).not.toBeInTheDocument(); + }); + + it('should show the Private IP addon if interface generation is not "linode"', () => { + const { queryByText } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + values: { + interface_generation: 'legacy_config', + }, + }, + }); + + const privateIPLabel = queryByText('Private IP'); + + expect(privateIPLabel).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.tsx index a8d97d19911..114468f1754 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Addons.tsx @@ -9,7 +9,10 @@ import { PrivateIP } from './PrivateIP'; import type { CreateLinodeRequest } from '@linode/api-v4'; export const Addons = () => { - const regionId = useWatch({ name: 'region' }); + const [regionId, interfaceGeneration] = useWatch< + CreateLinodeRequest, + ['region', 'interface_generation'] + >({ name: ['region', 'interface_generation'] }); const { data: regions } = useRegionsQuery(); @@ -21,6 +24,8 @@ export const Addons = () => { const isDistributedRegionSelected = selectedRegion?.site_type === 'distributed'; + const shouldShowPrivateIP = interfaceGeneration !== 'linode'; + return ( @@ -33,7 +38,7 @@ export const Addons = () => { )} } spacing={2}> - + {shouldShowPrivateIP && } diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.tsx index 70e7e9802fc..a1a8ca2876a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.tsx @@ -2,6 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { Checkbox, FormControlLabel, + NewFeatureChip, Notice, Stack, Typography, @@ -55,9 +56,18 @@ export const PrivateIP = () => { /> )} - Use Private IP for a backend node to a NodeBalancer. Use VPC instead - for private communication between your Linodes. + Lets you connect with other Linodes in the same region over the data + center's private network, without using a public IPv4 address. + + + + + You can use VPC for private networking instead. NodeBalancers + now connect to backend nodes without a private IPv4 address. + + + } onChange={field.onChange} From 2947bce1acedc9b4c8d7203ddb80085256161cef Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Fri, 9 Jan 2026 14:47:22 +0530 Subject: [PATCH 09/58] change: [UIE-9919] - Address post-demo feedback(points 6,7) (#13258) * change: [UIE-9919] - Address post-demo feedback(points 6,7) * Added changeset: NLB post-demo feedback-Optimize table col widths and add back button * Address review comments. * Update packages/manager/.changeset/pr-13258-changed-1767876909544.md Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --------- Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --- .../pr-13258-changed-1767876909544.md | 5 ++ .../NetworkLoadBalancersDetail.tsx | 68 ++++++++++++---- .../NetworkLoadBalancersListenerDetail.tsx | 80 ++++++++++++++----- .../NodesTable/NodesTable.tsx | 1 + .../NodesTable/NodesTableRow.tsx | 2 +- .../NetworkLoadBalancerTableRow.tsx | 12 ++- .../NetworkLoadBalancersLanding.tsx | 13 ++- 7 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 packages/manager/.changeset/pr-13258-changed-1767876909544.md diff --git a/packages/manager/.changeset/pr-13258-changed-1767876909544.md b/packages/manager/.changeset/pr-13258-changed-1767876909544.md new file mode 100644 index 00000000000..e37d87519f7 --- /dev/null +++ b/packages/manager/.changeset/pr-13258-changed-1767876909544.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Optimize NLB table column widths and add back button on detail pages ([#13258](https://github.com/linode/manager/pull/13258)) diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx index 65caa7fbf95..43e3beefaf5 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx @@ -1,11 +1,13 @@ import { useNetworkLoadBalancerQuery } from '@linode/queries'; -import { CircleProgress, ErrorState } from '@linode/ui'; +import { Box, CircleProgress, ErrorState, IconButton } from '@linode/ui'; +import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { LandingHeader } from 'src/components/LandingHeader'; +import { Link } from 'src/components/Link'; import { NLB_API_DOCS_LINK } from '../constants'; import { NetworkLoadBalancerDetailBody } from './NetworkLoadBalancerDetailBody'; @@ -35,21 +37,57 @@ const NetworkLoadBalancersDetail = () => { return ( <> - + > + + ({ + [theme.breakpoints.down('md')]: { + marginRight: 0, // Smaller screens + }, + marginRight: theme.spacingFunction(8), + padding: 0, + })} + tabIndex={-1} + > + + + + + { return ( <> - + > + + ({ + [theme.breakpoints.down('md')]: { + marginRight: 0, // Smaller screens + }, + marginRight: theme.spacingFunction(8), + padding: 0, + })} + tabIndex={-1} + > + + + + + { direction={order} handleClick={handleOrderChange} label="address_v6" + sx={{ width: '15%' }} > VPC IPv6 diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NodesTable/NodesTableRow.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NodesTable/NodesTableRow.tsx index 0e084a3280c..2609e7270d0 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NodesTable/NodesTableRow.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NodesTable/NodesTableRow.tsx @@ -39,7 +39,7 @@ export const NodesTableRow = (props: NetworkLoadBalancerNode) => { {linode_id} - + {address_v6 ? ( ) : ( diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx index 0e35ccac349..531022141cd 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx @@ -68,11 +68,19 @@ export const NetworkLoadBalancerTableRow = (props: NetworkLoadBalancer) => { - + - + ({ + [theme.breakpoints.down('md')]: { + width: '25%', // Smaller screens + }, + width: '15%', // Default width + })} + > {address_v6 ? ( ) : ( diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.tsx index ebe550b70cc..200861bd2ff 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.tsx @@ -85,9 +85,18 @@ export const NetworkLoadBalancersLanding = () => { Listener Ports - Virtual IP (IPv4) + Virtual IP (IPv4) - Virtual IP (IPv6) + ({ + [theme.breakpoints.down('md')]: { + width: '25%', // Smaller screens + }, + width: '15%', // Default width + })} + > + Virtual IP (IPv6) + Cluster Region From ae5748ede1733e9bebffb0e600e2021af52630ec Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:59:52 +0100 Subject: [PATCH 10/58] change: [UIE-9920] - Billing: Disable "Make payment" button for Akamai users (#13243) * disable make payment button for akamai users * Added changeset: Billing: Disable 'Make payment'button for Akamai users * remove dupe changeset --- .../pr-13243-changed-1767703325974.md | 5 +++++ .../Billing/BillingLanding/BillingLanding.tsx | 22 +++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-13243-changed-1767703325974.md diff --git a/packages/manager/.changeset/pr-13243-changed-1767703325974.md b/packages/manager/.changeset/pr-13243-changed-1767703325974.md new file mode 100644 index 00000000000..d766baf3707 --- /dev/null +++ b/packages/manager/.changeset/pr-13243-changed-1767703325974.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Billing: Disable 'Make payment'button for Akamai users ([#13243](https://github.com/linode/manager/pull/13243)) diff --git a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx index a79a7d21935..7573c5b3d27 100644 --- a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx +++ b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx @@ -67,11 +67,13 @@ export const BillingLanding = () => { pathname: '/billing', }, buttonDataAttrs: { - disabled: isReadOnly, - tooltipText: getRestrictedResourceText({ - isChildUser, - resourceType: 'Account', - }), + disabled: isReadOnly || isAkamaiAccount, + tooltipText: isAkamaiAccount + ? 'This feature is not available for Akamai accounts.' + : getRestrictedResourceText({ + isChildUser, + resourceType: 'Account', + }), }, createButtonText: 'Make a Payment', docsLabel: 'How Linode Billing Works', @@ -87,12 +89,10 @@ export const BillingLanding = () => { /> ) : undefined, onButtonClick: () => - !isAkamaiAccount - ? navigate({ - to: '/billing', - search: { action: 'make-payment' }, - }) - : {}, + navigate({ + to: '/billing', + search: { action: 'make-payment' }, + }), title: 'Billing', }; From 25a4879599dd520567207af30d375246eb59ffae Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Fri, 9 Jan 2026 17:03:40 +0530 Subject: [PATCH 11/58] upcoming: [M3-9225] - Add API queries for MarketplaceV2 (#13255) * upcoming: [M3-9225] - Add API queries for MarketplaceV2 * Added changeset: Add API queries for MarketplaceV2 * PR feedback * fixed api url and factories * added queries for /marketplace/types * PR feedback @pmakode-akamai --- .../api-v4/src/marketplace/marketplace.ts | 2 +- packages/api-v4/src/marketplace/types.ts | 35 ++- packages/manager/src/mocks/serverHandlers.ts | 2 +- ...r-13255-upcoming-features-1767845238509.md | 5 + packages/queries/src/index.ts | 1 + packages/queries/src/marketplace/index.ts | 3 + packages/queries/src/marketplace/keys.ts | 108 ++++++++++ .../queries/src/marketplace/marketplace.ts | 199 ++++++++++++++++++ packages/queries/src/marketplace/requests.ts | 60 ++++++ .../utilities/src/factories/marketplace.ts | 27 ++- 10 files changed, 422 insertions(+), 20 deletions(-) create mode 100644 packages/queries/.changeset/pr-13255-upcoming-features-1767845238509.md create mode 100644 packages/queries/src/marketplace/index.ts create mode 100644 packages/queries/src/marketplace/keys.ts create mode 100644 packages/queries/src/marketplace/marketplace.ts create mode 100644 packages/queries/src/marketplace/requests.ts diff --git a/packages/api-v4/src/marketplace/marketplace.ts b/packages/api-v4/src/marketplace/marketplace.ts index e253226a592..845326f7807 100644 --- a/packages/api-v4/src/marketplace/marketplace.ts +++ b/packages/api-v4/src/marketplace/marketplace.ts @@ -29,7 +29,7 @@ export const getMarketplaceProducts = (params?: Params, filters?: Filter) => export const getMarketplaceProduct = (productId: number) => Request( setURL( - `${BETA_API_ROOT}/marketplace/products/${encodeURIComponent(productId)}`, + `${BETA_API_ROOT}/marketplace/products/${encodeURIComponent(productId)}/details`, ), setMethod('GET'), ); diff --git a/packages/api-v4/src/marketplace/types.ts b/packages/api-v4/src/marketplace/types.ts index 430f4dc395d..179b7591c0e 100644 --- a/packages/api-v4/src/marketplace/types.ts +++ b/packages/api-v4/src/marketplace/types.ts @@ -1,42 +1,59 @@ export interface MarketplaceProductDetail { - documentation: string; - overview: { + documentation?: string; + overview?: { description: string; }; - pricing: string; - support: string; + pricing?: string; + support?: string; } export interface MarketplaceProduct { category_ids: number[]; + created_at: string; + created_by: string; details?: MarketplaceProductDetail; id: number; info_banner?: string; + logo_url: string; name: string; partner_id: number; product_tags?: string[]; short_description: string; - title_tag?: string; + tile_tag?: string; type_id: number; + updated_at?: string; + updated_by?: string; } export interface MarketplaceCategory { - category: string; + created_at: string; + created_by: string; id: number; - product_count: number; + name: string; + products_count: number; + updated_at?: string; + updated_by?: string; } export interface MarketplaceType { + created_at: string; + created_by: string; id: number; name: string; - product_count: number; + products_count: number; + updated_at?: string; + updated_by?: string; } export interface MarketplacePartner { + created_at: string; + created_by: string; id: number; + logo_url_dark_mode: string; logo_url_light_mode: string; - logo_url_night_mode?: string; name: string; + updated_at?: string; + updated_by?: string; url: string; } diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index c0d19f90ace..ebf8fa0d757 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -651,7 +651,7 @@ const marketplace = [ const marketplaceProduct = marketplaceProductFactory.buildList(10); return HttpResponse.json(makeResourcePage([...marketplaceProduct])); }), - http.get('*/v4beta/marketplace/products/:productId', () => { + http.get('*/v4beta/marketplace/products/:productId/details', () => { const marketplaceProductDetail = marketplaceProductFactory.build({ details: { overview: { diff --git a/packages/queries/.changeset/pr-13255-upcoming-features-1767845238509.md b/packages/queries/.changeset/pr-13255-upcoming-features-1767845238509.md new file mode 100644 index 00000000000..31ea725d372 --- /dev/null +++ b/packages/queries/.changeset/pr-13255-upcoming-features-1767845238509.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Add API queries for MarketplaceV2 ([#13255](https://github.com/linode/manager/pull/13255)) diff --git a/packages/queries/src/index.ts b/packages/queries/src/index.ts index 041af378f50..016e4c60cfb 100644 --- a/packages/queries/src/index.ts +++ b/packages/queries/src/index.ts @@ -11,6 +11,7 @@ export * from './iam'; export * from './images'; export * from './linodes'; export * from './locks'; +export * from './marketplace'; export * from './netloadbalancers'; export * from './networking'; export * from './networktransfer'; diff --git a/packages/queries/src/marketplace/index.ts b/packages/queries/src/marketplace/index.ts new file mode 100644 index 00000000000..ca0b44a0997 --- /dev/null +++ b/packages/queries/src/marketplace/index.ts @@ -0,0 +1,3 @@ +export * from './keys'; +export * from './marketplace'; +export * from './requests'; diff --git a/packages/queries/src/marketplace/keys.ts b/packages/queries/src/marketplace/keys.ts new file mode 100644 index 00000000000..600086c7197 --- /dev/null +++ b/packages/queries/src/marketplace/keys.ts @@ -0,0 +1,108 @@ +import { + getMarketplaceCategories, + getMarketplacePartners, + getMarketplaceProduct, + getMarketplaceProducts, + getMarketplaceTypes, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import { + getAllMarketplaceCategories, + getAllMarketplacePartners, + getAllMarketplaceProducts, + getAllMarketplaceTypes, +} from './requests'; + +import type { Filter, Params } from '@linode/api-v4'; + +export const marketplaceQueries = createQueryKeys('marketplace', { + product: (productId: number) => ({ + queryFn: () => getMarketplaceProduct(productId), + queryKey: [productId], + }), + products: { + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllMarketplaceProducts(params, filter), + queryKey: [params, filter], + }), + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getMarketplaceProducts( + { page: pageParam as number, page_size: 25 }, + filter, + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getMarketplaceProducts(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + categories: { + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllMarketplaceCategories(params, filter), + queryKey: [params, filter], + }), + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getMarketplaceCategories( + { page: pageParam as number, page_size: 25 }, + filter, + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getMarketplaceCategories(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + types: { + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllMarketplaceTypes(params, filter), + queryKey: [params, filter], + }), + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getMarketplaceTypes( + { page: pageParam as number, page_size: 25 }, + filter, + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getMarketplaceTypes(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + partners: { + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllMarketplacePartners(params, filter), + queryKey: [params, filter], + }), + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getMarketplacePartners( + { page: pageParam as number, page_size: 25 }, + filter, + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getMarketplacePartners(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); diff --git a/packages/queries/src/marketplace/marketplace.ts b/packages/queries/src/marketplace/marketplace.ts new file mode 100644 index 00000000000..feb6b7b3a4d --- /dev/null +++ b/packages/queries/src/marketplace/marketplace.ts @@ -0,0 +1,199 @@ +import { createPartnerReferral } from '@linode/api-v4'; +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import { accountQueries } from '../account'; +import { marketplaceQueries } from './keys'; + +import type { + APIError, + Filter, + MarketplaceCategory, + MarketplacePartner, + MarketplacePartnerReferralPayload, + MarketplaceProduct, + MarketplaceType, + Params, + ResourcePage, +} from '@linode/api-v4'; + +export const useMarketplaceProductsQuery = ( + params: Params, + filter: Filter, + enabled: boolean = true, +) => + useQuery, APIError[]>({ + ...marketplaceQueries.products._ctx.paginated(params, filter), + enabled, + placeholderData: keepPreviousData, + }); + +export const useAllMarketplaceProductsQuery = ( + params: Params = {}, + filter: Filter = {}, + enabled: boolean = true, +) => + useQuery({ + ...marketplaceQueries.products._ctx.all(params, filter), + enabled, + }); + +export const useInfiniteMarketplaceProductsQuery = ( + filter: Filter, + enabled: boolean, +) => + useInfiniteQuery, APIError[]>({ + ...marketplaceQueries.products._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); + +export const useMarketplaceProductQuery = ( + productId: number, + enabled: boolean = true, +) => + useQuery({ + ...marketplaceQueries.product(productId), + enabled, + }); + +export const useMarketplaceCategoriesQuery = ( + params: Params, + filter: Filter, + enabled: boolean = true, +) => + useQuery, APIError[]>({ + ...marketplaceQueries.categories._ctx.paginated(params, filter), + enabled, + placeholderData: keepPreviousData, + }); + +export const useAllMarketplaceCategoriesQuery = ( + params: Params = {}, + filter: Filter = {}, + enabled: boolean = true, +) => + useQuery({ + ...marketplaceQueries.categories._ctx.all(params, filter), + enabled, + }); + +export const useInfiniteMarketplaceCategoriesQuery = ( + filter: Filter, + enabled: boolean, +) => + useInfiniteQuery, APIError[]>({ + ...marketplaceQueries.categories._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); + +export const useMarketplaceTypesQuery = ( + params: Params, + filter: Filter, + enabled: boolean = true, +) => + useQuery, APIError[]>({ + ...marketplaceQueries.types._ctx.paginated(params, filter), + enabled, + placeholderData: keepPreviousData, + }); + +export const useAllMarketplaceTypesQuery = ( + params: Params = {}, + filter: Filter = {}, + enabled: boolean = true, +) => + useQuery({ + ...marketplaceQueries.types._ctx.all(params, filter), + enabled, + }); + +export const useInfiniteMarketplaceTypesQuery = ( + filter: Filter, + enabled: boolean, +) => + useInfiniteQuery, APIError[]>({ + ...marketplaceQueries.types._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); + +export const useMarketplacePartnersQuery = ( + params: Params, + filter: Filter, + enabled: boolean = true, +) => + useQuery, APIError[]>({ + ...marketplaceQueries.partners._ctx.paginated(params, filter), + enabled, + placeholderData: keepPreviousData, + }); + +export const useAllMarketplacePartnersQuery = ( + params: Params = {}, + filter: Filter = {}, + enabled: boolean = true, +) => + useQuery({ + ...marketplaceQueries.partners._ctx.all(params, filter), + enabled, + }); + +export const useInfiniteMarketplacePartnersQuery = ( + filter: Filter, + enabled: boolean, +) => + useInfiniteQuery, APIError[]>({ + ...marketplaceQueries.partners._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); + +export const useCreatePartnerReferralMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], MarketplacePartnerReferralPayload>({ + mutationFn: createPartnerReferral, + onSuccess: () => { + setTimeout(() => { + // Refetch notifications after 1.5 seconds. The API needs some time to process. + queryClient.invalidateQueries({ + queryKey: accountQueries.notifications.queryKey, + }); + }, 1500); + }, + }); +}; diff --git a/packages/queries/src/marketplace/requests.ts b/packages/queries/src/marketplace/requests.ts new file mode 100644 index 00000000000..5ae6b21d0d2 --- /dev/null +++ b/packages/queries/src/marketplace/requests.ts @@ -0,0 +1,60 @@ +import { + getMarketplaceCategories, + getMarketplacePartners, + getMarketplaceProducts, + getMarketplaceTypes, +} from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; + +import type { + Filter, + MarketplaceCategory, + MarketplacePartner, + MarketplaceProduct, + MarketplaceType, + Params, +} from '@linode/api-v4'; + +export const getAllMarketplaceProducts = ( + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getMarketplaceProducts( + { ...params, ...passedParams }, + { ...filter, ...passedFilter }, + ), + )().then((data) => data.data); + +export const getAllMarketplaceCategories = ( + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getMarketplaceCategories( + { ...params, ...passedParams }, + { ...filter, ...passedFilter }, + ), + )().then((data) => data.data); + +export const getAllMarketplaceTypes = ( + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getMarketplaceTypes( + { ...params, ...passedParams }, + { ...filter, ...passedFilter }, + ), + )().then((data) => data.data); + +export const getAllMarketplacePartners = ( + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getMarketplacePartners( + { ...params, ...passedParams }, + { ...filter, ...passedFilter }, + ), + )().then((data) => data.data); diff --git a/packages/utilities/src/factories/marketplace.ts b/packages/utilities/src/factories/marketplace.ts index b9f8adae80c..215c2df1534 100644 --- a/packages/utilities/src/factories/marketplace.ts +++ b/packages/utilities/src/factories/marketplace.ts @@ -9,37 +9,46 @@ import type { export const marketplaceProductFactory = Factory.Sync.makeFactory({ + category_ids: [1, 2], + created_at: '2024-01-01T00:00:00', + created_by: 'user1', id: Factory.each((id) => id), + logo_url: 'https://www.example.com/logo.png', name: Factory.each((id) => `marketplace-product-${id}`), partner_id: Factory.each((id) => id), - type_id: Factory.each((id) => id), - category_ids: [1, 2], + product_tags: ['tag1', 'tag2'], short_description: 'This is a short description of the marketplace product.', - title_tag: 'Marketplace Product Title Tag', - product_tags: ['tag1', 'tag2'], + tile_tag: '60 days free trial', + type_id: Factory.each((id) => id), }); export const marketplaceCategoryFactory = Factory.Sync.makeFactory({ + name: Factory.each((id) => `marketplace-category-${id}`), + created_at: '2024-01-01T00:00:00', + created_by: 'user1', id: Factory.each((id) => id), - category: Factory.each((id) => `marketplace-category-${id}`), - product_count: Factory.each((id) => id * 10), + products_count: Factory.each((id) => id * 10), }); export const marketplaceTypeFactory = Factory.Sync.makeFactory( { + created_at: '2024-01-01T00:00:00', + created_by: 'user1', id: Factory.each((id) => id), + products_count: Factory.each((id) => id * 5), name: Factory.each((id) => `marketplace-type-${id}`), - product_count: Factory.each((id) => id * 5), }, ); export const marketplacePartnersFactory = Factory.Sync.makeFactory({ + created_at: '2024-01-01T00:00:00', + created_by: 'user1', id: Factory.each((id) => id), + logo_url_dark_mode: 'https://www.example.com/logo-dark-mode.png', + logo_url_light_mode: 'https://www.example.com/logo-light-mode.png', name: Factory.each((id) => `marketplace-partner-${id}`), url: 'https://www.example.com', - logo_url_light_mode: 'https://www.example.com/logo-light-mode.png', - logo_url_night_mode: 'https://www.example.com/logo-night-mode.png', }); From 0145e69141231d71a94042466bf40104c4fcd853 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:09:07 +0100 Subject: [PATCH 12/58] fix: [UIE-9933], [UIE-9935] - IAM: UX copy updates, breadcrumbs fix (#13259) * fix: [UIE-9933], [UIE-9935] - IAM: UX copy updates, breadcrumbs fix * Added changeset: IAM Delegation: UX copy update, wrong breadcrumb fix --- .../pr-13259-fixed-1767882040647.md | 5 ++++ .../Delegations/AccountDelegations.test.tsx | 2 +- .../Delegations/AccountDelegationsTable.tsx | 2 +- .../Delegations/UpdateDelegationsDrawer.tsx | 6 ++--- .../IAM/Roles/Defaults/DefaultRolesPanel.tsx | 2 +- .../IAM/Roles/Defaults/DefaultsLanding.tsx | 23 +++++++++++++++++++ ...emoveAssignmentConfirmationDialog.test.tsx | 2 +- .../RemoveAssignmentConfirmationDialog.tsx | 2 +- .../src/features/IAM/Shared/constants.ts | 2 +- 9 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-13259-fixed-1767882040647.md diff --git a/packages/manager/.changeset/pr-13259-fixed-1767882040647.md b/packages/manager/.changeset/pr-13259-fixed-1767882040647.md new file mode 100644 index 00000000000..ea7dc164167 --- /dev/null +++ b/packages/manager/.changeset/pr-13259-fixed-1767882040647.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM Delegation: UX copy update, wrong breadcrumb fix ([#13259](https://github.com/linode/manager/pull/13259)) diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx index 8156369edcf..5dc6036b295 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx @@ -82,7 +82,7 @@ describe('AccountDelegations', () => { }); await waitFor(() => { - const emptyElement = screen.getByText(/No delegate users found/); + const emptyElement = screen.getByText(/No users added/); expect(emptyElement).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx index 1c464fa19ed..bcb5b668fca 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx @@ -59,7 +59,7 @@ export const AccountDelegationsTable = ({ style={{ width: '59%' }} sx={{ display: { sm: 'table-cell', xs: 'none' } }} > - Delegate Users + Users diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx index 69c0d1ca9c0..f277ef4cc6b 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx @@ -127,9 +127,9 @@ export const UpdateDelegationsDrawer = ({
Add or remove users who should have access to the child account. - Delegate users removed from this list will lose the role assignment - on the child account and they won’t be visible in the user list on - the child account. + Users removed from this list will lose the role assignment on the + child account and they won’t be visible in the user list on the + child account. {delegation && ( diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRolesPanel.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRolesPanel.tsx index 92d6da2e347..54be17b3776 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRolesPanel.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRolesPanel.tsx @@ -16,7 +16,7 @@ export const DefaultRolesPanel = () => { Default Roles for Delegate Users - View and manage roles to be assigned to new delegated users by + View and manage roles to be assigned to new delegate users by default. diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx index 7ed5c0b2db9..18cbf8d3a6e 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx @@ -1,3 +1,4 @@ +import { NewFeatureChip } from '@linode/ui'; import { TabPanels } from '@reach/tabs'; import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -6,11 +7,19 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; +import { useIsIAMEnabled } from '../../hooks/useIsIAMEnabled'; +import { IAM_LABEL } from '../../Shared/constants'; + export const DefaultsLanding = () => { const location = useLocation(); const navigate = useNavigate(); + const flags = useFlags(); + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const showLimitedAvailabilityBadges = + flags.iamLimitedAvailabilityBadges && isIAMEnabled && !isIAMBeta; const { tabs, tabIndex, handleTabChange } = useTabs([ { @@ -32,6 +41,20 @@ export const DefaultsLanding = () => { + {IAM_LABEL} + {showLimitedAvailabilityBadges ? ( + + ) : null} + + ), + linkTo: '/iam', + position: 1, + }, + ], }} spacingBottom={4} title="Default Roles for Delegate Users" diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx index 8ebc5444dac..ee0911fe1a6 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx @@ -139,7 +139,7 @@ describe('RemoveAssignmentConfirmationDialog', () => { ); expect(headerText).toBeVisible(); - const paragraph = screen.getByText(/Delegated users won’t get the/i); + const paragraph = screen.getByText(/Delegate users won’t get the/i); expect(paragraph).toBeVisible(); expect(paragraph).toHaveTextContent(mockRole.entity_name); diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx index 6eff0ef3bbc..0dcbe951f2d 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx @@ -116,7 +116,7 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { {isDefaultDelegationRolesForChildAccount ? ( - Delegated users won’t get the {role?.role_name} access on the{' '} + Delegate users won’t get the {role?.role_name} access on the{' '} {role?.entity_name} entity by default. ) : ( diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index dede2ad4ece..4aefdba9001 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -20,7 +20,7 @@ export const ERROR_STATE_TEXT = 'An unexpected error occurred. Refresh the page or try again later.'; // Delegation error messages -export const NO_DELEGATIONS_TEXT = 'No delegate users found.'; +export const NO_DELEGATIONS_TEXT = 'No users added.'; export const DELEGATION_VALIDATION_ERROR = 'At least one user must be selected as a delegate.'; From 7fe39155b42d61074e64f2a54f28ae52e8592061 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:49:57 +0530 Subject: [PATCH 13/58] change: [UIE-9860] - Change the default selection of network interface type to linode interface in Linode create flow (#13221) --- .../pr-13221-changed-1766474458876.md | 5 +++++ .../create-linode-with-firewall.spec.ts | 18 +++++++++--------- .../linodes/create-linode-with-vlan.spec.ts | 12 ++++++------ .../linodes/create-linode-with-vpc.spec.ts | 12 ++++++------ .../Networking/InterfaceGeneration.tsx | 2 +- .../Linodes/LinodeCreate/utilities.test.tsx | 16 ++++++++-------- .../features/Linodes/LinodeCreate/utilities.ts | 9 ++++----- 7 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 packages/manager/.changeset/pr-13221-changed-1766474458876.md diff --git a/packages/manager/.changeset/pr-13221-changed-1766474458876.md b/packages/manager/.changeset/pr-13221-changed-1766474458876.md new file mode 100644 index 00000000000..07d6e24c7af --- /dev/null +++ b/packages/manager/.changeset/pr-13221-changed-1766474458876.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Default selection of network interface type to linode interface in Linode create flow ([#13221](https://github.com/linode/manager/pull/13221)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts index bfae1112c27..b87f8dea920 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -421,6 +421,9 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); + // Switch to legacy Config Interfaces + linodeCreatePage.selectLegacyConfigInterfacesType(); + // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. cy.findByLabelText('Firewall').should('be.visible'); cy.get('[data-qa-autocomplete="Firewall"]').within(() => { @@ -491,9 +494,6 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Switch to Linode Interfaces - linodeCreatePage.selectLinodeInterfacesType(); - // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. cy.findByLabelText('Public Interface Firewall').should('be.visible'); cy.get('[data-qa-autocomplete="Public Interface Firewall"]').within(() => { @@ -566,6 +566,9 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); + // Switch to legacy Config Interfaces + linodeCreatePage.selectLegacyConfigInterfacesType(); + cy.findByText('Create Firewall').should('be.visible').click(); ui.drawer @@ -661,9 +664,6 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Switch to Linode Interfaces - linodeCreatePage.selectLinodeInterfacesType(); - cy.findByText('Create Firewall').should('be.visible').click(); ui.drawer @@ -766,6 +766,9 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); + // Switch to legacy Config Interfaces + linodeCreatePage.selectLegacyConfigInterfacesType(); + // Creating the linode without a firewall should display a warning. ui.button .findByTitle('Create Linode') @@ -869,9 +872,6 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Switch to Linode Interfaces - linodeCreatePage.selectLinodeInterfacesType(); - // Creating the linode without a firewall should display a warning. ui.button .findByTitle('Create Linode') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index 1b4a1c62f3e..1ed9356b23b 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -319,6 +319,9 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); + // Switch to legacy Config Interfaces + linodeCreatePage.selectLegacyConfigInterfacesType(); + // select existing VLAN. linodeCreatePage.selectInterface('vlan'); // Confirm that mocked VLAN is shown in the Autocomplete, and then select it. @@ -400,9 +403,6 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Switch to Linode Interfaces - linodeCreatePage.selectLinodeInterfacesType(); - // Select VLAN card linodeCreatePage.selectInterface('vlan'); @@ -485,6 +485,9 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); + // Switch to legacy Config Interfaces + linodeCreatePage.selectLegacyConfigInterfacesType(); + // Select VLAN card linodeCreatePage.selectInterface('vlan'); @@ -567,9 +570,6 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Switch to Linode Interfaces - linodeCreatePage.selectLinodeInterfacesType(); - // Select VLAN card linodeCreatePage.selectInterface('vlan'); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts index ea1688c42d8..0f9fa5ab253 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -476,6 +476,9 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); + // Switch to legacy Config Interfaces + linodeCreatePage.selectLegacyConfigInterfacesType(); + // Select VPC linodeCreatePage.selectInterface('vpc'); @@ -612,9 +615,6 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Switch to Linode Interfaces - linodeCreatePage.selectLinodeInterfacesType(); - // Select VPC option linodeCreatePage.selectInterface('vpc'); @@ -750,6 +750,9 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); + // Switch to legacy Config Interfaces + linodeCreatePage.selectLegacyConfigInterfacesType(); + // Select VPC card linodeCreatePage.selectInterface('vpc'); @@ -933,9 +936,6 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Switch to Linode Interfaces - linodeCreatePage.selectLinodeInterfacesType(); - // Select VPC card linodeCreatePage.selectInterface('vpc'); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceGeneration.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceGeneration.tsx index bd4cf8cf802..9020bfd894b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceGeneration.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceGeneration.tsx @@ -65,7 +65,7 @@ export const InterfaceGeneration = () => { aria-labelledby="interface-generation" onChange={field.onChange} sx={{ my: '0px !important' }} - value={field.value ?? 'legacy_config'} + value={field.value ?? 'linode'} > } diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx index 9184714117a..10ffa39e821 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx @@ -434,14 +434,6 @@ describe('getIsValidLinodeLabelCharacter', () => { }); describe('getDefaultInterfaceGenerationFromAccountSetting', () => { - it('returns "legacy_config" for "legacy_config_default_but_linode_allowed"', () => { - expect( - getDefaultInterfaceGenerationFromAccountSetting( - 'legacy_config_default_but_linode_allowed' - ) - ).toBe('legacy_config'); - }); - it('returns "legacy_config" for "legacy_config_only"', () => { expect( getDefaultInterfaceGenerationFromAccountSetting('legacy_config_only') @@ -454,6 +446,14 @@ describe('getDefaultInterfaceGenerationFromAccountSetting', () => { ); }); + it('returns "linode" for "legacy_config_default_but_linode_allowed"', () => { + expect( + getDefaultInterfaceGenerationFromAccountSetting( + 'legacy_config_default_but_linode_allowed' + ) + ).toBe('linode'); + }); + it('returns "linode" for "linode_default_but_legacy_config_allowed"', () => { expect( getDefaultInterfaceGenerationFromAccountSetting( diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 60cd2d430e7..a30cf672123 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -616,14 +616,13 @@ export const getDefaultInterfaceGenerationFromAccountSetting = ( ): InterfaceGenerationType | undefined => { if ( accountSetting === 'linode_only' || - accountSetting === 'linode_default_but_legacy_config_allowed' + accountSetting === 'linode_default_but_legacy_config_allowed' || + accountSetting === 'legacy_config_default_but_linode_allowed' ) { + // Default selection is to the Linode interface to encourage new customer accounts to adopt it safely when using Cloud Manager. return 'linode'; } - if ( - accountSetting === 'legacy_config_only' || - accountSetting === 'legacy_config_default_but_linode_allowed' - ) { + if (accountSetting === 'legacy_config_only') { return 'legacy_config'; } return undefined; From 9d3c96840c47c0835b168a5ccc3a3492ed916944 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 12 Jan 2026 09:17:28 +0100 Subject: [PATCH 14/58] chore: [UIE-9926] - Bump `jspdf` to 4.0.0 (#13248) * bump jspdf to 4.0.0 * types and test * oops buffer * Added changeset: Bump jspdf to 4.0.0 * try CI without global --- .../pr-13248-tech-stories-1767787781583.md | 5 ++++ packages/manager/package.json | 3 +-- packages/manager/src/dev-tools/utils.ts | 4 +-- .../BillingActivityPanel.tsx | 4 +-- .../Billing/PdfGenerator/PdfGenerator.test.ts | 7 ++++- .../Billing/PdfGenerator/PdfGenerator.ts | 25 +++++++++--------- pnpm-lock.yaml | 26 +++++++------------ 7 files changed, 38 insertions(+), 36 deletions(-) create mode 100644 packages/manager/.changeset/pr-13248-tech-stories-1767787781583.md diff --git a/packages/manager/.changeset/pr-13248-tech-stories-1767787781583.md b/packages/manager/.changeset/pr-13248-tech-stories-1767787781583.md new file mode 100644 index 00000000000..fddbe8367db --- /dev/null +++ b/packages/manager/.changeset/pr-13248-tech-stories-1767787781583.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Bump jspdf to 4.0.0 ([#13248](https://github.com/linode/manager/pull/13248)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 379e6098108..3d4f2df535f 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -59,7 +59,7 @@ "he": "^1.2.0", "immer": "^9.0.6", "ipaddr.js": "^1.9.1", - "jspdf": "^3.0.2", + "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.2", "launchdarkly-react-client-sdk": "3.0.10", "libphonenumber-js": "^1.10.6", @@ -140,7 +140,6 @@ "@types/eslint-plugin-jsx-a11y": "^6.10.0", "@types/he": "^1.1.0", "@types/history": "4", - "@types/jspdf": "^1.3.3", "@types/luxon": "3.4.2", "@types/markdown-it": "^14.1.2", "@types/md5": "^2.1.32", diff --git a/packages/manager/src/dev-tools/utils.ts b/packages/manager/src/dev-tools/utils.ts index 88839cdf6b8..dbed6769e15 100644 --- a/packages/manager/src/dev-tools/utils.ts +++ b/packages/manager/src/dev-tools/utils.ts @@ -92,9 +92,9 @@ export const saveSeedsCountMap = (countMap: { [key: string]: number }) => { /** * Retrieves the presets map from local storage. */ -export const getExtraPresetsMap = (): { +export const getExtraPresetsMap = (): Partial<{ [K in MockPresetExtraId]: number; -} => { +}> => { const encodedPresetsMap = localStorage.getItem(LOCAL_STORAGE_PRESETS_MAP_KEY); return encodedPresetsMap ? JSON.parse(encodedPresetsMap) : {}; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index b979abf6117..c8844b3ad62 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -282,7 +282,7 @@ export const BillingActivityPanel = React.memo((props: Props) => { ); const downloadPaymentPDF = React.useCallback( - (paymentId: number) => { + async (paymentId: number) => { const payment = payments?.find( (thisPayment) => thisPayment.id === paymentId ); @@ -305,7 +305,7 @@ export const BillingActivityPanel = React.memo((props: Props) => { taxes?.date, taxes?.country_tax ); - const result = printPayment(account, payment, countryTax); + const result = await printPayment(account, payment, countryTax); if (result.status === 'error') { pdfErrors.add(id); diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.test.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.test.ts index ac4f7fd72d8..1de7ca4326b 100644 --- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.test.ts +++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.test.ts @@ -470,7 +470,12 @@ describe('PdfGenerator', () => { }); // Call the printPayment function - const pdfResult = printPayment(account, payment, countryTax, timezone); + const pdfResult = await printPayment( + account, + payment, + countryTax, + timezone + ); // Expect the PDF generation to be successful expect(pdfResult.status).toEqual('success'); diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts index 89abbb5a486..c389747904a 100644 --- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts +++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts @@ -179,15 +179,15 @@ const addTitle = (doc: jsPDF, y: number, ...textStrings: Title[]) => { }; // M3-6177 only make one request to get the logo -const getAkamaiLogo = () => { - return axios - .get(AkamaiLogo, { responseType: 'blob' }) - .then((res) => { - return URL.createObjectURL(res.data); - }) - .catch(() => { - return AkamaiLogo; - }); +const getAkamaiLogo = async () => { + const response = await axios.get(AkamaiLogo, { responseType: 'blob' }); + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(response.data); + }); }; interface PrintInvoiceOptions { @@ -324,12 +324,12 @@ export const printInvoice = async ( } }; -export const printPayment = ( +export const printPayment = async ( account: Account, payment: Payment, countryTax?: TaxDetail, timezone?: string -): PdfResult => { +): Promise => { try { const date = formatDate(payment.date, { displayTime: true, @@ -340,7 +340,8 @@ export const printPayment = ( }); doc.setFontSize(10); - doc.addImage(AkamaiLogo, 'JPEG', 160, 10, 120, 40, undefined, 'MEDIUM'); + const AkamaiLogoURL = await getAkamaiLogo(); + doc.addImage(AkamaiLogoURL, 'JPEG', 160, 10, 120, 40, undefined, 'MEDIUM'); const leftHeaderYPosition = addLeftHeader( doc, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcd73f6e6a2..70294159460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,11 +257,11 @@ importers: specifier: ^1.9.1 version: 1.9.1 jspdf: - specifier: ^3.0.2 - version: 3.0.2 + specifier: ^4.0.0 + version: 4.0.0 jspdf-autotable: specifier: ^5.0.2 - version: 5.0.2(jspdf@3.0.2) + version: 5.0.2(jspdf@4.0.0) launchdarkly-react-client-sdk: specifier: 3.0.10 version: 3.0.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -401,9 +401,6 @@ importers: '@types/history': specifier: '4' version: 4.7.11 - '@types/jspdf': - specifier: ^1.3.3 - version: 1.3.3 '@types/luxon': specifier: 3.4.2 version: 3.4.2 @@ -2988,9 +2985,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/jspdf@1.3.3': - resolution: {integrity: sha512-DqwyAKpVuv+7DniCp2Deq1xGvfdnKSNgl9Agun2w6dFvR5UKamiv4VfYUgcypd8S9ojUyARFIlZqBrYrBMQlew==} - '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -5103,8 +5097,8 @@ packages: peerDependencies: jspdf: ^2 || ^3 - jspdf@3.0.2: - resolution: {integrity: sha512-G0fQDJ5fAm6UW78HG6lNXyq09l0PrA1rpNY5i+ly17Zb1fMMFSmS+3lw4cnrAPGyouv2Y0ylujbY2Ieq3DSlKA==} + jspdf@4.0.0: + resolution: {integrity: sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==} jsprim@2.0.2: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} @@ -8998,8 +8992,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/jspdf@1.3.3': {} - '@types/linkify-it@5.0.0': {} '@types/luxon@3.4.2': {} @@ -11466,13 +11458,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jspdf-autotable@5.0.2(jspdf@3.0.2): + jspdf-autotable@5.0.2(jspdf@4.0.0): dependencies: - jspdf: 3.0.2 + jspdf: 4.0.0 - jspdf@3.0.2: + jspdf@4.0.0: dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: From c3bd7ca13e8659c97ba3eabc34bd0583b2e05525 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Mon, 12 Jan 2026 09:36:32 +0100 Subject: [PATCH 15/58] feat: [UIE-9931] - IAM: fix payload for updateChildAccountDelegates (#13260) * feat: [UIE-9931] - IAM: fix payload for updateChildAccountDelegates * update updateChildAccountDelegates * changesets --- packages/api-v4/.changeset/pr-13260-fixed-1767958247170.md | 5 +++++ packages/api-v4/src/iam/delegation.ts | 2 +- packages/manager/.changeset/pr-13260-fixed-1767958263414.md | 5 +++++ .../manager/src/mocks/presets/crud/handlers/delegation.ts | 4 +++- 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13260-fixed-1767958247170.md create mode 100644 packages/manager/.changeset/pr-13260-fixed-1767958263414.md diff --git a/packages/api-v4/.changeset/pr-13260-fixed-1767958247170.md b/packages/api-v4/.changeset/pr-13260-fixed-1767958247170.md new file mode 100644 index 00000000000..eac8e4e9c48 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13260-fixed-1767958247170.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Fixed +--- + +IAM Delegation: fix payload for updateChildAccountDelegates ([#13260](https://github.com/linode/manager/pull/13260)) diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts index 333546ad6e9..22e5da8f911 100644 --- a/packages/api-v4/src/iam/delegation.ts +++ b/packages/api-v4/src/iam/delegation.ts @@ -64,7 +64,7 @@ export const updateChildAccountDelegates = ({ `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, ), setMethod('PUT'), - setData(users), + setData({ users }), ); export const getMyDelegatedChildAccounts = ({ diff --git a/packages/manager/.changeset/pr-13260-fixed-1767958263414.md b/packages/manager/.changeset/pr-13260-fixed-1767958263414.md new file mode 100644 index 00000000000..30201c63af2 --- /dev/null +++ b/packages/manager/.changeset/pr-13260-fixed-1767958263414.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM Delegation: fix payload for updateChildAccountDelegates ([#13260](https://github.com/linode/manager/pull/13260)) diff --git a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts index 7fb21aca9e5..a3a65b0a755 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @@ -143,7 +143,9 @@ export const childAccountDelegates = (mockState: MockState) => [ StrictResponse> > => { const euuid = params.euuid as string; - const newUsernames = (await request.json()) as string[]; + const { users: newUsernames } = (await request.json()) as { + users: string[]; + }; // Get current delegations const allDelegations = await mswDB.getAll('delegations'); From 9639de98dd6c81255e83217ded539d46c6fdac92 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 12 Jan 2026 14:58:25 +0530 Subject: [PATCH 16/58] upcoming: [UIE-9817] - Add Breadcrumb to Marketplace Product Landing Page (#13257) * Add breadcrumb * Add beta chip to Partner Referral breadcrumb * Added changeset: Add Breadcrumb to Marketplace product landing page * Keep label plural --- ...r-13257-upcoming-features-1767870979148.md | 5 ++++ .../Marketplace/MarketplaceLanding.tsx | 28 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13257-upcoming-features-1767870979148.md diff --git a/packages/manager/.changeset/pr-13257-upcoming-features-1767870979148.md b/packages/manager/.changeset/pr-13257-upcoming-features-1767870979148.md new file mode 100644 index 00000000000..bebfd187532 --- /dev/null +++ b/packages/manager/.changeset/pr-13257-upcoming-features-1767870979148.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Breadcrumb to Marketplace product landing page ([#13257](https://github.com/linode/manager/pull/13257)) diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx index 10c9c37b848..fae2e39cff3 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx @@ -1,8 +1,32 @@ -import { Notice } from '@linode/ui'; +import { BetaChip, Notice } from '@linode/ui'; import * as React from 'react'; +import { LandingHeader } from 'src/components/LandingHeader'; + export const MarketplaceLanding = () => { return ( - Partner Referral Catalog is coming soon... + <> + + Partner Referrals + + + ), + position: 1, + }, + { + label: 'Catalog', + position: 2, + }, + ], + pathname: '/cloud-marketplace/catalog', + }} + /> + Partner Referral Catalog is coming soon... + ); }; From e793c252b19b3adb54e66a099c00f5d80427d074 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 12 Jan 2026 16:56:35 +0530 Subject: [PATCH 17/58] upcoming: [UIE-9826] - Add reusable Product Selection Card component for Marketplace (#13247) * Add Marketplace ProductCard component * More changes * Some clean up * Add tests and some changes * Added changeset: Add reusable Product Selection Card component for Marketplace * Update Marketplace feature folder structure * Add color tokens for badge as per ux mocks * Clean up logo styles * Remove usage of fontWeight * Refactor: simplify component props * Update description truncation from 268 to 200 characters * Make some of the props required and update tests * More refactoring and cleanup * Add logo URL to tests base data --- ...r-13247-upcoming-features-1767773811535.md | 5 + .../MarketplaceLanding.tsx | 0 .../ProductSelectionCard.test.tsx | 136 +++++++++++++ .../ProductSelectionCard.tsx | 186 ++++++++++++++++++ .../marketplaceLazyRoute.tsx | 0 .../manager/src/routes/marketplace/index.ts | 6 +- 6 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13247-upcoming-features-1767773811535.md rename packages/manager/src/features/Marketplace/{ => MarketplaceLanding}/MarketplaceLanding.tsx (100%) create mode 100644 packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx create mode 100644 packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx rename packages/manager/src/features/Marketplace/{ => MarketplaceLanding}/marketplaceLazyRoute.tsx (100%) diff --git a/packages/manager/.changeset/pr-13247-upcoming-features-1767773811535.md b/packages/manager/.changeset/pr-13247-upcoming-features-1767773811535.md new file mode 100644 index 00000000000..bae9b95c584 --- /dev/null +++ b/packages/manager/.changeset/pr-13247-upcoming-features-1767773811535.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add reusable Product Selection Card component for Marketplace ([#13247](https://github.com/linode/manager/pull/13247)) diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/MarketplaceLanding.tsx similarity index 100% rename from packages/manager/src/features/Marketplace/MarketplaceLanding.tsx rename to packages/manager/src/features/Marketplace/MarketplaceLanding/MarketplaceLanding.tsx diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx new file mode 100644 index 00000000000..8f75454b5af --- /dev/null +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.test.tsx @@ -0,0 +1,136 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ProductSelectionCard } from './ProductSelectionCard'; + +describe('ProductSelectionCard', () => { + const baseData = { + companyName: 'Test Company', + description: 'This is a test product description', + logoUrl: '/test-logo.png', + productName: 'Test Product', + type: 'SaaS & APIs', + }; + + it('renders all - logo image, product name, company name, description and type chip', () => { + const { getByAltText, getByText } = renderWithTheme( + {}} + /> + ); + + const logo = getByAltText('Test Product logo'); + expect(logo).toBeVisible(); + expect(logo).toHaveAttribute('src', '/test-logo.png'); + + expect(getByText('Test Product')).toBeVisible(); + expect(getByText('Test Company')).toBeVisible(); + expect(getByText('This is a test product description')).toBeVisible(); + expect(getByText('SaaS & APIs')).toBeVisible(); + }); + + it('truncates long descriptions and appends an ellipsis', () => { + const longDescription = Array(300).fill('word').join(' '); + const { getByText } = renderWithTheme( + {}} + /> + ); + + const displayedText = getByText(/word/); + // Truncate adds "..." so length should be less than original + expect(displayedText.textContent?.length).toBeLessThan( + longDescription.length + ); + expect(displayedText.textContent).toContain('...'); + }); + + it('renders product tag chip when provided', () => { + const { getByText } = renderWithTheme( + {}} + /> + ); + + expect(getByText('New')).toBeVisible(); + }); + + it('calls onClick when card is clicked', async () => { + const handleClick = vi.fn(); + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('Test Product')); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders disabled state correctly', () => { + const { getByTestId } = renderWithTheme( + {}} + /> + ); + + expect(getByTestId('selection-card')).toBeDisabled(); + }); + + it('renders all elements together', () => { + const { getByText, getByAltText } = renderWithTheme( + {}} + /> + ); + + expect(getByText('Complete Product')).toBeVisible(); + expect(getByText('Test Company')).toBeVisible(); + expect(getByText('Full product description')).toBeVisible(); + expect(getByText('New')).toBeVisible(); + expect(getByText('SaaS & APIs')).toBeVisible(); + expect(getByAltText('Complete Product logo')).toBeVisible(); + }); + + it('does not render optional elements when not provided', () => { + const { getByText, queryByText } = renderWithTheme( + {}} + /> + ); + + expect(getByText('Minimal Product')).toBeVisible(); + expect(getByText('Test Company')).toBeVisible(); + expect(getByText('This is a test product description')).toBeVisible(); + expect(getByText('SaaS & APIs')).toBeVisible(); + + // optional elements should not be in the document + expect(queryByText('New')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx new file mode 100644 index 00000000000..a72ea49a6f8 --- /dev/null +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx @@ -0,0 +1,186 @@ +import { Box, Chip, Typography } from '@linode/ui'; +import { truncate } from '@linode/utilities'; +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; + +export interface ProductCardData { + /** + * Company name displayed below the product name + */ + companyName: string; + /** + * Product description text + */ + description: string; + /** + * URL or path to the product logo image + */ + logoUrl: string; + /** + * Product name/title + */ + productName: string; + /** + * Product tag chip displayed in top right corner (e.g., "New", "30 days free trial") + */ + productTag?: string; + /** + * Bottom left type chip label (e.g., "SaaS and APIs") + */ + type: string; +} + +export interface ProductSelectionCardProps { + /** + * Product data to display + */ + data: ProductCardData; + /** + * If true, the card will be disabled + * @default false + */ + disabled?: boolean; + /** + * Callback fired when the card is clicked + */ + onClick?: () => void; +} + +/** + * A reusable product selection card component for displaying marketplace products. + * Built on top of SelectionCard for consistency. + */ +export const ProductSelectionCard = React.memo( + (props: ProductSelectionCardProps) => { + const { data, disabled = false, onClick } = props; + const { type, companyName, description, logoUrl, productName, productTag } = + data; + + const subheadings = React.useMemo( + () => [ + // Company name as first subheading + ({ + color: theme.tokens.alias.Content.Text.Secondary.Default, + font: theme.font.semibold, + fontSize: theme.tokens.font.FontSize.Xxxs, // Must come after font + })} + > + {companyName} + , + // Description + ({ + color: theme.tokens.alias.Content.Text.Primary.Default, + fontSize: theme.tokens.font.FontSize.Xs, + marginTop: theme.spacingFunction(12), + paddingBottom: theme.spacingFunction(36), // Always space for type chip at bottom + })} + variant="body1" + > + {truncate(description, 200)} + , + // Type chip (as last element with absolute positioning at bottom) + ({ + bottom: theme.spacingFunction(16), + left: theme.spacingFunction(20), + position: 'absolute', + })} + > + ({ + backgroundColor: theme.tokens.alias.Background.Informativesubtle, + })} + /> + , + ], + [companyName, description, type] + ); + + // Render header row with logo and optional Product tag chip + const renderHeader = React.useCallback(() => { + return ( + + {/* Logo */} + {logoUrl && ( + + {`${productName} + + )} + + {/* Product Tag Chip */} + {productTag && ( + ({ + '& .MuiChip-label': { + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, // Must come after font + padding: `${theme.spacingFunction(4)} ${theme.spacingFunction(6)}`, + }, + backgroundColor: + theme.tokens.component.Badge.Positive.Subtle.Background, + color: theme.tokens.component.Badge.Positive.Subtle.Text, + flexShrink: 0, + })} + /> + )} + + ); + }, [logoUrl, productName, productTag]); + + return ( + ({ + alignItems: 'flex-start', + flexDirection: 'column', + minHeight: '280px', + padding: `${theme.spacingFunction(16)} ${theme.spacingFunction(20)}`, + position: 'relative', + gap: theme.spacingFunction(12), + })} + sxCardBaseIcon={{ + alignItems: 'flex-start', + justifyContent: 'flex-start', + width: '100%', + }} + /> + ); + } +); + +const StyledLogoBox = styled(Box)({ + height: '48px', + maxWidth: '96px', + overflow: 'hidden', + width: 'auto', +}); diff --git a/packages/manager/src/features/Marketplace/marketplaceLazyRoute.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/marketplaceLazyRoute.tsx similarity index 100% rename from packages/manager/src/features/Marketplace/marketplaceLazyRoute.tsx rename to packages/manager/src/features/Marketplace/MarketplaceLanding/marketplaceLazyRoute.tsx diff --git a/packages/manager/src/routes/marketplace/index.ts b/packages/manager/src/routes/marketplace/index.ts index 9ef9c903270..cf8afd10a87 100644 --- a/packages/manager/src/routes/marketplace/index.ts +++ b/packages/manager/src/routes/marketplace/index.ts @@ -21,9 +21,9 @@ export const marketplaceCatlogRoute = createRoute({ getParentRoute: () => marketplaceRoute, path: '/catalog', }).lazy(() => - import('src/features/Marketplace/marketplaceLazyRoute').then( - (m) => m.marketplaceLazyRoute - ) + import( + 'src/features/Marketplace/MarketplaceLanding/marketplaceLazyRoute' + ).then((m) => m.marketplaceLazyRoute) ); export const marketplaceRouteTree = marketplaceRoute.addChildren([ From 4f2cc9f618e9ca7d3d55d298ae36e239366fd1a5 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:31:49 +0100 Subject: [PATCH 18/58] fix: [UIE-9952] - IAM - Fix hydration error on User Detail pages (#13265) * fix console error * Added changeset: IAM hydration error on User Detail pages --- packages/manager/.changeset/pr-13265-fixed-1768222517680.md | 5 +++++ .../manager/src/features/IAM/Users/UserDetailsLanding.tsx | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-13265-fixed-1768222517680.md diff --git a/packages/manager/.changeset/pr-13265-fixed-1768222517680.md b/packages/manager/.changeset/pr-13265-fixed-1768222517680.md new file mode 100644 index 00000000000..db027d6c05f --- /dev/null +++ b/packages/manager/.changeset/pr-13265-fixed-1768222517680.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM hydration error on User Detail pages ([#13265](https://github.com/linode/manager/pull/13265)) diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 0d4d6ece9c0..24e44c98a70 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -67,7 +67,10 @@ export const UserDetailsLanding = () => { <> {IAM_LABEL} {showLimitedAvailabilityBadges ? ( - + ) : null} ), From 64174dbd8046bb78fd5630de9db9ae508d8380c9 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:00:15 +0100 Subject: [PATCH 19/58] change: [UIE-9942] - IAM - Replace `view_account` with fine-grained permissions (#13262) * save progress * remaining instances * cleanup and tests --- packages/api-v4/src/iam/types.ts | 6 +++++ .../src/features/IAM/Roles/Roles.test.tsx | 12 +++------ .../manager/src/features/IAM/Roles/Roles.tsx | 6 ++--- .../AssignedEntitiesTable.tsx | 5 +++- .../IAM/Users/UserDetails/UserProfile.tsx | 27 ++++++++++--------- .../Users/UserEntities/UserEntities.test.tsx | 7 ++--- .../IAM/Users/UserEntities/UserEntities.tsx | 17 +++++------- .../IAM/Users/UserRoles/UserRoles.test.tsx | 2 +- .../IAM/Users/UserRoles/UserRoles.tsx | 15 ++++------- .../features/IAM/Users/UsersTable/UserRow.tsx | 4 +-- .../features/IAM/Users/UsersTable/Users.tsx | 4 +-- .../Users/UsersTable/UsersActionMenu.test.tsx | 4 +-- .../IAM/Users/UsersTable/UsersActionMenu.tsx | 16 +++++------ 13 files changed, 62 insertions(+), 63 deletions(-) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 3ffeaa29952..4196c0c25cb 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -97,9 +97,12 @@ export type AccountAdmin = | 'list_default_firewalls' | 'list_delegate_users' | 'list_enrolled_beta_programs' + | 'list_entities' + | 'list_role_permissions' | 'list_service_transfers' | 'list_user_delegate_accounts' | 'list_user_grants' + | 'list_user_permissions' | 'revoke_profile_app' | 'revoke_profile_device' | 'send_profile_phone_number_verification_code' @@ -254,8 +257,11 @@ export type AccountViewer = | 'list_available_services' | 'list_default_firewalls' | 'list_enrolled_beta_programs' + | 'list_entities' + | 'list_role_permissions' | 'list_service_transfers' | 'list_user_grants' + | 'list_user_permissions' | 'view_account' | 'view_account_login' | 'view_account_settings' diff --git a/packages/manager/src/features/IAM/Roles/Roles.test.tsx b/packages/manager/src/features/IAM/Roles/Roles.test.tsx index 680fb22cd6b..9f3cb0e7450 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.test.tsx @@ -59,8 +59,7 @@ describe('RolesLanding', () => { const mockPermissions = accountRolesFactory.build(); queryMocks.usePermissions.mockReturnValue({ data: { - view_account: true, - is_account_admin: true, + list_role_permissions: true, }, }); queryMocks.useAccountRoles.mockReturnValue({ @@ -76,8 +75,7 @@ describe('RolesLanding', () => { it('should show an error message if user does not have permissions', () => { queryMocks.usePermissions.mockReturnValue({ data: { - view_account: false, - is_account_admin: false, + list_role_permissions: false, }, }); @@ -90,8 +88,7 @@ describe('RolesLanding', () => { it('should not show the default roles panel for non-child accounts', () => { queryMocks.usePermissions.mockReturnValue({ data: { - view_account: true, - is_account_admin: true, + list_role_permissions: true, }, }); queryMocks.useProfile.mockReturnValue({ data: { user_type: 'parent' } }); @@ -109,8 +106,7 @@ describe('RolesLanding', () => { it('should show the default roles panel for child accounts', () => { queryMocks.usePermissions.mockReturnValue({ data: { - view_account: true, - is_account_admin: true, + list_role_permissions: true, }, }); queryMocks.useProfile.mockReturnValue({ data: { user_type: 'child' } }); diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 21a1cabbdc5..bed8fcf04e2 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -13,10 +13,10 @@ import { DefaultRolesPanel } from './Defaults/DefaultRolesPanel'; export const RolesLanding = () => { const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( 'account', - ['view_account', 'is_account_admin'] + ['list_role_permissions'] ); const { data: accountRoles, isLoading } = useAccountRoles( - permissions?.view_account + permissions?.list_role_permissions ); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { isChildAccount, isProfileLoading } = useDelegationRole(); @@ -33,7 +33,7 @@ export const RolesLanding = () => { return ; } - if (!(permissions?.view_account || permissions?.is_account_admin)) { + if (!permissions?.list_role_permissions) { return ( You do not have permission to view roles. ); diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx index 323e5bfb343..5c0da4c509f 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -60,6 +60,7 @@ export const AssignedEntitiesTable = ({ username }: Props) => { const { data: permissions } = usePermissions('account', [ 'is_account_admin', 'update_default_delegate_access', + 'list_entities', ]); const { isDefaultDelegationRolesForChildAccount } = @@ -106,7 +107,9 @@ export const AssignedEntitiesTable = ({ username }: Props) => { data: entities, error: entitiesError, isLoading: entitiesLoading, - } = useAllAccountEntities({}); + } = useAllAccountEntities({ + enabled: permissions?.list_entities, + }); const { data: assignedUserRoles, diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx index daf4d8b6490..824b54d6568 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx @@ -20,30 +20,27 @@ import { UsernamePanel } from './UsernamePanel'; export const UserProfile = () => { const { username } = useParams({ from: '/iam/users/$username' }); const { data: permissions } = usePermissions('account', [ - 'is_account_admin', - 'view_account', + 'view_user', + 'update_user', + 'delete_user', + 'list_user_permissions', ]); - const isAccountAdmin = permissions?.is_account_admin; - const { data: user, error, isLoading, - } = useAccountUser( - username ?? '', - isAccountAdmin || permissions?.view_account - ); + } = useAccountUser(username ?? '', permissions?.view_user); const { data: assignedRoles } = useUserRoles( username ?? '', - isAccountAdmin || permissions?.view_account + permissions?.list_user_permissions ); if (isLoading) { return ; } - if (!(isAccountAdmin || permissions?.view_account)) { + if (!permissions?.view_user || !permissions?.list_user_permissions) { return ( You do not have permission to view this user's details. @@ -67,9 +64,15 @@ export const UserProfile = () => { sx={(theme) => ({ marginTop: theme.tokens.spacing.S16 })} > - + - + ); diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx index d608931073c..2fcde36d478 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx @@ -74,7 +74,7 @@ describe('UserEntities', () => { }); queryMocks.usePermissions.mockReturnValue({ data: { - is_account_admin: true, + list_entities: true, }, }); }); @@ -155,8 +155,9 @@ describe('UserEntities', () => { it('should not render if user does not have permissions', () => { queryMocks.usePermissions.mockReturnValue({ data: { - is_account_admin: false, - view_account: false, + list_entities: false, + view_user: false, + list_role_permissions: false, }, }); diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx index a0c3bb39fe9..7eb28064e5b 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx @@ -24,22 +24,17 @@ export const UserEntities = () => { const theme = useTheme(); const { username } = useParams({ from: '/iam/users/$username' }); const { data: permissions } = usePermissions('account', [ - 'is_account_admin', - 'view_account', + 'view_user', + 'list_entities', + 'list_user_permissions', ]); const { data: assignedRoles, isLoading, error: assignedRolesError, - } = useUserRoles( - username ?? '', - permissions?.is_account_admin || permissions?.view_account - ); + } = useUserRoles(username ?? '', permissions?.list_user_permissions); - const { error } = useAccountUser( - username ?? '', - permissions?.is_account_admin || permissions?.view_account - ); + const { error } = useAccountUser(username ?? '', permissions?.view_user); const hasAssignedRoles = assignedRoles ? assignedRoles.entity_access.length > 0 @@ -49,7 +44,7 @@ export const UserEntities = () => { return ; } - if (!(permissions?.is_account_admin || permissions?.view_account)) { + if (!permissions?.list_entities) { return ( You do not have permission to view this user's entities. diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx index 3565e7d17c2..74bc7b3d757 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx @@ -73,7 +73,7 @@ describe('UserRoles', () => { }); queryMocks.usePermissions.mockReturnValue({ data: { - is_account_admin: true, + view_user: true, }, }); }); diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx index f2dc0dd8759..32d4055076a 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx @@ -24,7 +24,8 @@ export const UserRoles = () => { const { username } = useParams({ from: '/iam/users/$username' }); const { data: permissions } = usePermissions('account', [ 'is_account_admin', - 'view_account', + 'view_user', + 'list_user_permissions', ]); const theme = useTheme(); @@ -32,15 +33,9 @@ export const UserRoles = () => { data: assignedRoles, isLoading, error: assignedRolesError, - } = useUserRoles( - username ?? '', - permissions?.is_account_admin || permissions?.view_account - ); + } = useUserRoles(username ?? '', permissions?.list_user_permissions); - const { error } = useAccountUser( - username ?? '', - permissions?.is_account_admin || permissions?.view_account - ); + const { error } = useAccountUser(username ?? '', permissions?.view_user); const hasAssignedRoles = assignedRoles ? assignedRoles.account_access.length > 0 || @@ -51,7 +46,7 @@ export const UserRoles = () => { return ; } - if (!(permissions?.is_account_admin || permissions?.view_account)) { + if (!permissions?.view_user) { return ( You do not have permission to view this user's roles. diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx index 099ed62a8fe..c8af1603226 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx @@ -30,11 +30,11 @@ export const UserRow = ({ onDelete, user }: Props) => { const { data: permissions } = usePermissions('account', [ 'delete_user', 'is_account_admin', - 'view_account', + 'view_user', ]); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const canViewUser = permissions.view_account; + const canViewUser = permissions.view_user; // Determine if the current user is a child account with isIAMDelegationEnabled enabled // If so, we need to show the 'User type' column in the table diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index 8946ff22953..57a4c3e54e4 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -43,7 +43,7 @@ export const UsersLanding = () => { const theme = useTheme(); const { data: permissions } = usePermissions('account', [ 'create_user', - 'view_account', + 'view_user', ]); const pagination = usePaginationV2({ currentRoute: '/iam/users', @@ -166,7 +166,7 @@ export const UsersLanding = () => { }, }} debounceTime={250} - disabled={!permissions?.view_account} + disabled={!permissions?.view_user} errorText={searchError?.message} hideLabel isSearching={isFetching} diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx index 18c0681f754..d2c4ccb2363 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx @@ -44,7 +44,7 @@ describe('UsersActionMenu', () => { permissions={{ is_account_admin: true, delete_user: true, - view_account: true, + view_user: true, }} username="test_user" /> @@ -100,7 +100,7 @@ describe('UsersActionMenu', () => { permissions={{ is_account_admin: true, delete_user: true, - view_account: true, + view_user: true, }} username="current_user" /> diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx index 53b5907ca99..9c752574b9a 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx @@ -10,7 +10,7 @@ import type { PickPermissions, UserType } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; type UserActionMenuPermissions = PickPermissions< - 'delete_user' | 'is_account_admin' | 'view_account' + 'delete_user' | 'is_account_admin' | 'view_user' >; interface Props { @@ -29,7 +29,7 @@ export const UsersActionMenu = (props: Props) => { useDelegationRole(); const isAccountAdmin = permissions.is_account_admin; - const isAccountViewer = permissions.view_account; + const canViewUser = permissions.view_user; const canDeleteUser = isAccountAdmin || permissions.delete_user; const isDelegateUser = userType === 'delegate'; @@ -47,8 +47,8 @@ export const UsersActionMenu = (props: Props) => { }); }, hidden: shouldHideForChildDelegate, - disabled: !isAccountViewer, - tooltip: !isAccountViewer + disabled: !canViewUser, + tooltip: !canViewUser ? 'You do not have permission to view user details.' : undefined, title: 'View User Details', @@ -60,8 +60,8 @@ export const UsersActionMenu = (props: Props) => { params: { username }, }); }, - disabled: !isAccountViewer, - tooltip: !isAccountViewer + disabled: !canViewUser, + tooltip: !canViewUser ? 'You do not have permission to view assigned roles.' : undefined, title: 'View Assigned Roles', @@ -73,8 +73,8 @@ export const UsersActionMenu = (props: Props) => { params: { username }, }); }, - disabled: !isAccountViewer, - tooltip: !isAccountViewer + disabled: !canViewUser, + tooltip: !canViewUser ? 'You do not have permission to view entity access.' : undefined, title: 'View Entity Access', From 9d9f778e39ce022388cc8deaa0b2e312354d6d0f Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:42:02 -0500 Subject: [PATCH 20/58] test: [UIE-9996] - Fix `chooseRegion` mock region test failures when passing override region (#13277) * Fix issue when checking override region capabilities when test depends on mock regions * Added changeset: Fix issue in 'chooseRegion' util when specifying an override region --- .../manager/.changeset/pr-13277-tests-1768331133173.md | 5 +++++ packages/manager/cypress/support/util/regions.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13277-tests-1768331133173.md diff --git a/packages/manager/.changeset/pr-13277-tests-1768331133173.md b/packages/manager/.changeset/pr-13277-tests-1768331133173.md new file mode 100644 index 00000000000..4a3d5148e02 --- /dev/null +++ b/packages/manager/.changeset/pr-13277-tests-1768331133173.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix issue in 'chooseRegion' util when specifying an override region ([#13277](https://github.com/linode/manager/pull/13277)) diff --git a/packages/manager/cypress/support/util/regions.ts b/packages/manager/cypress/support/util/regions.ts index 6c399851cbe..8fb42e0da7f 100644 --- a/packages/manager/cypress/support/util/regions.ts +++ b/packages/manager/cypress/support/util/regions.ts @@ -326,8 +326,12 @@ const resolveSearchRegions = ( ]; // If the user has specified an override region for this run, it takes precedent - // over any other specified criteria. - if (overrideRegion && detectOverrideRegion) { + // over any other specified criteria unless mock regions are passed in `options`. + if ( + overrideRegion && + detectOverrideRegion && + (!options?.regions || options.regions.length === 0) + ) { // TODO Consider skipping instead of failing when test isn't applicable to override region. if (!regionHasCapabilities(overrideRegion, requiredCapabilities)) { throw new Error( From d5498910f6764d6d7dffac6d9217aa2e7f9bbc67 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 14 Jan 2026 12:31:54 +0530 Subject: [PATCH 21/58] upcoming: [DI-29059] - Add delete action for user alert channels (#13256) * upcoming: [DI-29059] - Add delete action, related handlers, tests * upcoming: [DI-29059] - Fix tc * upcoming: [DI-29059] - Update type * upcoming: [DI-29059] - Update the statements according to latest figma * upcoming: [DI-29059] - Add changesets * upcoming: [DI-29059] - Address suggestions for delete state * upcoming: [DI-29059] - Update as per dev --------- Co-authored-by: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> --- ...r-13256-upcoming-features-1767858904787.md | 5 + packages/api-v4/src/cloudpulse/alerts.ts | 8 + packages/api-v4/src/cloudpulse/types.ts | 7 + ...r-13256-upcoming-features-1767858919409.md | 5 + .../TypeToConfirmDialog.tsx | 1 + .../NotificationChannelActionMenu.tsx | 14 +- .../NotificationChannelListTable.test.tsx | 180 ++++++++++++++++++ .../NotificationChannelListTable.tsx | 81 +++++++- .../NotificationChannelTableRow.test.tsx | 2 + .../NotificationChannelTableRow.tsx | 1 + .../NotificationChannels/Utils/utils.ts | 66 ++++--- .../features/CloudPulse/Alerts/constants.ts | 9 + packages/manager/src/mocks/serverHandlers.ts | 8 + .../manager/src/queries/cloudpulse/alerts.ts | 25 +++ 14 files changed, 384 insertions(+), 28 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13256-upcoming-features-1767858904787.md create mode 100644 packages/manager/.changeset/pr-13256-upcoming-features-1767858919409.md diff --git a/packages/api-v4/.changeset/pr-13256-upcoming-features-1767858904787.md b/packages/api-v4/.changeset/pr-13256-upcoming-features-1767858904787.md new file mode 100644 index 00000000000..6b6a2d7d0e4 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13256-upcoming-features-1767858904787.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +CloudPulse-Alerts: Add `DeleteChannelPayload` type and request for deletion of a notification channel ([#13256](https://github.com/linode/manager/pull/13256)) diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 33e59842ada..572aeec8740 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -172,3 +172,11 @@ export const updateNotificationChannel = ( setMethod('PUT'), setData(data, editNotificationChannelPayloadSchema), ); + +export const deleteNotificationChannel = (channelId: number) => + Request( + setURL( + `${API_ROOT}/monitor/alert-channels/${encodeURIComponent(channelId)}`, + ), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 43f725b6c7d..96a3858f50a 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -489,3 +489,10 @@ export interface EditNotificationChannelPayloadWithId */ channelId: number; } + +export interface DeleteChannelPayload { + /** + * The ID of the channel to delete. + */ + channelId: number; +} diff --git a/packages/manager/.changeset/pr-13256-upcoming-features-1767858919409.md b/packages/manager/.changeset/pr-13256-upcoming-features-1767858919409.md new file mode 100644 index 00000000000..dfa2fb60ce5 --- /dev/null +++ b/packages/manager/.changeset/pr-13256-upcoming-features-1767858919409.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Alerts: Add support for delete action for user alert channels ([#13256](https://github.com/linode/manager/pull/13256)) diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index fc3f9e1337f..40b33ec8478 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -37,6 +37,7 @@ interface EntityInfo { | 'Managed Credential' | 'Managed Service Monitor' | 'NodeBalancer' + | 'Notification Channel' | 'Placement Group' | 'Subnet' | 'Volume' diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx index 17d502b814a..3baf9489818 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx @@ -7,6 +7,10 @@ import { getNotificationChannelActionsList } from '../Utils/utils'; import type { AlertNotificationType } from '@linode/api-v4'; export interface NotificationChannelActionHandlers { + /** + * Callback for delete action + */ + handleDelete: () => void; /** * Callback for show details action */ @@ -18,6 +22,10 @@ export interface NotificationChannelActionHandlers { } export interface NotificationChannelActionMenuProps { + /** + * Number of alerts associated with the notification channel + */ + alertsCount: number; /** * The label of the Notification Channel */ @@ -34,12 +42,14 @@ export interface NotificationChannelActionMenuProps { export const NotificationChannelActionMenu = ( props: NotificationChannelActionMenuProps ) => { - const { channelLabel, handlers, notificationType } = props; + const { channelLabel, handlers, notificationType, alertsCount } = props; return ( diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx index 6f74321299f..6703ebba574 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx @@ -6,10 +6,32 @@ import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; import { formatDate } from 'src/utilities/formatDate'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { + DELETE_CHANNEL_FAILED_MESSAGE, + DELETE_CHANNEL_SUCCESS_MESSAGE, + DELETE_CHANNEL_TOOLTIP_TEXT, +} from '../../constants'; import { NotificationChannelListTable } from './NotificationChannelListTable'; +const ALERT_TYPE = 'alerts-definitions'; +const ALERT_URL = 'monitor/alert-channels/{id}/alerts'; + const mockScrollToElement = vi.fn(); +const queryMocks = vi.hoisted(() => ({ + mutateAsync: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/alerts', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/alerts'); + return { + ...actual, + useDeleteNotificationChannel: vi.fn(() => ({ + mutateAsync: queryMocks.mutateAsync, + })), + }; +}); + describe('NotificationChannelListTable', () => { it('should render the notification channel table headers', () => { renderWithTheme( @@ -152,4 +174,162 @@ describe('NotificationChannelListTable', () => { screen.getByRole('button', { name: /next/i }); }); + + it('should not show delete action for system channels', async () => { + const channel = notificationChannelFactory.build({ + type: 'system', + }); + + renderWithTheme( + + ); + + const actionMenu = screen.getByRole('button', { + name: `Action menu for Notification Channel ${channel.label}`, + }); + + await userEvent.click(actionMenu); + expect(screen.queryByTestId('Delete')).not.toBeInTheDocument(); + }); + + it('should disable delete if the user channel has alerts and show tooltip', async () => { + const channel = notificationChannelFactory.build({ + alerts: { + type: ALERT_TYPE, + alert_count: 3, + url: ALERT_URL, + }, + type: 'user', + }); + + renderWithTheme( + + ); + + const actionMenu = screen.getByRole('button', { + name: `Action menu for Notification Channel ${channel.label}`, + }); + + await userEvent.click(actionMenu); + expect(screen.getByTestId('Delete')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + + const tooltip = screen.getByLabelText(DELETE_CHANNEL_TOOLTIP_TEXT); + expect(tooltip).toBeInTheDocument(); + }); + + it('should open delete confirmation dialog when delete is clicked', async () => { + const user = userEvent.setup(); + const channel = notificationChannelFactory.build({ + label: 'test_channel', + alerts: { + type: ALERT_TYPE, + alert_count: 0, + url: ALERT_URL, + }, + }); + + renderWithTheme( + + ); + + await user.click( + screen.getByRole('button', { + name: `Action menu for Notification Channel ${channel.label}`, + }) + ); + await user.click(screen.getByText('Delete')); + + expect(screen.getByText(`Delete ${channel.label}?`)).toBeVisible(); + }); + + it('should show success snackbar when deleting notification channel succeeds', async () => { + queryMocks.mutateAsync.mockResolvedValue({}); + const user = userEvent.setup(); + const channel = notificationChannelFactory.build({ + label: 'Channel to be deleted', + alerts: { + type: ALERT_TYPE, + alert_count: 0, + url: ALERT_URL, + }, + }); + + renderWithTheme( + + ); + + await user.click( + screen.getByRole('button', { + name: `Action menu for Notification Channel ${channel.label}`, + }) + ); + await user.click(screen.getByText('Delete')); + + expect(screen.getByText(`Delete ${channel.label}?`)).toBeVisible(); + + // Type the channel label to confirm + const input = screen.getByLabelText('Notification Channel Label'); + await user.type(input, channel.label); + await user.click(screen.getByRole('button', { name: 'Delete' })); + expect(screen.getByText(DELETE_CHANNEL_SUCCESS_MESSAGE)).toBeVisible(); + }); + + it('should show error snackbar when deleting notification channel fails', async () => { + const user = userEvent.setup(); + const channel = notificationChannelFactory.build({ + label: 'Channel to be deleted', + alerts: { + alert_count: 0, + url: ALERT_URL, + type: ALERT_TYPE, + }, + }); + + queryMocks.mutateAsync.mockRejectedValue([ + { reason: DELETE_CHANNEL_FAILED_MESSAGE }, + ]); + + renderWithTheme( + + ); + + await user.click( + screen.getByRole('button', { + name: `Action menu for Notification Channel ${channel.label}`, + }) + ); + await user.click(screen.getByText('Delete')); + + expect(screen.getByText(`Delete ${channel.label}?`)).toBeVisible(); + + // Type the channel label to confirm + const input = screen.getByLabelText('Notification Channel Label'); + await user.type(input, channel.label); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByText(DELETE_CHANNEL_FAILED_MESSAGE)).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx index e44b2027e08..0a3ae3fffd3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx @@ -1,6 +1,7 @@ -import { TooltipIcon } from '@linode/ui'; +import { Notice, TooltipIcon, Typography } from '@linode/ui'; import { GridLegacy, TableBody, TableHead } from '@mui/material'; import { useNavigate } from '@tanstack/react-router'; +import { useSnackbar } from 'notistack'; import React from 'react'; import Paginate from 'src/components/Paginate'; @@ -10,16 +11,26 @@ import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { useDeleteNotificationChannel } from 'src/queries/cloudpulse/alerts'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { + DELETE_CHANNEL_FAILED_MESSAGE, + DELETE_CHANNEL_SUCCESS_MESSAGE, +} from '../../constants'; import { ChannelAlertsTooltipText, ChannelListingTableLabelMap, } from './constants'; import { NotificationChannelTableRow } from './NotificationChannelTableRow'; -import type { APIError, NotificationChannel } from '@linode/api-v4'; +import type { + APIError, + DeleteChannelPayload, + NotificationChannel, +} from '@linode/api-v4'; import type { Order } from '@linode/utilities'; export interface NotificationChannelListTableProps { @@ -46,6 +57,13 @@ export const NotificationChannelListTable = React.memo( (props: NotificationChannelListTableProps) => { const { error, isLoading, notificationChannels, scrollToElement } = props; const navigate = useNavigate(); + const { enqueueSnackbar } = useSnackbar(); + const { mutateAsync: deleteChannel, isPending: isDeleting } = + useDeleteNotificationChannel(); + + const [selectedChannel, setSelectedChannel] = + React.useState(null); + const [isDialogOpen, setIsDialogOpen] = React.useState(false); const handleDetails = ({ id }: NotificationChannel) => { navigate({ @@ -60,6 +78,39 @@ export const NotificationChannelListTable = React.memo( params: { channelId: id }, }); }; + + const handleDelete = React.useCallback((channel: NotificationChannel) => { + setSelectedChannel(channel); + setIsDialogOpen(true); + }, []); + + const handleDeleteConfirm = React.useCallback(() => { + if (!selectedChannel) { + return; + } + + const payload: DeleteChannelPayload = { + channelId: selectedChannel.id, + }; + + deleteChannel(payload) + .then(() => { + enqueueSnackbar(DELETE_CHANNEL_SUCCESS_MESSAGE, { + variant: 'success', + }); + }) + .catch((deleteError: APIError[]) => { + const errorResponse = getAPIErrorOrDefault( + deleteError, + DELETE_CHANNEL_FAILED_MESSAGE + ); + enqueueSnackbar(errorResponse[0].reason, { variant: 'error' }); + }) + .finally(() => { + setIsDialogOpen(false); + }); + }, [deleteChannel, enqueueSnackbar, selectedChannel]); + const _error = error ? getAPIErrorOrDefault( error, @@ -180,6 +231,7 @@ export const NotificationChannelListTable = React.memo( handlers={{ handleDetails: () => handleDetails(channel), handleEdit: () => handleEdit(channel), + handleDelete: () => handleDelete(channel), }} key={channel.id} notificationChannel={channel} @@ -206,6 +258,31 @@ export const NotificationChannelListTable = React.memo( pageSize={pageSize} sx={{ border: 0 }} /> + { + setIsDialogOpen(false); + setSelectedChannel(null); + }} + open={isDialogOpen} + title={`Delete ${selectedChannel?.label ?? ''}?`} + > + + + Warning: Deleting your Notification Channel + will result in permanent data loss. + + + ); }} diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx index 2218fe4dbdb..db975c660c5 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx @@ -10,9 +10,11 @@ import { NotificationChannelTableRow } from './NotificationChannelTableRow'; describe('NotificationChannelTableRow', () => { const mockHandleDetails = vi.fn(); const mockHandleEdit = vi.fn(); + const mockHandleDelete = vi.fn(); const handlers = { handleDetails: mockHandleDetails, handleEdit: mockHandleEdit, + handleDelete: mockHandleDelete, }; it('should render a notification channel row with all fields', () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx index bb396d6bafd..d62ad069286 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx @@ -67,6 +67,7 @@ export const NotificationChannelTableRow = ( data-qa-notification-channel-action-cell={`notification-channel-${id}`} > => ({ - system: [ - { - onClick: handleDetails, - title: 'Show Details', - }, - ], - user: [ - { - onClick: handleDetails, - title: 'Show Details', - }, - { - onClick: handleEdit, - title: 'Edit', - }, - ], -}); +interface NotificationChannelActionsListProps { + /** + * Number of alerts associated with the notification channel + */ + alertsCount: number; + /** + * Handlers for actions like edit, delete, show details, etc., + */ + handlers: NotificationChannelActionHandlers; +} +export const getNotificationChannelActionsList = ( + props: NotificationChannelActionsListProps +): Record => { + const { handlers, alertsCount } = props; + const { handleDetails, handleDelete, handleEdit } = handlers; + return { + system: [ + { + onClick: handleDetails, + title: 'Show Details', + }, + ], + user: [ + { + onClick: handleDetails, + title: 'Show Details', + }, + { + onClick: handleEdit, + title: 'Edit', + }, + { + onClick: handleDelete, + title: 'Delete', + disabled: alertsCount > 0, + tooltip: alertsCount > 0 ? DELETE_CHANNEL_TOOLTIP_TEXT : undefined, + }, + ], + }; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index e5a2525a576..7f51ea2e586 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -313,3 +313,12 @@ export const UPDATE_CHANNEL_SUCCESS_MESSAGE = export const UPDATE_CHANNEL_FAILED_MESSAGE = 'Failed to update the notification channel. Verify the details and try again.'; + +export const DELETE_CHANNEL_TOOLTIP_TEXT = + 'This channel is linked to active alerts. Please reassign or remove those alerts before deleting this channel.'; + +export const DELETE_CHANNEL_SUCCESS_MESSAGE = + 'Notification channel deleted successfully.'; + +export const DELETE_CHANNEL_FAILED_MESSAGE = + 'Failed to delete the notification channel. Ensure it is not in use and try again.'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 34c9622754d..0ad13c6fce6 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3656,6 +3656,11 @@ export const handlers = [ recipient_type: 'user', }, }, + alerts: { + alert_count: 0, + type: 'alerts-definitions', + url: 'monitor/alert-channels/{id}/alerts', + }, }) ); notificationChannels.push( @@ -4540,4 +4545,7 @@ export const handlers = [ }) ); }), + http.delete('*/v4beta/monitor/alert-channels/:channelId', () => { + return HttpResponse.json({}); + }), ]; diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 71e5d75ba30..7365fbd385c 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -4,6 +4,7 @@ import { createNotificationChannel, deleteAlertDefinition, deleteEntityFromAlert, + deleteNotificationChannel, editAlertDefinition, updateNotificationChannel, updateServiceAlerts, @@ -25,6 +26,7 @@ import type { CreateAlertDefinitionPayload, CreateNotificationChannelPayload, DeleteAlertPayload, + DeleteChannelPayload, EditAlertPayloadWithService, EditNotificationChannelPayloadWithId, EntityAlertUpdatePayload, @@ -340,3 +342,26 @@ export const useNotificationChannelQuery = (channelId: number) => { queryFactory.notificationChannels._ctx.channelById(channelId) ); }; + +export const useDeleteNotificationChannel = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ channelId }) => deleteNotificationChannel(channelId), + onSuccess: (_, { channelId }) => { + queryClient.cancelQueries({ + queryKey: queryFactory.notificationChannels._ctx.all().queryKey, + }); + queryClient.setQueryData( + queryFactory.notificationChannels._ctx.all().queryKey, + (oldData) => { + return oldData?.filter(({ id }) => id !== channelId) ?? []; + } + ); + queryClient.removeQueries({ + queryKey: + queryFactory.notificationChannels._ctx.channelById(channelId) + .queryKey, + }); + }, + }); +}; From c2a91158284081545b9166b128ac334e8f65fedc Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:03:21 +0530 Subject: [PATCH 22/58] change: [UIE-9861] - Handle Incompatibility of linode interfaces in create LKE flow (#13272) --- .../pr-13272-changed-1768292265959.md | 5 ++++ .../features/Account/NetworkInterfaceType.tsx | 4 +++- .../CreateCluster/CreateCluster.tsx | 23 +++++++++++++++++++ .../KubeCheckoutBar/KubeCheckoutBar.test.tsx | 16 +++++++++++++ .../KubeCheckoutBar/KubeCheckoutBar.tsx | 5 +++- 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13272-changed-1768292265959.md diff --git a/packages/manager/.changeset/pr-13272-changed-1768292265959.md b/packages/manager/.changeset/pr-13272-changed-1768292265959.md new file mode 100644 index 00000000000..790f22fe5cb --- /dev/null +++ b/packages/manager/.changeset/pr-13272-changed-1768292265959.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Handle Incompatibility of linode interfaces in create LKE flow ([#13272](https://github.com/linode/manager/pull/13272)) diff --git a/packages/manager/src/features/Account/NetworkInterfaceType.tsx b/packages/manager/src/features/Account/NetworkInterfaceType.tsx index 9e42490ddd0..0f4903de44d 100644 --- a/packages/manager/src/features/Account/NetworkInterfaceType.tsx +++ b/packages/manager/src/features/Account/NetworkInterfaceType.tsx @@ -75,7 +75,9 @@ export const NetworkInterfaceType = () => { return ( - Network Interface Type + + Network Interface Type + diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index ea7063d3823..40f25f36e57 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -1,4 +1,5 @@ import { + useAccountSettings, useAllTypes, useMutateAccountAgreements, useRegionsQuery, @@ -12,6 +13,7 @@ import { Select, Stack, TextField, + Typography, } from '@linode/ui'; import { plansNoticesUtils, scrollErrorIntoViewV2 } from '@linode/utilities'; import { createKubeClusterWithRequiredACLSchema } from '@linode/validation'; @@ -31,6 +33,7 @@ import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorMessage } from 'src/components/ErrorMessage'; import { LandingHeader } from 'src/components/LandingHeader'; +import { Link } from 'src/components/Link'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { getRestrictedResourceText } from 'src/features/Account/utils'; @@ -123,6 +126,7 @@ export const CreateCluster = () => { const { data, error: regionsError } = useRegionsQuery(); const regionsData = data ?? []; + const { data: accountSettings } = useAccountSettings(); const { showAPL } = useAPLAvailability(); const [ipV4Addr, setIPv4Addr] = React.useState([ stringToExtendedIP(''), @@ -238,6 +242,9 @@ export const CreateCluster = () => { globalGrantType: 'add_lkes', }); + const isInterfaceIncompatible = + accountSettings?.interfaces_for_new_linodes === 'linode_only'; + const { data: allTypes, error: typesError, @@ -488,6 +495,21 @@ export const CreateCluster = () => { variant="error" /> )} + {isInterfaceIncompatible && ( + + + Your account’s{' '} + + Network Interface Type setting is incompatible with LKE + + . To create a cluster, update this setting to allow the option + for Configuration Profile Interfaces.{' '} + + Account settings + + + + )} { ? UNKNOWN_PRICE : highAvailabilityPrice } + isInterfaceIncompatible={isInterfaceIncompatible} pools={nodePools} region={selectedRegion?.id} regionsData={regionsData} diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx index 2be6c62667b..3b93be2a8de 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx @@ -55,6 +55,22 @@ describe('KubeCheckoutBar', () => { ); }); + it('should disable create button if Network Interface Type setting is linode only', async () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + nodePools: [nodePoolFactory.build()], + }, + }, + }); + + expect(getByText('Create Cluster').closest('button')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + it('should render a section for each pool', async () => { const { queryAllByTestId } = renderWithThemeAndHookFormContext({ component: , diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx index 786684b08a0..d1deb2b7eb1 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx @@ -43,6 +43,7 @@ export interface Props { hasAgreed: boolean; highAvailability?: boolean; highAvailabilityPrice: string; + isInterfaceIncompatible?: boolean; pools: CreateNodePoolData[]; region: string | undefined; regionsData: Region[]; @@ -58,6 +59,7 @@ export const KubeCheckoutBar = (props: Props) => { hasAgreed, highAvailability, highAvailabilityPrice, + isInterfaceIncompatible, pools, region, regionsData, @@ -98,7 +100,8 @@ export const KubeCheckoutBar = (props: Props) => { needsAPool || gdprConditions || (haConditions && !enterprisePrice) || - !region + !region || + isInterfaceIncompatible ); if (isLoading) { From 64060c2611f78b0319857e6f098d7d24955652ae Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:12:37 +0530 Subject: [PATCH 23/58] change: [UIE-9859] - Improvements in Add Network interface drawer (#13264) --- .../pr-13264-changed-1768219863915.md | 5 ++ .../AddInterfaceForm.test.tsx | 80 +++++++++++++++++-- .../AddInterfaceDrawer/AddInterfaceForm.tsx | 52 +++++++++++- .../AddInterfaceDrawer/InterfaceType.tsx | 27 ++++++- 4 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-13264-changed-1768219863915.md diff --git a/packages/manager/.changeset/pr-13264-changed-1768219863915.md b/packages/manager/.changeset/pr-13264-changed-1768219863915.md new file mode 100644 index 00000000000..a34f416391c --- /dev/null +++ b/packages/manager/.changeset/pr-13264-changed-1768219863915.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improvements in Add Network interface drawer ([#13264](https://github.com/linode/manager/pull/13264)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.test.tsx index bceeecee31e..61ddae5436d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.test.tsx @@ -1,3 +1,4 @@ +import { linodeInterfaceFactoryPublic } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -11,8 +12,23 @@ import { AddInterfaceForm } from './AddInterfaceForm'; const props = { linodeId: 0, onClose: vi.fn(), regionId: '' }; describe('AddInterfaceForm', () => { - it('renders radios for the interface types (Public, VPC, VLAN)', () => { - const { getByRole } = renderWithTheme(); + beforeEach(() => { + server.use( + http.get('*/linode/instances/:linodeId/interfaces', () => { + return HttpResponse.json({ + interfaces: [], + }); + }) + ); + }); + + it('renders radios for the interface types (Public, VPC, VLAN)', async () => { + const { getByRole, findByRole } = renderWithTheme( + + ); + + // Wait for the loading to complete and form to render + await findByRole('radio', { name: 'VPC' }); expect(getByRole('radio', { name: 'VPC' })).toBeInTheDocument(); expect(getByRole('radio', { name: 'Public' })).toBeInTheDocument(); @@ -20,30 +36,36 @@ describe('AddInterfaceForm', () => { }); it('renders a Firewall select if "VPC" is selected', async () => { - const { getByRole, getByLabelText } = renderWithTheme( + const { getByRole, getByLabelText, findByRole } = renderWithTheme( ); + // Wait for the loading to complete and form to render + await findByRole('radio', { name: 'VPC' }); await userEvent.click(getByRole('radio', { name: 'VPC' })); expect(getByLabelText('Firewall')).toBeVisible(); }); it('renders a Firewall select if "Public" is selected', async () => { - const { getByRole, getByLabelText } = renderWithTheme( + const { getByRole, getByLabelText, findByRole } = renderWithTheme( ); + // Wait for the loading to complete and form to render + await findByRole('radio', { name: 'Public' }); await userEvent.click(getByRole('radio', { name: 'Public' })); expect(getByLabelText('Firewall')).toBeVisible(); }); it('renders does not render a Firewall select if "VLAN" is selected', async () => { - const { getByRole, queryByLabelText } = renderWithTheme( + const { getByRole, queryByLabelText, findByRole } = renderWithTheme( ); + // Wait for the loading to complete and form to render + await findByRole('radio', { name: 'VLAN' }); await userEvent.click(getByRole('radio', { name: 'VLAN' })); expect(queryByLabelText('Firewall')).toBeNull(); @@ -67,12 +89,58 @@ describe('AddInterfaceForm', () => { }) ); - const { getByRole, findByDisplayValue } = renderWithTheme( + const { getByRole, findByDisplayValue, findByRole } = renderWithTheme( ); + // Wait for the loading to complete and form to render + await findByRole('radio', { name: 'VPC' }); await userEvent.click(getByRole('radio', { name: 'VPC' })); await findByDisplayValue(firewall.label); }); + + it('should show a warning notice on selection of VPC option if a Public interface already exists', async () => { + const mockPublicInterface = linodeInterfaceFactoryPublic.build(); + + server.use( + http.get('*/linode/instances/:linodeId/interfaces', () => { + return HttpResponse.json({ + interfaces: [mockPublicInterface], + }); + }) + ); + + const { getByRole, findByRole, getByText } = renderWithTheme( + + ); + + // Wait for the loading to complete and form to render + await findByRole('radio', { name: 'VPC' }); + await userEvent.click(getByRole('radio', { name: 'VPC' })); + expect( + getByText(/This Linode already has a public interface/) + ).toBeVisible(); + }); + + it('should disable Public interface radio button if a Public interface already exists', async () => { + const mockPublicInterface = linodeInterfaceFactoryPublic.build(); + + server.use( + http.get('*/linode/instances/:linodeId/interfaces', () => { + return HttpResponse.json({ + interfaces: [mockPublicInterface], + }); + }) + ); + + const { getByRole, findByRole } = renderWithTheme( + + ); + + // Wait for the loading to complete and form to render + await findByRole('radio', { name: 'Public' }); + + expect(getByRole('radio', { name: 'Public' })).toBeDisabled(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx index a7b32c09dc6..16f3e2d438a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx @@ -1,6 +1,9 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { useCreateLinodeInterfaceMutation } from '@linode/queries'; -import { Notice, Stack } from '@linode/ui'; +import { + useCreateLinodeInterfaceMutation, + useLinodeInterfacesQuery, +} from '@linode/queries'; +import { Box, CircleProgress, Notice, Stack, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; @@ -10,6 +13,7 @@ import { getLinodeInterfacePayload, } from 'src/features/Linodes/LinodeCreate/Networking/utilities'; +import { getLinodeInterfaceType } from '../utilities'; import { Actions } from './Actions'; import { InterfaceFirewall } from './InterfaceFirewall'; import { InterfaceType } from './InterfaceType'; @@ -32,6 +36,14 @@ export const AddInterfaceForm = (props: Props) => { const { mutateAsync } = useCreateLinodeInterfaceMutation(linodeId); + const { data: interfacesData, isPending: isLoadingInterfaces } = + useLinodeInterfacesQuery(linodeId); + + const existingInterfaces = + interfacesData?.interfaces.map((networkInterface) => + getLinodeInterfaceType(networkInterface) + ) ?? []; + const isPublicInterfacePresent = existingInterfaces.includes('Public'); const form = useForm({ defaultValues: { firewall_id: null, @@ -78,6 +90,21 @@ export const AddInterfaceForm = (props: Props) => { const selectedInterfacePurpose = form.watch('purpose'); + if (isLoadingInterfaces) { + return ( + + + + ); + } + return ( @@ -94,13 +121,30 @@ export const AddInterfaceForm = (props: Props) => { variant="error" /> )} - + {selectedInterfacePurpose === 'public' && } {selectedInterfacePurpose === 'vlan' && ( )} {selectedInterfacePurpose === 'vpc' && ( - + + {isPublicInterfacePresent && ( + + + This Linode already has a public interface. Having both a + VPC interface and a public interface is not recommended. If + you need public internet access, consider using the VPC’s + Public access option instead. + + + Each Linode includes one public IP address. To request + additional public IPs, please note that they incur a monthly + charge. + + + )} + + )} {selectedInterfacePurpose !== 'vlan' && } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx index 2b1816d1584..83465ed7068 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx @@ -5,6 +5,8 @@ import { FormHelperText, Radio, RadioGroup, + Stack, + TooltipIcon, } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; @@ -13,10 +15,15 @@ import { useController, useFormContext } from 'react-hook-form'; import { getDefaultFirewallForInterfacePurpose } from 'src/features/Linodes/LinodeCreate/Networking/utilities'; +import type { LinodeInterfaceType } from '../utilities'; import type { CreateInterfaceFormValues } from './utilities'; import type { InterfacePurpose } from '@linode/api-v4'; -export const InterfaceType = () => { +interface Props { + existingInterfaces?: LinodeInterfaceType[] | null; +} +export const InterfaceType = (props: Props) => { + const { existingInterfaces } = props; const queryClient = useQueryClient(); const { enqueueSnackbar } = useSnackbar(); @@ -72,7 +79,23 @@ export const InterfaceType = () => { sx={{ my: `0 !important` }} value={field.value ?? null} > - } label="Public" value="public" /> + } + disabled={existingInterfaces?.includes('Public')} + label={ + + Public + {existingInterfaces?.includes('Public') && ( + + )} + + } + value="public" + /> } label="VPC" value="vpc" /> } label="VLAN" value="vlan" /> From 173688abb26a18cdba0d15a0e56daa5adb5369ba Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Wed, 14 Jan 2026 13:57:18 +0100 Subject: [PATCH 24/58] feat: [UIE-9934] - IAM: reset to prev page when removing last role (#13268) * feat: [UIE-9934] - IAM: reset to prev page when removing last role * Added changeset: IAM: removing entity/role can cause an empty page --- .../.changeset/pr-13268-fixed-1768232528721.md | 5 +++++ .../AssignedEntitiesTable/AssignedEntitiesTable.tsx | 4 ++-- .../AssignedRolesTable/AssignedRolesTable.tsx | 13 ++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13268-fixed-1768232528721.md diff --git a/packages/manager/.changeset/pr-13268-fixed-1768232528721.md b/packages/manager/.changeset/pr-13268-fixed-1768232528721.md new file mode 100644 index 00000000000..94b2077232f --- /dev/null +++ b/packages/manager/.changeset/pr-13268-fixed-1768232528721.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM: removing entity/role can cause an empty page ([#13268](https://github.com/linode/manager/pull/13268)) diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx index 5c0da4c509f..0b96bc6f346 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -172,11 +172,11 @@ export const AssignedEntitiesTable = ({ username }: Props) => { const handleRemoveAssignmentDialogClose = () => { setIsRemoveAssignmentDialogOpen(false); - // If we just deleted the last one on a page, reset to the first page. + // If we just deleted the last one on a page, reset to the previous page. const removedLastOnPage = filteredAndSortedRoles.length % pagination.pageSize === 1; if (removedLastOnPage) { - pagination.handlePageChange(1); + pagination.handlePageChange(pagination.page - 1); } }; diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index 34ca789b505..fd03017e6eb 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -166,6 +166,17 @@ export const AssignedRolesTable = () => { setSelectedRole(role); }; + const handleRemoveRoleDialogClose = () => { + setIsUnassignRoleDialogOpen(false); + + // If we just deleted the last one on a page, reset to the previous page. + const removedLastOnPage = + filteredAndSortedRoles.length % pagination.pageSize === 1; + if (removedLastOnPage) { + pagination.handlePageChange(pagination.page - 1); + } + }; + const { data: accountRoles, isLoading: accountPermissionsLoading } = useAccountRoles(); const { data: entities, isLoading: entitiesLoading } = useAllAccountEntities( @@ -447,7 +458,7 @@ export const AssignedRolesTable = () => { role={selectedRole} /> setIsUnassignRoleDialogOpen(false)} + onClose={() => handleRemoveRoleDialogClose()} open={isUnassignRoleDialogOpen} role={selectedRole} /> From 6dbabd7730ebfbbe7fbd9ec99487649e900d457a Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:34:59 -0500 Subject: [PATCH 25/58] upcoming: [UIE-9327] - Add service URIs to Database summary tab (#13261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 - Display general Service URI for all databases under Connection details in the summary tab - If applicable, display PgBouncer connection details Service URI for postgres clusters with at least one connection pool ## How to test 🧪 ### Prerequisites (How to setup test environment) - Ensure the Database PgBouncer feature flag is enabled and turn on the legacy MSW ### Verification steps (How to verify changes) - [ ] Go to a mysql Database cluster's summary page and confirm that a general mysql service URI displays under Connection Details. There should not be a PgBouncer section. - [ ] Go to a postgres Database cluster's summary page and confirm that a general postgres service URI displays under Connection Details. There should be a PgBouncer section --- ...r-13261-upcoming-features-1768256606077.md | 5 + .../ConnectionDetailsHostRows.tsx | 16 +- .../DatabaseDetail/ConnectionDetailsRow.tsx | 9 +- .../DatabaseSummary/DatabaseCaCert.tsx | 107 ++++++++++++ .../DatabaseSummary/DatabaseSummary.tsx | 47 ++++- .../DatabaseSummaryConnectionDetails.style.ts | 39 ----- .../DatabaseSummaryConnectionDetails.tsx | 92 ++-------- .../DatabaseDetail/ServiceURI.test.tsx | 46 ++++- .../Databases/DatabaseDetail/ServiceURI.tsx | 163 +++++++++++------- 9 files changed, 342 insertions(+), 182 deletions(-) create mode 100644 packages/manager/.changeset/pr-13261-upcoming-features-1768256606077.md create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx diff --git a/packages/manager/.changeset/pr-13261-upcoming-features-1768256606077.md b/packages/manager/.changeset/pr-13261-upcoming-features-1768256606077.md new file mode 100644 index 00000000000..9145c9aec6b --- /dev/null +++ b/packages/manager/.changeset/pr-13261-upcoming-features-1768256606077.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add service URIs to Database summary tab ([#13261](https://github.com/linode/manager/pull/13261)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx index 8aa01b3b5a4..0cc591d1eac 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx @@ -20,6 +20,7 @@ import type { Database } from '@linode/api-v4/lib/databases/types'; interface ConnectionDetailsHostRowsProps { database: Database; + isSummaryTab?: boolean; } type HostContentMode = 'default' | 'private' | 'public'; @@ -30,7 +31,7 @@ type HostContentMode = 'default' | 'private' | 'public'; export const ConnectionDetailsHostRows = ( props: ConnectionDetailsHostRowsProps ) => { - const { database } = props; + const { database, isSummaryTab } = props; const { classes } = useStyles(); const sxTooltipIcon = { @@ -136,21 +137,28 @@ export const ConnectionDetailsHostRows = ( return ( <> - + {getHostContent(hasVPC ? 'private' : 'default')} {hasPublicVPC && ( - + {getHostContent('public')} )} {getReadOnlyHostContent(hasVPC ? 'private' : 'default')} {hasPublicVPC && ( - + {getReadOnlyHostContent('public')} )} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx index cebfd7593c7..93c3f9be1d6 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx @@ -8,22 +8,25 @@ import { interface ConnectionDetailsRowProps { children: React.ReactNode; + isSummaryTab?: boolean; label: string; } export const ConnectionDetailsRow = (props: ConnectionDetailsRowProps) => { - const { children, label } = props; + const { children, label, isSummaryTab } = props; return ( <> {label} - {children} + + {children} + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx new file mode 100644 index 00000000000..9d8e840467c --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx @@ -0,0 +1,107 @@ +import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; +import { TooltipIcon } from '@linode/ui'; +import { downloadFile } from '@linode/utilities'; +import { styled } from '@mui/material/styles'; +import { Button } from 'akamai-cds-react-components'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import DownloadIcon from 'src/assets/icons/lke-download.svg'; +import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; + +import { sxTooltipIcon } from './DatabaseSummaryConnectionDetails'; + +import type { Database, SSLFields } from '@linode/api-v4'; + +interface Props { + database: Database; +} + +export const DatabaseCaCert = (props: Props) => { + const { database } = props; + const { enqueueSnackbar } = useSnackbar(); + const [isCACertDownloading, setIsCACertDownloading] = + React.useState(false); + + const handleDownloadCACertificate = () => { + setIsCACertDownloading(true); + getSSLFields(database.engine, database.id) + .then((response: SSLFields) => { + // Convert to utf-8 from base64 + try { + const decodedFile = window.atob(response.ca_certificate); + downloadFile(`${database.label}-ca-certificate.crt`, decodedFile); + setIsCACertDownloading(false); + } catch { + enqueueSnackbar('Error parsing your CA Certificate file', { + variant: 'error', + }); + setIsCACertDownloading(false); + return; + } + }) + .catch((errorResponse: any) => { + const error = getErrorStringOrDefault( + errorResponse, + 'Unable to download your CA Certificate' + ); + setIsCACertDownloading(false); + enqueueSnackbar(error, { variant: 'error' }); + }); + }; + + const disableDownloadCACertificateBtn = database.status === 'provisioning'; + + return ( + <> + + + Download CA Certificate + + {disableDownloadCACertificateBtn && ( + + + + )} + + ); +}; + +export const StyledCaCertButton = styled(Button, { + label: 'StyledCaCertButton', +})(({ theme }) => ({ + '&:hover': { + backgroundColor: 'transparent', + opacity: 0.7, + }, + '&[disabled]': { + '& g': { + stroke: theme.tokens.color.Neutrals[30], + }, + '&:hover': { + backgroundColor: 'inherit', + textDecoration: 'none', + }, + // Override disabled background color defined for dark mode + backgroundColor: 'transparent', + color: theme.tokens.color.Neutrals[30], + cursor: 'default', + }, + color: theme.palette.primary.main, + font: theme.font.bold, + fontSize: '0.875rem', + lineHeight: '1.125rem', + minHeight: 'auto', + minWidth: 'auto', + padding: 0, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index d04a3767284..79f7cdfae6c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -1,14 +1,32 @@ -import { Paper } from '@linode/ui'; +import { useDatabaseConnectionPoolsQuery } from '@linode/queries'; +import { Paper, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import ClusterConfiguration from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration'; import ConnectionDetails from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails'; +import { useFlags } from 'src/hooks/useFlags'; import { useDatabaseDetailContext } from '../DatabaseDetailContext'; +import { ServiceURI } from '../ServiceURI'; +import { DatabaseCaCert } from './DatabaseCaCert'; export const DatabaseSummary = () => { const { database } = useDatabaseDetailContext(); + const flags = useFlags(); + + const { data: connectionPools } = useDatabaseConnectionPoolsQuery( + database.id, + flags.databasePgBouncer, + {} + ); + + const showPgBouncerConnectionDetails = + flags.databasePgBouncer && + database.engine === 'postgresql' && + connectionPools && + connectionPools.data.length > 0; return ( @@ -29,7 +47,34 @@ export const DatabaseSummary = () => { > + {showPgBouncerConnectionDetails && ( + + + PgBouncer Connection Details + + + + )} + {database.ssl_connection && ( + + + + )} ); }; + +export const StyledButtonCtn = styled('div', { + label: 'StyledButtonCtn', +})(({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-end', + marginTop: '10px', + padding: `${theme.spacingFunction(8)} 0`, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts index cef7b59a304..556f28b7b1c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts @@ -3,45 +3,6 @@ import { makeStyles } from 'tss-react/mui'; import type { Theme } from '@mui/material/styles'; export const useStyles = makeStyles()((theme: Theme) => ({ - actionBtnsCtn: { - display: 'flex', - justifyContent: 'flex-end', - marginTop: '10px', - padding: `${theme.spacing(1)} 0`, - }, - caCertBtn: { - '& svg': { - marginRight: theme.spacing(), - }, - '&:hover': { - backgroundColor: 'transparent', - opacity: 0.7, - }, - '&[disabled]': { - '& g': { - stroke: theme.tokens.color.Neutrals[30], - }, - '&:hover': { - backgroundColor: 'inherit', - textDecoration: 'none', - }, - // Override disabled background color defined for dark mode - backgroundColor: 'transparent', - color: theme.tokens.color.Neutrals[30], - cursor: 'default', - }, - color: theme.palette.primary.main, - font: theme.font.bold, - fontSize: '0.875rem', - lineHeight: '1.125rem', - marginLeft: theme.spacing(), - minHeight: 'auto', - minWidth: 'auto', - padding: 0, - }, - tooltipIcon: { - alignContent: 'center', - }, connectionDetailsCtn: { '& p': { lineHeight: '1.5rem', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index a725b87d4fc..5329032f66c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -1,32 +1,28 @@ -import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; import { useDatabaseCredentialsQuery } from '@linode/queries'; import { Box, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; -import { downloadFile } from '@linode/utilities'; import { Button } from 'akamai-cds-react-components'; -import { useSnackbar } from 'notistack'; import * as React from 'react'; -import DownloadIcon from 'src/assets/icons/lke-download.svg'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Link } from 'src/components/Link'; import { DB_ROOT_USERNAME } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { isDefaultDatabase } from '../../utilities'; import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows'; import { ConnectionDetailsRow } from '../ConnectionDetailsRow'; +import { ServiceURI } from '../ServiceURI'; import { StyledGridContainer } from './DatabaseSummaryClusterConfiguration.style'; import { useStyles } from './DatabaseSummaryConnectionDetails.style'; -import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types'; +import type { Database } from '@linode/api-v4/lib/databases/types'; import type { Theme } from '@mui/material/styles'; interface Props { database: Database; } -const sxTooltipIcon = { +export const sxTooltipIcon = { marginLeft: '4px', padding: '0px', }; @@ -34,7 +30,6 @@ const sxTooltipIcon = { export const DatabaseSummaryConnectionDetails = (props: Props) => { const { database } = props; const { classes } = useStyles(); - const { enqueueSnackbar } = useSnackbar(); const flags = useFlags(); const isLegacy = database.platform !== 'rdbms-default'; const hasVPC = Boolean(database?.private_network?.vpc_id); @@ -42,8 +37,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { flags.databaseVpc && isDefaultDatabase(database); const [showCredentials, setShowPassword] = React.useState(false); - const [isCACertDownloading, setIsCACertDownloading] = - React.useState(false); const { data: credentials, @@ -72,35 +65,7 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { } }, [credentials, getDatabaseCredentials, showCredentials]); - const handleDownloadCACertificate = () => { - setIsCACertDownloading(true); - getSSLFields(database.engine, database.id) - .then((response: SSLFields) => { - // Convert to utf-8 from base64 - try { - const decodedFile = window.atob(response.ca_certificate); - downloadFile(`${database.label}-ca-certificate.crt`, decodedFile); - setIsCACertDownloading(false); - } catch (e) { - enqueueSnackbar('Error parsing your CA Certificate file', { - variant: 'error', - }); - setIsCACertDownloading(false); - return; - } - }) - .catch((errorResponse: any) => { - const error = getErrorStringOrDefault( - errorResponse, - 'Unable to download your CA Certificate' - ); - setIsCACertDownloading(false); - enqueueSnackbar(error, { variant: 'error' }); - }); - }; - const disableShowBtn = ['failed', 'provisioning'].includes(database.status); - const disableDownloadCACertificateBtn = database.status === 'provisioning'; const credentialsBtn = (handleClick: () => void, btnText: string) => { return ( @@ -116,31 +81,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { ); }; - const caCertificateJSX = ( - <> - - {disableDownloadCACertificateBtn && ( - - - - )} - - ); - const CredentialsContent = ( <> {password} @@ -181,23 +121,30 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { Connection Details - - {username} - + + {flags.databasePgBouncer && ( + + + + )} + + {username} + + {CredentialsContent} - + {isLegacy ? database.engine : 'defaultdb'} - - + + {database.port} - + {database.ssl_connection ? 'ENABLED' : 'DISABLED'} {displayConnectionType && ( - + ({ marginRight: theme.spacingFunction(20), @@ -213,9 +160,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { )} -
- {database.ssl_connection ? caCertificateJSX : null} -
); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx index 2b65b71c560..b9379d57746 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx @@ -52,7 +52,7 @@ describe('ServiceURI', () => { expect(revealPasswordBtn).toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require` + `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:100/{connection pool label}?sslmode=require` ); // eslint-disable-next-line testing-library/no-container @@ -75,7 +75,7 @@ describe('ServiceURI', () => { const serviceURIText = screen.getByTestId('service-uri').textContent; expect(revealPasswordBtn).not.toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://lnroot:password123@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require` + `postgres://lnroot:password123@db-mysql-primary-0.b.linodeb.net:100/{connection pool label}?sslmode=require` ); }); @@ -91,4 +91,46 @@ describe('ServiceURI', () => { }); expect(errorRetryBtn).toBeInTheDocument(); }); + + it('should render general service URI if isGeneralServiceURI is true', () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + }); + const { container } = renderWithTheme( + + ); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:3306/defaultdb?sslmode=require` + ); + + // eslint-disable-next-line testing-library/no-container + const copyButton = container.querySelector('[data-qa-copy-btn]'); + expect(copyButton).toBeInTheDocument(); + }); + + it('should reveal general service URI password after clicking reveal button', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + refetch: vi.fn(), + }); + renderWithTheme(); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + await userEvent.click(revealPasswordBtn); + + const serviceURIText = screen.getByTestId('service-uri').textContent; + expect(revealPasswordBtn).not.toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://password123@db-mysql-primary-0.b.linodeb.net:3306/defaultdb?sslmode=require` + ); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx index adcc31ed8cc..0cfe994211d 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx @@ -13,17 +13,20 @@ import { StyledValueGrid, } from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; -import type { Database } from '@linode/api-v4'; +import type { Database, DatabaseCredentials } from '@linode/api-v4'; interface ServiceURIProps { database: Database; + isGeneralServiceURI?: boolean; } export const ServiceURI = (props: ServiceURIProps) => { - const { database } = props; + const { database, isGeneralServiceURI = false } = props; const [hidePassword, setHidePassword] = useState(true); const [isCopying, setIsCopying] = useState(false); + const engine = + database.engine === 'postgresql' ? 'postgres' : database.engine; const { data: credentials, @@ -39,10 +42,8 @@ export const ServiceURI = (props: ServiceURIProps) => { setIsCopying(true); const { data } = await getDatabaseCredentials(); if (data) { - // copy with username/password data - copy( - `postgres://${data?.username}:${data?.password}@${database.hosts?.primary}?sslmode=require` - ); + // copy with revealed credentials + copy(getServiceURIText(isGeneralServiceURI, data)); } else { enqueueSnackbar( 'There was an error retrieving cluster credentials. Please try again.', @@ -60,12 +61,105 @@ export const ServiceURI = (props: ServiceURIProps) => { } }; - const serviceURI = `postgres://${credentials?.username}:${credentials?.password}@${database.hosts?.primary}?sslmode=require`; + const getServiceURIText = ( + isGeneralServiceURI: boolean, + credentials: DatabaseCredentials | undefined + ) => { + if (isGeneralServiceURI) { + return `${engine}://${credentials?.password}@${database.hosts?.primary}:${database.port}/defaultdb?sslmode=require`; + } + return `postgres://${credentials?.username}:${credentials?.password}@${database.hosts?.primary}:${database.connection_pool_port}/{connection pool label}?sslmode=require`; + }; + + const getCredentials = (isGeneralServiceURI: boolean) => { + return !isGeneralServiceURI + ? `${credentials?.username}:${credentials?.password}` + : credentials?.password; + }; // hide loading state if the user clicks on the copy icon const showBtnLoading = !isCopying && (credentialsLoading || credentialsFetching); + const ErrorButton = ( + + ); + + const RevealPasswordButton = ( + + ); + + const ServiceURIJSX = (isGeneralServiceURI: boolean) => ( + + + {engine}:// + {credentialsError + ? ErrorButton + : hidePassword || (!credentialsError && !credentials) + ? RevealPasswordButton + : getCredentials(isGeneralServiceURI)} + {!isGeneralServiceURI ? ( + <> + @{database.hosts?.primary}:{database.connection_pool_port}/ + {'{connection pool label}'} + ?sslmode=require + + ) : ( + <> + @{database.hosts?.primary}: + {`${database.port}/defaultdb?sslmode=require`} + + )} + + {isCopying ? ( + + ) : ( + + + + )} + + ); + + if (isGeneralServiceURI) { + return ServiceURIJSX(isGeneralServiceURI); + } + return ( { > Service URI - - - postgres:// - {credentialsError ? ( - - ) : hidePassword || (!credentialsError && !credentials) ? ( - - ) : ( - `${credentials?.username}:${credentials?.password}` - )} - @{database.hosts?.primary}: - {'{connection pool port}'}/ - {'{connection pool label}'}?sslmode=require - - {isCopying ? ( - - ) : ( - - - - )} - + {ServiceURIJSX(isGeneralServiceURI)} ); }; -export const StyledCode = styled(Code, { +const StyledCode = styled(Code, { label: 'StyledCode', })(() => ({ margin: 0, })); -export const StyledCopyTooltip = styled(CopyTooltip, { +const StyledCopyTooltip = styled(CopyTooltip, { label: 'StyledCopyTooltip', })(({ theme }) => ({ alignSelf: 'center', From a5f9748b20e2f0f09b5ce5ad453b7e38eee4f7a5 Mon Sep 17 00:00:00 2001 From: agorthi-akamai Date: Fri, 16 Jan 2026 10:39:43 +0530 Subject: [PATCH 26/58] test: fix previous month calculation tests for year boundaries (#13240) * test: fix previous month calculation tests for year boundaries * Fix[DI-29231] failure specification * Fix[DI-29231] failure specification * Update packages/manager/.changeset/pr-13240-tests-1767941254923.md Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> * fix previous month calculation tests for year boundaries * fix previous month calculation tests for year boundaries * Merge branch 'develop' into time-range-spec-fix_linode_January_03 * Add back scrollbar logic after earlier removal * Add back scrollbar logic after earlier removal * add Cypress spec for custom date/time picker with range and timezone * add Cypress spec for custom date/time picker with range and timezone * add Cypress spec for custom date/time picker with range and timezone * add Cypress spec for custom date/time picker with range and timezone --------- Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --- .../pr-13240-tests-1767941254923.md | 5 + .../cloudpulse/timerange-verification.spec.ts | 283 +++++------------- 2 files changed, 74 insertions(+), 214 deletions(-) create mode 100644 packages/manager/.changeset/pr-13240-tests-1767941254923.md diff --git a/packages/manager/.changeset/pr-13240-tests-1767941254923.md b/packages/manager/.changeset/pr-13240-tests-1767941254923.md new file mode 100644 index 00000000000..7593761b965 --- /dev/null +++ b/packages/manager/.changeset/pr-13240-tests-1767941254923.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix time range specification in `timerange-verification.spec.ts` ([#13240](https://github.com/linode/manager/pull/13240)) 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 416da6e8546..e6c47d2cd93 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -99,49 +99,6 @@ const databaseMock: Database = databaseFactory.build({ const mockProfile = profileFactory.build({ timezone: 'UTC', }); -/** - * 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, - * 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 GMT (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. - * - `month`: The month of the year as a number. - */ -const getDateRangeInGMT = ( - hour: number, - minute: number = 0, - isStart: boolean = false -) => { - const now = DateTime.now().setZone('GMT'); // Set the timezone to GMT - const targetDate = isStart - ? now.startOf('month').set({ hour, minute }).setZone('GMT') - : now.set({ hour, minute }).setZone('GMT'); - const actualDate = targetDate.setZone('GMT').toFormat('yyyy-LL-dd HH:mm'); - - const previousMonthDate = targetDate.minus({ months: 1 }); - - return { - actualDate, - day: targetDate.day, - hour: targetDate.hour, - minute: targetDate.minute, - month: targetDate.toFormat('LLLL'), - year: targetDate.year, - daysInMonth: targetDate.daysInMonth, - previousMonth: previousMonthDate.toFormat('LLLL'), - previousYear: previousMonthDate.year, - }; -}; - /** * This function calculates the start of the current month and the current date and time, * adjusted by subtracting 5 hours and 30 minutes, and returns them in the ISO 8601 format (UTC). @@ -176,33 +133,7 @@ const getLastMonthRange = (): DateTimeWithPreset => { }; }; -const convertToGmt = (dateStr: string): string => { - return DateTime.fromISO(dateStr.replace(' ', 'T')).toFormat( - 'yyyy-MM-dd HH:mm' - ); -}; -const formatToUtcDateTime = (dateStr: string): string => { - return DateTime.fromISO(dateStr) - .toUTC() // 🌍 keep it in UTC - .toFormat('yyyy-MM-dd HH:mm'); -}; - -/* - * TODO Fix or migrate the tests in `timerange-verification.spec.ts`. - * - * The tests in this spec frequently fail during specific dates and time periods - * throughout the day and year. Because there are so many tests in this spec, the - * timeouts and subsequent failures can delay test runs by several (45+) minutes - * which frequently interferes with unrelated test runs. - * - * Other considerations: - * - * - Would unit tests or component tests be a better fit for this? - * - * - Are these tests adding any value? They fail frequently and the failures do - * not get reviewed. They do not seem to be protecting us from regressions. - */ -describe.skip('Integration tests for verifying Cloudpulse custom and preset configurations', () => { +describe('Integration tests for verifying Cloudpulse custom and preset configurations', () => { /* * - Mocks user preferences for dashboard details (dashboard, engine, resources, and region). * - Simulates loading test data without real API calls. @@ -246,6 +177,10 @@ describe.skip('Integration tests for verifying Cloudpulse custom and preset conf mockGetDatabases([databaseMock]).as('fetchDatabases'); cy.visitWithLogin('/metrics'); + + cy.get('[aria-label="Content is loading"]', { timeout: 1000 }).should( + 'not.exist' + ); cy.wait([ '@fetchServices', '@fetchDashboard', @@ -253,55 +188,58 @@ describe.skip('Integration tests for verifying Cloudpulse custom and preset conf '@fetchDatabases', ]); }); - it('should implement and validate custom date/time picker for a specific date and time range', () => { - // --- Generate start and end date/time in GMT --- - const { - actualDate: startActualDate, - day: startDay, - hour: startHour, - minute: startMinute, - month: startMonth, - year: startYear, - previousMonth, - previousYear, - daysInMonth, - } = getDateRangeInGMT(12, 15, true); - - const { - actualDate: endActualDate, - day: endDay, - hour: endHour, - minute: endMinute, - } = getDateRangeInGMT(12, 30); - - cy.wait(1000); - // --- Select start date --- - ui.button.findByTitle('Last hour').as('startDateInput'); + // --- Mock the start date to ensure consistent test results --- - cy.get('@startDateInput').scrollIntoView(); + const MOCK_START_DATE = new Date('2025-08-01'); - cy.get('@startDateInput').click(); + cy.clock(MOCK_START_DATE.getTime(), ['Date']); + + // --- Define date/time values for the test --- + + const startDayOfMonth = 1; + const endDayOfMonth = 3; + const startHour = 1; + const startMinute = 15; + const endHour = 2; + const endMinute = 45; + + const ACTUAL_START_DATE_TIME = '2025-08-01 01:15'; + const ACTUAL_END_DATE_TIME = '2025-08-03 02:45'; + + // --- Reset the date/time picker to a known state --- + ui.button.findByTitle('Last hour').click(); + + ui.button.findByTitle('Reset').should('be.visible').click(); + + cy.get('[data-qa-preset="Reset"]').should( + 'have.attr', + 'aria-selected', + 'true' + ); + + // --- Open the date picker dialog and select start/end days --- cy.get('[role="dialog"]').within(() => { - cy.findAllByText(startDay).first().click(); - cy.findAllByText(endDay).first().click(); + // --- Select start and end day --- + cy.findAllByText(startDayOfMonth).first().click(); + cy.findAllByText(endDayOfMonth).first().click(); }); + // --- Select start time (hours and minutes) in the time picker --- ui.button .findByAttribute('aria-label^', 'Choose time') .first() - .should('be.visible', { timeout: 10000 }) // waits up to 10 seconds + .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); // Selects the start hour, minute, and meridiem (AM/PM) in the time picker. - cy.get(`[aria-label="${startHour} hours"]`).click(); - cy.wait(1000); ui.button .findByAttribute('aria-label^', 'Choose time') .first() @@ -310,9 +248,9 @@ describe.skip('Integration tests for verifying Cloudpulse custom and preset conf cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).first().click(); - cy.get(`[aria-label="${startMinute} minutes"]`).click(); + cy.get(`[aria-label="${startMinute} minutes"]`).first().click(); ui.button .findByAttribute('aria-label^', 'Choose time') @@ -327,44 +265,48 @@ describe.skip('Integration tests for verifying Cloudpulse custom and preset conf cy.findByLabelText('Select meridiem') .as('startMeridiemSelect') .scrollIntoView(); - cy.get('@startMeridiemSelect').find('[aria-label="PM"]').click(); + cy.get('@startMeridiemSelect').find('[aria-label="AM"]').click(); - // --- Select end time --- + // --- Select end time (hours and minutes) in the time picker --- ui.button .findByAttribute('aria-label^', 'Choose time') .last() .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); - cy.get('@timePickerButton', { timeout: 15000 }).click(); + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); + + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); // Selects the start hour, minute, and meridiem (AM/PM) in the time picker. - cy.findByLabelText('Select hours').scrollIntoView({ - duration: 500, - easing: 'linear', - }); - cy.get(`[aria-label="${endHour} hours"]`).click(); + cy.get(`[aria-label="${endHour} hours"]`).last().click(); - cy.get('[aria-label^="Choose time"]') + ui.button + .findByAttribute('aria-label^', 'Choose time') .last() - .should('be.visible') + .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); - cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); + + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).last().click(); - cy.get(`[aria-label="${endMinute} minutes"]`).click(); + cy.get(`[aria-label="${endMinute} minutes"]`).last().click(); - cy.get('[aria-label^="Choose time"]') + ui.button + .findByAttribute('aria-label^', 'Choose time') .last() .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); cy.findByLabelText('Select meridiem') .as('endMeridiemSelect') .scrollIntoView(); - cy.get('@endMeridiemSelect').find('[aria-label="PM"]').click(); + cy.get('@endMeridiemSelect').find('[aria-label="AM"]').click(); // --- Set timezone --- cy.findByPlaceholderText('Choose a Timezone').as('timezoneInput').clear(); @@ -376,119 +318,32 @@ describe.skip('Integration tests for verifying Cloudpulse custom and preset conf .and('be.enabled') .click(); - // --- Re-validate after apply --- + // --- Validate that the UI shows the expected start/end date/time --- cy.get('[aria-labelledby="start-date"]').should( 'have.value', - `${startActualDate} PM` + `${ACTUAL_START_DATE_TIME} AM` ); + cy.get('[aria-labelledby="end-date"]').should( 'have.value', - `${endActualDate} PM` + `${ACTUAL_END_DATE_TIME} AM` ); - ui.button.findByTitle('Cancel').and('be.enabled').click(); - - // --- Select Node Type --- ui.autocomplete.findByLabel('Node Type').type('Primary{enter}'); - // --- Validate API requests --- - cy.wait(Array(4).fill('@getMetrics')); + // --- Validate API requests triggered for the selected range --- + cy.wait(['@getMetrics', '@getMetrics', '@getMetrics', '@getMetrics']); cy.get('@getMetrics.all') .should('have.length', 4) .each((xhr: unknown) => { const { request: { body }, } = xhr as Interception; - expect(formatToUtcDateTime(body.absolute_time_duration.start)).to.equal( - convertToGmt(startActualDate) - ); - expect(formatToUtcDateTime(body.absolute_time_duration.end)).to.equal( - convertToGmt(endActualDate) - ); - }); - - // --- Test Time Range Presets --- - mockCreateCloudPulseMetrics(serviceType, metricsAPIResponsePayload).as( - 'getPresets' - ); - - // Open the date range picker to apply the "Last 30 Days" preset - - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); - cy.get('@startDateInput').click(); - - ui.button.findByTitle('Last 30 days').should('be.visible').click(); - - cy.get('[data-qa-preset="Last 30 days"]').should( - 'have.attr', - 'aria-selected', - 'true' - ); - - cy.contains(`${previousMonth} ${previousYear}`) - .closest('div') - .next() - .find('[aria-selected="true"]') - .then(($els) => { - const selectedDays = Array.from($els).map((el) => - Number(el.textContent?.trim()) - ); - - expect(daysInMonth, 'daysInMonth should be defined').to.be.a('number'); - - const totalDays = daysInMonth as number; - const expectedCount = totalDays - endDay; - - expect( - selectedDays.length, - 'number of selected days from the previous month for the last-30-days range' - ).to.eq(expectedCount); - expect( - totalDays - selectedDays.length, - 'start day of Last 30 days' - ).to.eq(endDay); - }); - - cy.contains(`${startMonth} ${startYear}`) - .closest('div') - .next() - .find('[aria-selected="true"]') - .then(($els) => { - const selectedDays = Array.from($els).map((el) => - Number(el.textContent?.trim()) - ); - - expect( - selectedDays.length, - 'number of selected days in the current month for the last-30-days range' - ).to.eq(endDay); - expect(Math.max(...selectedDays), 'end day of Last 30 days').to.eq( - endDay - ); - }); - cy.get('[data-qa-buttons="apply"]') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.button - .findByTitle('Last 30 days') - .should('be.visible') - .should('be.enabled'); - - cy.get('@getPresets.all') - .should('have.length', 4) - .each((xhr: unknown) => { - const { - request: { body }, - } = xhr as Interception; - expect(body).to.have.nested.property( - 'relative_time_duration.unit', - 'days' + expect(body.absolute_time_duration.start).to.equal( + '2025-08-01T01:15:00Z' ); - expect(body).to.have.nested.property( - 'relative_time_duration.value', - 30 + expect(body.absolute_time_duration.end).to.equal( + '2025-08-03T02:45:00Z' ); }); }); From cd23757ba6f676d6c7aaf8db91703086fd97bd29 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:48:34 +0530 Subject: [PATCH 27/58] change: [UIE-9945] - Implement UX and user feedback for linode interfaces feature in Account settings page (#13280) --- .../pr-13280-changed-1768458211341.md | 5 + .../e2e/core/account/network-settings.spec.ts | 16 +- .../Account/NetworkInterfaceType.test.tsx | 8 +- .../features/Account/NetworkInterfaceType.tsx | 153 +++++++++--------- 4 files changed, 94 insertions(+), 88 deletions(-) create mode 100644 packages/manager/.changeset/pr-13280-changed-1768458211341.md diff --git a/packages/manager/.changeset/pr-13280-changed-1768458211341.md b/packages/manager/.changeset/pr-13280-changed-1768458211341.md new file mode 100644 index 00000000000..c2adae1e0bf --- /dev/null +++ b/packages/manager/.changeset/pr-13280-changed-1768458211341.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Apply UX and user feedback for linode interfaces feature in Account settings page ([#13280](https://github.com/linode/manager/pull/13280)) diff --git a/packages/manager/cypress/e2e/core/account/network-settings.spec.ts b/packages/manager/cypress/e2e/core/account/network-settings.spec.ts index ef19a5788e8..50a9cea238f 100644 --- a/packages/manager/cypress/e2e/core/account/network-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/network-settings.spec.ts @@ -28,9 +28,9 @@ import type { LinodeInterfaceAccountSetting } from '@linode/api-v4'; const interfaceTypeMap = { legacy_config_default_but_linode_allowed: - 'Configuration Profile Interfaces but allow Linode Interfaces', + 'Configuration Profile Interfaces (default) but allow Linode Interfaces', linode_default_but_legacy_config_allowed: - 'Linode Interfaces but allow Configuration Profile Interfaces', + 'Linode Interfaces (default) but allow Configuration Profile Interfaces', legacy_config_only: 'Configuration Profile Interfaces Only', linode_only: 'Linode Interfaces Only', }; @@ -54,7 +54,7 @@ describe('Account network settings', () => { describe('Network interface types', () => { /* * - Confirms that customers can update their account-level Linode interface type. - * - Confirms that "Interfaces for new Linodes" drop-down displays user's set value on page load. + * - Confirms that "Allowed interfaces for new Linodes" drop-down displays user's set value on page load. * - Confirms that save button is initially disabled, but becomes enabled upon changing the selection. * - Confirms that outgoing API request contains expected payload data for chosen interface type. * - Confirms that toast appears upon successful settings update operation. @@ -81,7 +81,7 @@ describe('Account network settings', () => { // Confirm that selected interface type matches API response, and that // "Save" button is disabled by default. - cy.findByLabelText('Interfaces for new Linodes').should( + cy.findByLabelText('Allowed interfaces for new Linodes').should( 'have.value', interfaceTypeMap[defaultInterface] ); @@ -89,7 +89,7 @@ describe('Account network settings', () => { // Confirm that changing selection causes "Save" button to become enabled, // then changing back causes it to become disabled again. - cy.findByLabelText('Interfaces for new Linodes').click(); + cy.findByLabelText('Allowed interfaces for new Linodes').click(); ui.autocompletePopper.find().within(() => { cy.findByText(interfaceTypeMap[otherInterfaces[0]]) .should('be.visible') @@ -97,7 +97,7 @@ describe('Account network settings', () => { }); ui.button.findByTitle('Save').should('be.enabled'); - cy.findByLabelText('Interfaces for new Linodes').click(); + cy.findByLabelText('Allowed interfaces for new Linodes').click(); ui.autocompletePopper.find().within(() => { cy.findByText(interfaceTypeMap[defaultInterface]) .should('be.visible') @@ -108,7 +108,7 @@ describe('Account network settings', () => { // Confirm that we can update our setting using every other choice, // and that the outgoing API request payload contains the expected value. otherInterfaces.forEach((otherInterface, index) => { - cy.findByLabelText('Interfaces for new Linodes').click(); + cy.findByLabelText('Allowed interfaces for new Linodes').click(); ui.autocompletePopper.find().within(() => { cy.findByText(interfaceTypeMap[otherInterface]) .should('be.visible') @@ -161,7 +161,7 @@ describe('Account network settings', () => { .should('be.visible') .within(() => { cy.findByText('Network Interface Type').should('be.visible'); - cy.findByLabelText('Interfaces for new Linodes') + cy.findByLabelText('Allowed interfaces for new Linodes') .should('be.visible') .click(); ui.autocompletePopper.find().within(() => { diff --git a/packages/manager/src/features/Account/NetworkInterfaceType.test.tsx b/packages/manager/src/features/Account/NetworkInterfaceType.test.tsx index 41ba4809a79..f43ba89293a 100644 --- a/packages/manager/src/features/Account/NetworkInterfaceType.test.tsx +++ b/packages/manager/src/features/Account/NetworkInterfaceType.test.tsx @@ -21,7 +21,7 @@ describe('NetworkInterfaces', () => { const { getByText } = renderWithTheme(); expect(getByText('Network Interface Type')).toBeVisible(); - expect(getByText('Interfaces for new Linodes')).toBeVisible(); + expect(getByText('Allowed interfaces for new Linodes')).toBeVisible(); expect(getByText('Save')).toBeVisible(); }); @@ -49,9 +49,9 @@ describe('NetworkInterfaces', () => { ); - expect(getByLabelText('Interfaces for new Linodes')).toHaveAttribute( - 'disabled' - ); + expect( + getByLabelText('Allowed interfaces for new Linodes') + ).toHaveAttribute('disabled'); expect(getByText('Save')).toHaveAttribute('aria-disabled', 'true'); }); }); diff --git a/packages/manager/src/features/Account/NetworkInterfaceType.tsx b/packages/manager/src/features/Account/NetworkInterfaceType.tsx index 0f4903de44d..37837ebd431 100644 --- a/packages/manager/src/features/Account/NetworkInterfaceType.tsx +++ b/packages/manager/src/features/Account/NetworkInterfaceType.tsx @@ -1,11 +1,20 @@ import { useAccountSettings, useMutateAccountSettings } from '@linode/queries'; -import { Box, Button, Paper, Select, Stack, Typography } from '@linode/ui'; +import { + Box, + Button, + Paper, + Select, + Stack, + Typography, + useTheme, +} from '@linode/ui'; import { useSnackbar } from 'notistack'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { Link } from 'src/components/Link'; + import { usePermissions } from '../IAM/hooks/usePermissions'; -import { LinodeInterfaceFeatureStatusChip } from '../Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceFeatureChip'; import type { AccountSettings, @@ -18,29 +27,44 @@ type InterfaceSettingValues = Pick< 'interfaces_for_new_linodes' >; -const accountSettingInterfaceOptions: SelectOption[] = - [ - { - label: 'Linode Interfaces but allow Configuration Profile Interfaces', - value: 'linode_default_but_legacy_config_allowed', - }, - { - label: 'Linode Interfaces Only', - value: 'linode_only', - }, - { - label: 'Configuration Profile Interfaces but allow Linode Interfaces', - value: 'legacy_config_default_but_linode_allowed', - }, - { - label: 'Configuration Profile Interfaces Only', - value: 'legacy_config_only', - }, - ]; +interface AccountSettingInterfaceOptionType + extends SelectOption { + tooltipText: string; +} + +const accountSettingInterfaceOptions: AccountSettingInterfaceOptionType[] = [ + { + label: + 'Linode Interfaces (default) but allow Configuration Profile Interfaces', + value: 'linode_default_but_legacy_config_allowed', + tooltipText: + 'Linode Interfaces are used by default unless you select Configuration Profile Interfaces. Linodes with Configuration Profile Interfaces can be upgraded to Linode Interfaces.', + }, + { + label: 'Linode Interfaces Only', + value: 'linode_only', + tooltipText: + 'Existing Linodes with Configuration Profile Interfaces will continue to work. You can upgrade these Linodes to use Linode Interfaces.', + }, + { + label: + 'Configuration Profile Interfaces (default) but allow Linode Interfaces', + value: 'legacy_config_default_but_linode_allowed', + tooltipText: + 'Configuration Profile Interfaces are used by default unless you select Linode Interfaces. You can upgrade to Linode Interfaces at any time.', + }, + { + label: 'Configuration Profile Interfaces Only', + value: 'legacy_config_only', + tooltipText: + 'Existing Linodes with Linode Interfaces will continue to work. Upgrades to Linode Interfaces are not available.', + }, +]; export const NetworkInterfaceType = () => { const { enqueueSnackbar } = useSnackbar(); const { data: accountSettings } = useAccountSettings(); + const theme = useTheme(); const { mutateAsync: updateAccountSettings } = useMutateAccountSettings(); const { data: permissions } = usePermissions('account', [ @@ -80,11 +104,18 @@ export const NetworkInterfaceType = () => {
- - Choose whether to use Configuration Profile Interfaces or Linode - Interfaces - - when creating new Linodes or upgrading existing ones. + + When creating new Linodes or upgrading existing ones, select between + Configuration Profile Interfaces and Linode Interfaces.{' '} + + Learn more + + . + + + Linode Interfaces are recommended. However, use Configuration + Profile Interfaces with LKE or when a Linode needs a private IP + address. {