From d9799c5ebd8abcb13bed8e1ed4937694fe7b49e5 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:01:28 -0400 Subject: [PATCH 01/67] upcoming: [M3-10326] - VM Host Maintenance - Status Icon, Copy Updates, Conditional Notice Display (#12512) * upcoming: [M3-10326] - VM Host Maintenance - Status Icon, Copy Updates, Conditional Notice Display * Remove in-progress event status type - added erroneously * Add changesets * Add iconSlot to TableSortCell * Fix issue with filtering and address reason field when empty * Update test with latest utilities --------- Co-authored-by: Jaalah Ramos --- .../pr-12512-removed-1752621192730.md | 5 ++++ packages/api-v4/src/account/types.ts | 1 - packages/api-v4/src/cloudpulse/types.ts | 2 +- ...r-12512-upcoming-features-1752621282205.md | 5 ++++ .../TableSortCell/TableSortCell.tsx | 3 +++ .../Maintenance/MaintenanceTable.test.tsx | 21 ++++++++++++++++ .../Account/Maintenance/MaintenanceTable.tsx | 24 +++++++++++++++++-- .../Maintenance/MaintenanceTableRow.tsx | 18 ++++++++------ .../AdditionalOptions/MaintenancePolicy.tsx | 14 ++++++----- .../features/Linodes/LinodeCreate/Region.tsx | 12 ++++++---- .../manager/src/features/Linodes/constants.ts | 1 - .../pr-12512-changed-1752621151511.md | 5 ++++ packages/ui/src/foundations/themes/light.ts | 2 +- 13 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12512-removed-1752621192730.md create mode 100644 packages/manager/.changeset/pr-12512-upcoming-features-1752621282205.md create mode 100644 packages/ui/.changeset/pr-12512-changed-1752621151511.md diff --git a/packages/api-v4/.changeset/pr-12512-removed-1752621192730.md b/packages/api-v4/.changeset/pr-12512-removed-1752621192730.md new file mode 100644 index 00000000000..8976405bfcc --- /dev/null +++ b/packages/api-v4/.changeset/pr-12512-removed-1752621192730.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Removed +--- + +Remove unnecessary in-progress event.status type during earlier development ([#12512](https://github.com/linode/manager/pull/12512)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 1831f4ffcd4..0aad4ee18ac 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -510,7 +510,6 @@ export type EventStatus = | 'canceled' | 'failed' | 'finished' - | 'in-progress' | 'notification' | 'scheduled' | 'started'; diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index fd47d2fc4e6..43a33bd4287 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -392,4 +392,4 @@ export interface CloudPulseAlertsPayload { * Only included in Beta mode. */ user?: number[]; -} \ No newline at end of file +} diff --git a/packages/manager/.changeset/pr-12512-upcoming-features-1752621282205.md b/packages/manager/.changeset/pr-12512-upcoming-features-1752621282205.md new file mode 100644 index 00000000000..ddc16a6c0d9 --- /dev/null +++ b/packages/manager/.changeset/pr-12512-upcoming-features-1752621282205.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Show GPU warning notice conditionally based on policy type - display for "migrate" policy but hide for "power-off-on" policy ([#12512](https://github.com/linode/manager/pull/12512)) diff --git a/packages/manager/src/components/TableSortCell/TableSortCell.tsx b/packages/manager/src/components/TableSortCell/TableSortCell.tsx index 784a7a713dc..4c2190dbeb9 100644 --- a/packages/manager/src/components/TableSortCell/TableSortCell.tsx +++ b/packages/manager/src/components/TableSortCell/TableSortCell.tsx @@ -12,6 +12,7 @@ export interface TableSortCellProps extends _TableCellProps { active: boolean; direction: 'asc' | 'desc'; handleClick: (key: string, order?: 'asc' | 'desc') => void; + iconSlot?: React.ReactNode; isLoading?: boolean; label: string; noWrap?: boolean; @@ -24,6 +25,7 @@ export const TableSortCell = (props: TableSortCellProps) => { direction, // eslint-disable-next-line handleClick, + iconSlot, isLoading, label, noWrap, @@ -72,6 +74,7 @@ export const TableSortCell = (props: TableSortCellProps) => { {children} {!active && } + {iconSlot} {isLoading && } ); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx index e2aabd869ac..801d6da509f 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx @@ -95,4 +95,25 @@ describe('Maintenance Table', () => { // Check for custom empty state screen.getByText('No in progress maintenance.'); }); + + it('should render tooltip icon next to status for upcoming maintenance', async () => { + server.use( + http.get('*/account/maintenance', () => { + const accountMaintenance = accountMaintenanceFactory.buildList(1, { + status: 'scheduled', + }); + return HttpResponse.json(makeResourcePage(accountMaintenance)); + }) + ); + + await renderWithTheme(); + + // Wait for loading to complete + await waitForElementToBeRemoved(screen.getByTestId(loadingTestId)); + + // The tooltip icon should be present with the correct data-testid + expect( + screen.getByTestId('maintenance-status-tooltip') + ).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx index 9d3889d0b57..0ae0cf35ad0 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx @@ -2,7 +2,7 @@ import { useAccountMaintenanceQuery, useAllAccountMaintenanceQuery, } from '@linode/queries'; -import { Box, Paper, Typography } from '@linode/ui'; +import { Box, Paper, TooltipIcon, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { useFormattedDate } from '@linode/utilities'; import * as React from 'react'; @@ -255,7 +255,7 @@ export const MaintenanceTable = ({ type }: Props) => { className={classes.cell} direction={order} handleClick={handleOrderChange} - label={getMaintenanceDateLabel(type)} + label={getMaintenanceDateField(type)} > {getMaintenanceDateLabel(type)} @@ -308,6 +308,26 @@ export const MaintenanceTable = ({ type }: Props) => { className={classes.cell} direction={order} handleClick={handleOrderChange} + iconSlot={ + type === 'upcoming' && ( + + Scheduled status refers to an event that is planned to + start at a certain time.
+
Pending status refers to an event that has yet + to be completed or decided. + + } + /> + ) + } label="status" > Status diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 0cf3f0e7eae..fa041639532 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -155,14 +155,18 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { )} - {isTruncated ? ( - }> -
- -
-
+ {reason ? ( + isTruncated ? ( + }> +
+ +
+
+ ) : ( + + ) ) : ( - + '—' )}
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx index b89c2d037ef..bfd06c4fd3a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx @@ -21,9 +21,9 @@ export const MaintenancePolicy = () => { const { control } = useFormContext(); const flags = useFlags(); - const [selectedRegion, selectedType] = useWatch({ + const [selectedRegion, selectedType, maintenancePolicy] = useWatch({ control, - name: ['region', 'type'], + name: ['region', 'type', 'maintenance_policy'], }); const { data: region } = useRegionQuery(selectedRegion); @@ -52,9 +52,11 @@ export const MaintenancePolicy = () => { }, }} > - {regionSupportsMaintenancePolicy && isGPUPlan && ( - {GPU_PLAN_NOTICE} - )} + {regionSupportsMaintenancePolicy && + isGPUPlan && + maintenancePolicy === 'linode/migrate' && ( + {GPU_PLAN_NOTICE} + )} { onChange={(policy) => field.onChange(policy.slug)} textFieldProps={{ helperText: !region - ? 'Select a region to see available maintenance policies.' + ? 'Select a region to choose a maintenance policy.' : selectedRegion && !regionSupportsMaintenancePolicy ? MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT : undefined, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx index 1a13c41bcd8..e3846ffbc41 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx @@ -122,10 +122,14 @@ export const Region = React.memo(() => { setValue('metadata.user_data', null); } - if ( - values.maintenance_policy && - !region.capabilities.includes('Maintenance Policy') - ) { + // Handle maintenance policy based on region capabilities + if (region.capabilities.includes('Maintenance Policy')) { + // If the region supports maintenance policy, set it to the default value + // or keep the current value if it's already set + if (!values.maintenance_policy) { + setValue('maintenance_policy', 'linode/migrate'); + } + } else { // Clear maintenance_policy if the selected region doesn't support it setValue('maintenance_policy', undefined); } diff --git a/packages/manager/src/features/Linodes/constants.ts b/packages/manager/src/features/Linodes/constants.ts index aebe974fa8b..d7ffb3e79a4 100644 --- a/packages/manager/src/features/Linodes/constants.ts +++ b/packages/manager/src/features/Linodes/constants.ts @@ -26,4 +26,3 @@ export const ALERTS_BETA_MODE_BANNER_TEXT = export const ALERTS_LEGACY_MODE_BUTTON_TEXT = 'Try Alerts (Beta)'; export const ALERTS_BETA_MODE_BUTTON_TEXT = 'Switch to legacy Alerts'; - diff --git a/packages/ui/.changeset/pr-12512-changed-1752621151511.md b/packages/ui/.changeset/pr-12512-changed-1752621151511.md new file mode 100644 index 00000000000..201c5ba825f --- /dev/null +++ b/packages/ui/.changeset/pr-12512-changed-1752621151511.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Changed +--- + +Use gap for TableSortLabel spacing of text and icons ([#12512](https://github.com/linode/manager/pull/12512)) diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index b321822d958..f648f837203 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -1608,7 +1608,7 @@ export const lightTheme: ThemeOptions = { height: '16px', margin: `0 ${Spacing.S4}`, path: { - fill: Table.HeaderNested.Text, + fill: Table.HeaderNested.Icon, }, width: '16px', }, From e388dfeeb14206f3ca9b2d021360c7b9d894ceba Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:26:18 -0400 Subject: [PATCH 02/67] chore: [M3-10368] - Use `DebouncedSearchTextField` on Images Landing (#12555) * use search field component * fix spacing * fix up images landing unit tests * update cypress test to use correct selector for search field component * Added changeset: Use Search field on Images landing page rather than a classic Text field * fix test helper --------- Co-authored-by: Banks Nussman --- .../pr-12555-changed-1753281074340.md | 5 + .../e2e/core/images/search-images.spec.ts | 2 +- .../ImagesLanding/ImagesLanding.test.tsx | 329 +++++------- .../Images/ImagesLanding/ImagesLanding.tsx | 501 +++++++++--------- .../manager/src/utilities/testHelpers.tsx | 47 +- 5 files changed, 399 insertions(+), 485 deletions(-) create mode 100644 packages/manager/.changeset/pr-12555-changed-1753281074340.md diff --git a/packages/manager/.changeset/pr-12555-changed-1753281074340.md b/packages/manager/.changeset/pr-12555-changed-1753281074340.md new file mode 100644 index 00000000000..581191c6d0a --- /dev/null +++ b/packages/manager/.changeset/pr-12555-changed-1753281074340.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use Search field on Images landing page rather than a classic Text field ([#12555](https://github.com/linode/manager/pull/12555)) diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts index 2945baacd87..a64cac73e20 100644 --- a/packages/manager/cypress/e2e/core/images/search-images.spec.ts +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -67,7 +67,7 @@ describe('Search Images', () => { cy.contains(image2.label).should('not.exist'); // Clear search, confirm both images are shown. - cy.findByTestId('clear-images-search').click(); + cy.findByLabelText('Clear').click(); cy.contains(image1.label).should('be.visible'); cy.contains(image2.label).should('be.visible'); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx index 2cdd4a23ae6..dc98f2359ac 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -1,5 +1,5 @@ import { grantsFactory, profileFactory } from '@linode/utilities'; -import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -10,33 +10,6 @@ import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import ImagesLanding from './ImagesLanding'; -const queryMocks = vi.hoisted(() => ({ - useParams: vi.fn().mockReturnValue({ action: undefined, imageId: undefined }), - useSearch: vi.fn().mockReturnValue({ query: undefined }), -})); - -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useParams: queryMocks.useParams, - useSearch: queryMocks.useSearch, - }; -}); - -const mockHistory = { - push: vi.fn(), - replace: vi.fn(), -}; - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useHistory: vi.fn(() => mockHistory), - }; -}); - beforeAll(() => mockMatchMedia()); const loadingTestId = 'circle-progress'; @@ -55,12 +28,13 @@ describe('Images Landing Table', () => { }) ); - const { getAllByText, queryByTestId } = renderWithTheme(); + const { getAllByText, queryByTestId } = renderWithTheme(, { + initialRoute: '/images', + }); const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + + await waitForElementToBeRemoved(loadingElement); // Two tables should render getAllByText('Custom Images'); @@ -89,14 +63,11 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { findByText } = renderWithTheme(, { + initialRoute: '/images', + }); - expect(getByText('No Custom Images to display.')).toBeInTheDocument(); + expect(await findByText('No Custom Images to display.')).toBeVisible(); }); it('should render automatic images empty state', async () => { @@ -112,13 +83,11 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(); - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { findByText } = renderWithTheme(, { + initialRoute: '/images', + }); - expect(getByText('No Recovery Images to display.')).toBeInTheDocument(); + expect(await findByText('No Recovery Images to display.')).toBeVisible(); }); it('should render images landing empty state', async () => { @@ -128,192 +97,145 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(); + const { getByText, queryByTestId } = renderWithTheme(, { + initialRoute: '/images', + }); const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await waitForElementToBeRemoved(loadingElement); expect( getByText((text) => text.includes('Store custom Linux images')) - ).toBeInTheDocument(); + ).toBeVisible(); }); it('should allow opening the Edit Image drawer', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const image = imageFactory.build(); + server.use( - http.get('*/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { - getAllByLabelText, - getByTestId, - getByText, - queryByTestId, - rerender, - } = renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { getByText, findByLabelText, router } = renderWithTheme( + , + { initialRoute: '/images' } + ); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); await userEvent.click(getByText('Edit')); - queryMocks.useParams.mockReturnValue({ action: 'edit' }); - - rerender(); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - getByText('Edit Image'); + expect(router.state.location.pathname).toBe( + `/images/${encodeURIComponent(image.id)}/edit` + ); }); it('should allow opening the Restore Image drawer', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const image = imageFactory.build(); + server.use( - http.get('*/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { - getAllByLabelText, - getByTestId, - getByText, - queryByTestId, - rerender, - } = renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { router, getByText, findByLabelText } = renderWithTheme( + , + { initialRoute: '/images' } + ); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); await userEvent.click(getByText('Rebuild an Existing Linode')); - queryMocks.useParams.mockReturnValue({ action: 'rebuild' }); - - rerender(); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - await waitFor(() => { - getByText('Rebuild an Existing Linode from an Image'); - }); + expect(router.state.location.pathname).toBe( + `/images/${encodeURIComponent(image.id)}/rebuild` + ); }); it('should allow deploying to a new Linode', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const image = imageFactory.build(); + server.use( - http.get('*/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { getAllByLabelText, getByText, queryByTestId } = renderWithTheme( - - ); + const { findByLabelText, getByText, queryByTestId, router } = + renderWithTheme(, { initialRoute: '/images' }); const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await waitForElementToBeRemoved(loadingElement); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); await userEvent.click(getByText('Deploy to New Linode')); - expect(mockHistory.push).toBeCalledWith({ - pathname: '/linodes/create/', - search: `?type=Images&imageID=${images[0].id}`, + expect(router.state.location.pathname).toBe('/linodes/create'); + + expect(router.state.location.search).toStrictEqual({ + type: 'Images', + imageID: image.id, }); }); it('should allow deleting an image', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const image = imageFactory.build(); + server.use( - http.get('*/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { - getAllByLabelText, - getByTestId, - getByText, - queryByTestId, - rerender, - } = renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { router, findByLabelText, getByText } = renderWithTheme( + , + { initialRoute: '/images' } + ); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); await userEvent.click(getByText('Delete')); - queryMocks.useParams.mockReturnValue({ action: 'delete' }); - - rerender(); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - await waitFor(() => { - getByText('Are you sure you want to delete this Image?'); - }); + expect(router.state.location.pathname).toBe( + `/images/${encodeURIComponent(image.id)}/delete` + ); }); it('disables the create button if the user does not have permission to create images', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const images = imageFactory.buildList(3); + server.use( http.get('*/v4/profile', () => { const profile = profileFactory.build({ restricted: true }); @@ -328,12 +250,12 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(); + const { getByText, queryByTestId } = renderWithTheme(, { + initialRoute: '/images' + }); const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await waitForElementToBeRemoved(loadingElement); const createImageButton = getByText('Create Image').closest('button'); @@ -345,13 +267,9 @@ describe('Images Landing Table', () => { }); it('disables the action menu buttons if user does not have permissions to edit images', async () => { - const images = imageFactory.buildList(1, { + const image = imageFactory.build({ id: 'private/99999', label: 'vi-test-image', - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], }); server.use( @@ -374,41 +292,42 @@ describe('Images Landing Table', () => { }); return HttpResponse.json(grants); }), - http.get('*/v4/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { findAllByLabelText, getAllByLabelText, queryByTestId } = - renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { findByLabelText } = renderWithTheme(, { + initialRoute: '/images', + }); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); - const disabledEditText = await findAllByLabelText( + const disabledEditText = await findByLabelText( "You don't have permissions to edit this Image. Please contact your account administrator to request the necessary permissions." ); - const disabledDeleteText = await findAllByLabelText( + const disabledDeleteText = await findByLabelText( "You don't have permissions to delete this Image. Please contact your account administrator to request the necessary permissions." ); - const disabledLinodeCreationText = await findAllByLabelText( + const disabledLinodeCreationText = await findByLabelText( "You don't have permissions to create Linodes. Please contact your account administrator to request the necessary permissions." ); - const disabledLinodeRebuildingText = await findAllByLabelText( + const disabledLinodeRebuildingText = await findByLabelText( "You don't have permissions to rebuild Linodes. Please contact your account administrator to request the necessary permissions." ); - expect(disabledEditText.length).toBe(2); - expect(disabledDeleteText.length).toBe(1); - expect(disabledLinodeCreationText.length).toBe(1); - expect(disabledLinodeRebuildingText.length).toBe(1); + expect(disabledEditText).toBeVisible(); + expect(disabledDeleteText).toBeVisible(); + expect(disabledLinodeCreationText).toBeVisible(); + expect(disabledLinodeRebuildingText).toBeVisible(); }); }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index b12dd09bec0..1bde7299a6b 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -8,14 +8,11 @@ import { getAPIFilterFromQuery } from '@linode/search'; import { ActionsPanel, CircleProgress, - CloseIcon, Drawer, ErrorState, - IconButton, - InputAdornment, Notice, Paper, - TextField, + Stack, Typography, } from '@linode/ui'; import { Hidden } from '@linode/ui'; @@ -23,12 +20,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; -import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { Link } from 'src/components/Link'; @@ -110,7 +105,6 @@ export const ImagesLanding = () => { }); const search = useSearch({ from: '/images' }); const { query } = search; - const history = useHistory(); const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const isCreateImageRestricted = useRestrictedGlobalGrantCheck({ @@ -367,25 +361,21 @@ export const ImagesLanding = () => { }; const handleDeployNewLinode = (imageId: string) => { - history.push({ - pathname: `/linodes/create/`, - search: `?type=Images&imageID=${imageId}`, - }); - }; - - const resetSearch = () => { navigate({ - search: (prev) => ({ ...prev, query: undefined }), - to: '/images', + to: '/linodes/create', + search: { + type: 'Images', + imageID: imageId, + }, }); }; - const onSearch = (e: React.ChangeEvent) => { + const onSearch = (query: string) => { navigate({ search: (prev) => ({ ...prev, page: undefined, - query: e.target.value || undefined, + query: query || undefined, }), to: '/images', }); @@ -420,7 +410,7 @@ export const ImagesLanding = () => { const isFetching = manualImagesIsFetching || automaticImagesIsFetching; return ( - + <> { spacingBottom={16} title="Images" /> - - {isFetching && } - - - - - ), - }} - label="Search" - onChange={debounce(400, (e) => { - onSearch(e); - })} - placeholder="Search Images" - value={query ?? ''} - /> - -
- Custom Images - - These are{' '} - - encrypted - {' '} - images you manually uploaded or captured from an existing compute - instance disk. You can deploy an image to a compute instance in any - region. - -
- - - - - Image - - - Status - - - Replicated in - - - Original Image - - - All Replicas - - + + + +
+ Custom Images + + These are{' '} + + encrypted + {' '} + images you manually uploaded or captured from an existing compute + instance disk. You can deploy an image to a compute instance in + any region. + +
+
+ + - Created + Image - - - Image ID - - - - - - {manualImages?.results === 0 && ( - - )} - {manualImagesError && query && ( - - )} - {manualImages?.data.map((manualImage) => ( - - ))} - -
- -
- -
- Recovery Images - - These are images we automatically capture when Linode disks are - deleted. They will be deleted after the indicated expiration date. - -
- - - - - Image - - - Status - - - Size - - + + Status + + + Replicated in + + + Original Image + + + All Replicas + + + + Created + + + + Image ID + + + + + + {manualImages?.results === 0 && ( + + )} + {manualImagesError && query && ( + + )} + {manualImages?.data.map((manualImage) => ( + + ))} + +
+ +
+ +
+ Recovery Images + + These are images we automatically capture when Linode disks are + deleted. They will be deleted after the indicated expiration date. + +
+ + + + + Image + + + Status + - Created + Size - - - Expires - - - - - - {automaticImages?.results === 0 && ( - - )} - {automaticImagesError && query && ( - - )} - {automaticImages?.data.map((automaticImage) => ( - - ))} - -
- + + Created + + + + Expires + + + + + + {automaticImages?.results === 0 && ( + + )} + {automaticImagesError && query && ( + + )} + {automaticImages?.data.map((automaticImage) => ( + + ))} + + + +
+ - - - - - - - handleDeleteImage(selectedImage!), - }} - secondaryButtonProps={{ - 'data-testid': 'cancel', - label: dialogStatus === 'cancel' ? 'Keep Image' : 'Cancel', - onClick: handleCloseDialog, - }} + + - } - entityError={selectedImageError} - isFetching={isFetchingSelectedImage} - onClose={handleCloseDialog} - open={action === 'delete'} - title={ - dialogStatus === 'cancel' - ? 'Cancel Upload' - : `Delete Image ${selectedImage?.label}` - } - > - {dialogState.error && ( - - )} - - {dialogStatus === 'cancel' - ? 'Are you sure you want to cancel this Image upload?' - : 'Are you sure you want to delete this Image?'} - - -
+ + handleDeleteImage(selectedImage!), + }} + secondaryButtonProps={{ + 'data-testid': 'cancel', + label: dialogStatus === 'cancel' ? 'Keep Image' : 'Cancel', + onClick: handleCloseDialog, + }} + /> + } + entityError={selectedImageError} + isFetching={isFetchingSelectedImage} + onClose={handleCloseDialog} + open={action === 'delete'} + title={ + dialogStatus === 'cancel' + ? 'Cancel Upload' + : `Delete Image ${selectedImage?.label}` + } + > + {dialogState.error && ( + + )} + + {dialogStatus === 'cancel' + ? 'Are you sure you want to cancel this Image upload?' + : 'Are you sure you want to delete this Image?'} + + + + ); }; diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index 4e08572950e..0901cd3cd1f 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -30,7 +30,7 @@ import { mergeDeepRight } from './mergeDeepRight'; import type { QueryClient } from '@tanstack/react-query'; // TODO: Tanstack Router - replace AnyRouter once migration is complete. import type { AnyRootRoute, AnyRouter } from '@tanstack/react-router'; -import type { MatcherFunction, RenderResult } from '@testing-library/react'; +import type { MatcherFunction } from '@testing-library/react'; import type { DeepPartial } from 'redux'; import type { FlagSet } from 'src/featureFlags'; import type { ApplicationState } from 'src/store'; @@ -106,14 +106,16 @@ export const wrapWithTheme = (ui: any, options: Options = {}) => { path: options.initialRoute ?? '/', }); - const router: AnyRouter = createRouter({ - history: createMemoryHistory({ - initialEntries: (options.MemoryRouter?.initialEntries as string[]) ?? [ - options.initialRoute ?? '/', - ], - }), - routeTree: rootRoute.addChildren([indexRoute]), - }); + const router: AnyRouter = + options.router ?? + createRouter({ + history: createMemoryHistory({ + initialEntries: (options.MemoryRouter?.initialEntries as string[]) ?? [ + options.initialRoute ?? '/', + ], + }), + routeTree: rootRoute.addChildren([indexRoute]), + }); return ( @@ -150,14 +152,29 @@ export const wrapWithTableBody = (ui: any, options: Options = {}) => options ); -export const renderWithTheme = ( - ui: React.ReactNode, - options: Options = {} -): RenderResult => { - const utils = render(wrapWithTheme(ui, options)); +export const renderWithTheme = (ui: React.ReactNode, options: Options = {}) => { + const rootRoute = createRootRoute({}); + const indexRoute = createRoute({ + component: () => ui, + getParentRoute: () => rootRoute, + path: options.initialRoute ?? '/', + }); + + const router: AnyRouter = createRouter({ + history: createMemoryHistory({ + initialEntries: (options.MemoryRouter?.initialEntries as string[]) ?? [ + options.initialRoute ?? '/', + ], + }), + routeTree: rootRoute.addChildren([indexRoute]), + }); + + const utils = render(wrapWithTheme(ui, { ...options, router })); return { ...utils, - rerender: (ui) => utils.rerender(wrapWithTheme(ui, options)), + rerender: (ui: React.ReactNode) => + utils.rerender(wrapWithTheme(ui, options)), + router, }; }; From 00874ec6ec1ca6df90ddba49de867c209db2d101 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:46:03 -0400 Subject: [PATCH 03/67] test: [M3-8559] - Increase `Security.test.tsx` `waitFor` timeout from 1s to 5s (#12576) * Increase `waitFor` timeout from 1s to 5s * Add changeset --- .../.changeset/pr-12576-tests-1753390501259.md | 5 +++++ .../features/Linodes/LinodeCreate/Security.test.tsx | 13 ++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-12576-tests-1753390501259.md diff --git a/packages/manager/.changeset/pr-12576-tests-1753390501259.md b/packages/manager/.changeset/pr-12576-tests-1753390501259.md new file mode 100644 index 00000000000..9bcb5c7c0bb --- /dev/null +++ b/packages/manager/.changeset/pr-12576-tests-1753390501259.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Improve stability of Linode create password field tests ([#12576](https://github.com/linode/manager/pull/12576)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx index 501c57114e4..1dfd8ce4bd7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx @@ -52,12 +52,15 @@ describe('Security', () => { component: , }); - await waitFor(() => { - const rootPasswordInput = getByLabelText('Root Password'); + await waitFor( + () => { + const rootPasswordInput = getByLabelText('Root Password'); - expect(rootPasswordInput).toBeVisible(); - expect(rootPasswordInput).toBeDisabled(); - }); + expect(rootPasswordInput).toBeVisible(); + expect(rootPasswordInput).toBeDisabled(); + }, + { timeout: 5_000 } + ); }); it('should enable the root password input if the user does has create_linode permission', async () => { From b12096d6736e2a27cfe9dca405b483625c25469a Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:38:01 +0530 Subject: [PATCH 04/67] upcoming: [DI-25355] - Added util function to filter region based on monitor capability (#12573) * upcoming: [DI-25355] - Added util function to filter region based on monitor capability * upcoming: [DI-25355] - Updated test cases * upcoming: [DI-25355] - Updated test cases * upcoming: [DI-25355] - Updated test cases * upcoming: [DI-25355] - Updated code docs * upcoming: [DI-25355] - Code cleanup * upcoming: [DI-25355] - Eslint fixes * added changeset * updated typecheck failures --- .../pr-12573-added-1753364724800.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 2 +- .../pr-12573-added-1753364742642.md | 5 + .../cloudpulse-dashboard-errors.spec.ts | 10 +- .../core/cloudpulse/create-user-alert.spec.ts | 27 +++- .../dbaas-widgets-verification.spec.ts | 6 +- .../linode-widget-verification.spec.ts | 6 +- .../cloudpulse/timerange-verification.spec.ts | 5 +- packages/manager/src/featureFlags.ts | 1 - .../Alerts/AlertRegions/AlertRegions.test.tsx | 17 +-- .../Alerts/AlertRegions/AlertRegions.tsx | 5 +- .../AlertsResources/AlertsResources.tsx | 8 +- .../Alerts/Utils/AlertResourceUtils.test.ts | 28 ++--- .../Alerts/Utils/AlertResourceUtils.ts | 29 ++--- .../CloudPulse/Alerts/Utils/utils.test.ts | 119 ++++++++++++++++++ .../features/CloudPulse/Alerts/Utils/utils.ts | 79 ++++++------ .../shared/CloudPulseRegionSelect.test.tsx | 18 ++- .../shared/CloudPulseRegionSelect.tsx | 23 +--- 18 files changed, 257 insertions(+), 136 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12573-added-1753364724800.md create mode 100644 packages/manager/.changeset/pr-12573-added-1753364742642.md diff --git a/packages/api-v4/.changeset/pr-12573-added-1753364724800.md b/packages/api-v4/.changeset/pr-12573-added-1753364724800.md new file mode 100644 index 00000000000..2c4ff19c76d --- /dev/null +++ b/packages/api-v4/.changeset/pr-12573-added-1753364724800.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +ACLP: `string` type for `capabilityServiceTypeMapping` constant ([#12573](https://github.com/linode/manager/pull/12573)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 43a33bd4287..e95500e8233 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -365,7 +365,7 @@ export interface DeleteAlertPayload { } export const capabilityServiceTypeMapping: Record< - MetricsServiceType, + AlertServiceType | MetricsServiceType | string, AccountCapability > = { linode: 'Linodes', diff --git a/packages/manager/.changeset/pr-12573-added-1753364742642.md b/packages/manager/.changeset/pr-12573-added-1753364742642.md new file mode 100644 index 00000000000..e62cd1ede8a --- /dev/null +++ b/packages/manager/.changeset/pr-12573-added-1753364742642.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +ACLP: `filterRegionByServiceType` method to alerts/utils/utils.ts, remove `supportedRegionIds` property from `CloudPulseResourceTypeMapFlag` feature flag ([#12573](https://github.com/linode/manager/pull/12573)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts index 958b329d134..b616987bcd1 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts @@ -55,13 +55,11 @@ const flags: Partial = { dimensionKey: 'LINODE_ID', maxResourceSelections: 10, serviceType: 'linode', - supportedRegionIds: 'us-ord', }, { dimensionKey: 'cluster_id', maxResourceSelections: 10, serviceType: 'dbaas', - supportedRegionIds: 'us-ord, us-east', }, ], }; @@ -101,11 +99,19 @@ const mockRegions = [ id: 'us-ord', label: 'Chicago, IL', capabilities: ['Managed Databases'], + monitors: { + metrics: ['Linodes', 'Managed Databases'], + alerts: [], + }, }), regionFactory.build({ id: 'us-east', label: 'Newark, NJ', capabilities: ['Managed Databases'], + monitors: { + metrics: ['Managed Databases'], + alerts: [], + }, }), ]; diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index 50143dec9e4..3e27a710155 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -47,11 +47,26 @@ const flags: Partial = { aclp: { beta: true, enabled: true } }; // Create mock data const mockAccount = accountFactory.build(); -const mockRegion = regionFactory.build({ - capabilities: ['Managed Databases'], - id: 'us-ord', - label: 'Chicago, IL', -}); +const mockRegions = [ + regionFactory.build({ + id: 'us-ord', + label: 'Chicago, IL', + capabilities: ['Managed Databases'], + monitors: { + alerts: ['Managed Databases'], + metrics: [], + }, + }), + regionFactory.build({ + id: 'us-east', + label: 'New York, NY', + capabilities: ['Managed Databases'], + monitors: { + alerts: ['Managed Databases'], + metrics: [], + }, + }), +]; const { metrics, serviceType } = widgetDetails.dbaas; const databaseMock = databaseFactory.buildList(10, { cluster_size: 3, @@ -164,7 +179,7 @@ describe('Create Alert', () => { mockGetAccount(mockAccount); mockGetProfile(mockProfile); mockGetCloudPulseServices([serviceType]); - mockGetRegions([mockRegion]); + mockGetRegions(mockRegions); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); mockGetDatabases(databaseMock); mockGetAllAlertDefinitions([mockAlerts]).as('getAlertDefinitionsList'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index d51f28560fc..96be3fc1fa3 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -63,13 +63,11 @@ const flags: Partial = { dimensionKey: 'LINODE_ID', maxResourceSelections: 10, serviceType: 'linode', - supportedRegionIds: '', }, { dimensionKey: 'cluster_id', maxResourceSelections: 10, serviceType: 'dbaas', - supportedRegionIds: 'us-ord', }, ], }; @@ -123,6 +121,10 @@ const mockRegion = regionFactory.build({ capabilities: ['Managed Databases'], id: 'us-ord', label: 'Chicago, IL', + monitors: { + metrics: ['Managed Databases'], + alerts: [], + }, }); const extendedMockRegion = regionFactory.build({ diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index fabbaccb66e..fd3f12688b1 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -53,13 +53,11 @@ const flags: Partial = { dimensionKey: 'LINODE_ID', maxResourceSelections: 10, serviceType: 'linode', - supportedRegionIds: 'us-ord', }, { dimensionKey: 'cluster_id', maxResourceSelections: 10, serviceType: 'dbaas', - supportedRegionIds: '', }, ], }; @@ -99,6 +97,10 @@ const mockRegion = regionFactory.build({ capabilities: ['Linodes'], id: 'us-ord', label: 'Chicago, IL', + monitors: { + alerts: [], + metrics: ['Linodes'], + }, }); const extendedMockRegion = regionFactory.build({ 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 61afdebd6f7..54d342965c0 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -52,6 +52,10 @@ const mockRegion = regionFactory.build({ capabilities: ['Managed Databases'], id: 'us-ord', label: 'Chicago, IL', + monitors: { + metrics: ['Managed Databases'], + alerts: [], + }, }); const flags: Partial = { @@ -61,7 +65,6 @@ const flags: Partial = { dimensionKey: 'cluster_id', maxResourceSelections: 10, serviceType: 'dbaas', - supportedRegionIds: 'us-ord', }, ], }; diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 52d733759b2..f61e2f37824 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -86,7 +86,6 @@ export interface CloudPulseResourceTypeMapFlag { dimensionKey: string; maxResourceSelections?: number; serviceType: string; - supportedRegionIds?: string; } interface GpuV2 { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx index c4b32a52c2d..f54a12cb28b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx @@ -8,21 +8,12 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { AlertRegions } from './AlertRegions'; import type { AlertServiceType } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; -const regions = regionFactory.buildList(6); +const regions = regionFactory.buildList(6, { + monitors: { alerts: ['Managed Databases'] }, +}); const serviceType: AlertServiceType = 'dbaas'; -const flags: Partial = { - aclpResourceTypeMap: [ - { - serviceType, - supportedRegionIds: regions.map(({ id }) => id).join(','), - dimensionKey: 'region', - }, - ], -}; - const queryMocks = vi.hoisted(() => ({ useFlags: vi.fn(), useRegionsQuery: vi.fn(), @@ -65,7 +56,7 @@ const component = ( ); describe('Alert Regions', () => { it('Should render the filters and notices ', () => { - renderWithTheme(component, { flags }); + renderWithTheme(component); const regionSearch = screen.getByTestId('region-search'); const showSelectedOnly = screen.getByTestId('show-selected-only'); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx index ac01a686bcd..c360cb33a0b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx @@ -4,7 +4,6 @@ import { Typography } from '@linode/ui'; import React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { type AlertFormMode } from '../constants'; @@ -39,7 +38,6 @@ interface AlertRegionsProps { export const AlertRegions = React.memo((props: AlertRegionsProps) => { const { serviceType, handleChange, value = [], errorText, mode } = props; - const { aclpResourceTypeMap } = useFlags(); const [searchText, setSearchText] = React.useState(''); const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); @@ -92,9 +90,8 @@ export const AlertRegions = React.memo((props: AlertRegionsProps) => { serviceType, resources, regions, - aclpResourceTypeMap, }), - [aclpResourceTypeMap, regions, resources, serviceType] + [regions, resources, serviceType] ); if (isRegionsLoading || isResourcesLoading) { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 89a44f5cdbf..50778292c78 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -5,7 +5,6 @@ import React from 'react'; import EntityIcon from 'src/assets/icons/entityIcons/alertsresources.svg'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; @@ -129,14 +128,9 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { isLoading: isRegionsLoading, } = useRegionsQuery(); - const flags = useFlags(); const theme = useTheme(); - // Validate launchDarkly region ids with the ids from regionOptions prop - const supportedRegionIds = getSupportedRegionIds( - flags.aclpResourceTypeMap, - serviceType - ); + const supportedRegionIds = getSupportedRegionIds(regions, serviceType); const xFilterToBeApplied: Filter | undefined = React.useMemo(() => { const regionFilter: Filter = supportedRegionIds ? { diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts index b166d62d038..d3064ecbfea 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts @@ -181,30 +181,18 @@ describe('getFilteredResources', () => { }); describe('getSupportedRegionIds', () => { - const mockResourceTypeMap = [ - { - dimensionKey: 'LINODE_ID', - serviceType: 'linode', - supportedRegionIds: 'us-east,us-west,us-central,us-southeast', + const regions = regionFactory.buildList(4, { + monitors: { + alerts: ['Linodes'], }, - ]; + }); it('should return supported region ids', () => { - const result = getSupportedRegionIds( - mockResourceTypeMap, - 'linode' - ) as string[]; + const result = getSupportedRegionIds(regions, 'linode') as string[]; expect(result.length).toBe(4); }); - it('should return undefined if no supported region ids are defined in resource type map for the given service type', () => { - const mockResourceTypeMap = [ - { - dimensionKey: 'LINODE_ID', - serviceType: 'linode', - }, - ]; - - const result = getSupportedRegionIds(mockResourceTypeMap, 'linode'); - expect(result).toBeUndefined(); + it('should return empty list if regions list empty', () => { + const result = getSupportedRegionIds([], 'linode'); + expect(result).toHaveLength(0); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts index 61e6ae6cef9..c86a1701904 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts @@ -2,6 +2,7 @@ import { alertAdditionalFilterKeyMap, applicableAdditionalFilterKeys, } from '../AlertsResources/constants'; +import { filterRegionByServiceType } from './utils'; import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; import type { AlertInstance } from '../AlertsResources/DisplayAlertResources'; @@ -12,7 +13,6 @@ import type { AlertResourceFiltersProps, } from '../AlertsResources/types'; import type { AlertServiceType, Region } from '@linode/api-v4'; -import type { CloudPulseResourceTypeMapFlag } from 'src/featureFlags'; interface FilterResourceProps { /** @@ -138,28 +138,19 @@ export const getRegionOptions = ( }; /** - * @param aclpResourceTypeMap The launch darkly flag where supported region ids are listed - * @param serviceType The service type associated with the alerts - * @returns Array of supported regions associated with the resource ids of the alert + * Filters regions based on service type and returns their IDs. + * @param regions List of regions to filter + * @param serviceType The alert service type to filter regions by + * @returns An array of region IDs that support the specified alert service type, + * or undefined if no regions are provided */ export const getSupportedRegionIds = ( - aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[] | undefined, - serviceType: AlertServiceType | undefined + regions?: Region[], + serviceType?: AlertServiceType ): string[] | undefined => { - const resourceTypeFlag = aclpResourceTypeMap?.find( - (item: CloudPulseResourceTypeMapFlag) => item.serviceType === serviceType + return filterRegionByServiceType('alerts', regions, serviceType).map( + ({ id }) => id ); - - if ( - resourceTypeFlag?.supportedRegionIds === null || - resourceTypeFlag?.supportedRegionIds === undefined - ) { - return undefined; - } - - return resourceTypeFlag.supportedRegionIds - .split(',') - .map((regionId: string) => regionId.trim()); }; /** 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 a80eff55fdd..ad9dd5edb47 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { act, renderHook } from '@testing-library/react'; import { alertFactory, serviceTypesFactory } from 'src/factories'; @@ -10,6 +11,7 @@ import { convertSecondsToMinutes, convertSecondsToOptions, filterAlerts, + filterRegionByServiceType, getSchemaWithEntityIdValidation, getServiceTypeLabel, handleMultipleError, @@ -232,6 +234,13 @@ describe('getSchemaWithEntityIdValidation', () => { 'Must be one of avg, sum, min, max, count and no full stop.|Must have at least one rule.|Invalid value.', }); }); + + it('test convert secondsToOptions method', () => { + expect(convertSecondsToOptions(300)).toEqual('5 min'); + expect(convertSecondsToOptions(60)).toEqual('1 min'); + expect(convertSecondsToOptions(3600)).toEqual('1 hr'); + expect(convertSecondsToOptions(900)).toEqual('15 min'); + }); }); describe('useContextualAlertsState', () => { @@ -303,3 +312,113 @@ describe('useContextualAlertsState', () => { expect(result.current.hasUnsavedChanges).toBe(true); }); }); + +describe('filterRegionByServiceType', () => { + const regions = [ + regionFactory.build({ + monitors: { + alerts: ['Linodes'], + metrics: ['Managed Databases'], + }, + }), + ...regionFactory.buildList(3, { + monitors: { + metrics: [], + alerts: [], + }, + }), + ...regionFactory.buildList(3, { + monitors: { + alerts: ['Linodes', 'Managed Databases'], + metrics: [], + }, + }), + regionFactory.build({ + monitors: undefined, + }), + ]; + + it('should return empty list for linode metrics', () => { + const result = filterRegionByServiceType('metrics', regions, 'linode'); + + expect(result).toHaveLength(0); + }); + + it('should return 4 regions for linode alerts', () => { + expect(filterRegionByServiceType('alerts', regions, 'linode')).toHaveLength( + 4 + ); + }); + + it('should return 1 region for dbaas metrics', () => { + expect(filterRegionByServiceType('metrics', regions, 'dbaas')).toHaveLength( + 1 + ); + }); + + it('should return 3 regions for dbaas alerts', () => { + expect(filterRegionByServiceType('alerts', regions, 'dbaas')).toHaveLength( + 3 + ); + }); + + it('should return no regions for unknown service type', () => { + const result = filterRegionByServiceType('alerts', regions, 'unknown'); + + expect(result).toHaveLength(0); + }); +}); + +describe('filterRegionByServiceType', () => { + const regions = [ + regionFactory.build({ + monitors: { + alerts: ['Linodes'], + metrics: ['Managed Databases'], + }, + }), + ...regionFactory.buildList(3, { + monitors: { + metrics: [], + alerts: [], + }, + }), + ...regionFactory.buildList(3, { + monitors: { + alerts: ['Linodes', 'Managed Databases'], + metrics: [], + }, + }), + regionFactory.build(), + ]; + + it('should return empty list for linode metrics', () => { + const result = filterRegionByServiceType('metrics', regions, 'linode'); + + expect(result).toHaveLength(0); + }); + + it('should return 4 regions for linode alerts', () => { + expect(filterRegionByServiceType('alerts', regions, 'linode')).toHaveLength( + 4 + ); + }); + + it('should return 1 region for dbaas metrics', () => { + expect(filterRegionByServiceType('metrics', regions, 'dbaas')).toHaveLength( + 1 + ); + }); + + it('should return 3 regions for dbaas alerts', () => { + expect(filterRegionByServiceType('alerts', regions, 'dbaas')).toHaveLength( + 3 + ); + }); + + it('should return no regions for unknown service type', () => { + const result = filterRegionByServiceType('alerts', regions, 'unknown'); + + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index b86d15beb5d..c580843fba3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -1,3 +1,15 @@ +import { + type Alert, + type AlertDefinitionMetricCriteria, + type AlertDefinitionType, + type AlertServiceType, + type APIError, + capabilityServiceTypeMapping, + type EditAlertPayloadWithService, + type NotificationChannel, + type Region, + type ServiceTypesList, +} from '@linode/api-v4'; import type { FieldPath, FieldValues, UseFormSetError } from 'react-hook-form'; import { array, object, string } from 'yup'; @@ -6,22 +18,9 @@ import { aggregationTypeMap, metricOperatorTypeMap } from '../constants'; import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; import type { AlertDimensionsProp } from '../AlertsDetail/DisplayAlertDetailChips'; import type { CreateAlertDefinitionForm } from '../CreateAlert/types'; -import type { - Alert, - AlertDefinitionMetricCriteria, - AlertDefinitionType, - AlertServiceType, - APIError, - EditAlertPayloadWithService, - NotificationChannel, - Region, - ServiceTypesList, -} from '@linode/api-v4'; +import type { MonitoringCapabilities } from '@linode/api-v4'; import type { Theme } from '@mui/material'; -import type { - AclpAlertServiceTypeConfig, - CloudPulseResourceTypeMapFlag, -} from 'src/featureFlags'; +import type { AclpAlertServiceTypeConfig } from 'src/featureFlags'; import type { ObjectSchema } from 'yup'; interface AlertChipBorderProps { @@ -109,10 +108,6 @@ interface HandleMultipleErrorProps { } interface SupportedRegionsProps { - /** - * The resource type map flag - */ - aclpResourceTypeMap?: CloudPulseResourceTypeMapFlag[]; /** * The list of regions */ @@ -464,24 +459,13 @@ export const handleMultipleError = ( * @returns The filtered regions based on the supported and resources */ export const getSupportedRegions = (props: SupportedRegionsProps) => { - const { aclpResourceTypeMap, serviceType, regions, resources } = props; - const resourceTypeFlag = aclpResourceTypeMap?.find( - (item: CloudPulseResourceTypeMapFlag) => item.serviceType === serviceType + const { serviceType, regions, resources } = props; + + const supportedRegions = filterRegionByServiceType( + 'alerts', + regions, + serviceType ); - let supportedRegions = regions; - if ( - resourceTypeFlag?.supportedRegionIds !== null && - resourceTypeFlag?.supportedRegionIds !== undefined - ) { - const supportedRegionsIdList = resourceTypeFlag.supportedRegionIds - .split(',') - .map((regionId: string) => regionId.trim()); - - supportedRegions = - supportedRegions?.filter(({ id }) => - supportedRegionsIdList.includes(id) - ) ?? []; - } return ( supportedRegions?.filter(({ id }) => @@ -490,6 +474,29 @@ export const getSupportedRegions = (props: SupportedRegionsProps) => { ); }; +/** + * Filters regions based on service type and capability type + * @param type The monitoring capability type to filter by (e.g., 'alerts', 'metrics') + * @param regions The list of regions to filter + * @param serviceType The service type to filter regions by + * @returns Array of regions that support the specified service type and monitoring type + */ +export const filterRegionByServiceType = ( + type: keyof MonitoringCapabilities, + regions?: Region[], + serviceType?: null | string +): Region[] => { + if (!serviceType || !regions) return regions ?? []; + const capability = capabilityServiceTypeMapping[serviceType]; + + if (!capability) { + return []; + } + return regions.filter((region) => { + return region.monitors?.[type]?.includes(capability); + }); +}; + /* * Converts seconds into a relevant format of minutes or hours to be displayed in the Autocomplete Options. * @param seconds The seconds that need to be converted into minutes or hours. diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index 31b612c1afb..fe63f9d49d1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -31,11 +31,9 @@ const flags: Partial = { aclpResourceTypeMap: [ { serviceType: 'dbaas', - supportedRegionIds: 'us-west, us-east', }, { serviceType: 'linode', - supportedRegionIds: 'us-lax, us-mia', }, ] as CloudPulseResourceTypeMapFlag[], }; @@ -45,21 +43,37 @@ const allRegions: Region[] = [ capabilities: [capabilityServiceTypeMapping['linode']], id: 'us-lax', label: 'US, Los Angeles, CA', + monitors: { + metrics: ['Linodes'], + alerts: [], + }, }), regionFactory.build({ capabilities: [capabilityServiceTypeMapping['linode']], id: 'us-mia', label: 'US, Miami, FL', + monitors: { + metrics: ['Linodes'], + alerts: [], + }, }), regionFactory.build({ capabilities: [capabilityServiceTypeMapping['dbaas']], id: 'us-west', label: 'US, Fremont, CA', + monitors: { + metrics: ['Managed Databases'], + alerts: [], + }, }), regionFactory.build({ capabilities: [capabilityServiceTypeMapping['dbaas']], id: 'us-east', label: 'US, Newark, NJ', + monitors: { + metrics: ['Managed Databases'], + alerts: [], + }, }), regionFactory.build({ capabilities: [capabilityServiceTypeMapping['dbaas']], diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 6ce69d60c5c..d7789d7f741 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -6,12 +6,12 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { filterRegionByServiceType } from '../Alerts/Utils/utils'; import { NO_REGION_MESSAGE } from '../Utils/constants'; import { deepEqual } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; import type { Dashboard, Filter, FilterValue, Region } from '@linode/api-v4'; -import type { CloudPulseResourceTypeMapFlag } from 'src/featureFlags'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; @@ -105,26 +105,9 @@ export const CloudPulseRegionSelect = React.memo( regions, // Function to call on change ]); - // validate launchDarkly region_ids with the ids from the fetched 'all-regions' const supportedRegions = React.useMemo(() => { - const resourceTypeFlag = flags.aclpResourceTypeMap?.find( - (item: CloudPulseResourceTypeMapFlag) => - item.serviceType === serviceType - ); - - if ( - resourceTypeFlag?.supportedRegionIds === null || - resourceTypeFlag?.supportedRegionIds === undefined - ) { - return regions; - } - - const supportedRegionsIdList = resourceTypeFlag.supportedRegionIds - .split(',') - .map((regionId: string) => regionId.trim()); - - return regions?.filter(({ id }) => supportedRegionsIdList.includes(id)); - }, [flags.aclpResourceTypeMap, regions, serviceType]); + return filterRegionByServiceType('metrics', regions, serviceType); + }, [regions, serviceType]); const supportedRegionsFromResources = supportedRegions?.filter(({ id }) => resources?.some(({ region }) => region === id) From 30026cfb81d59cbb7522bb8aeb0ee0f328a26c23 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:05:55 -0400 Subject: [PATCH 05/67] feat: [M3-10361] - Use Search v2 to power the Volumes Landing page (#12553) * use search v2 * Added changeset: Improved search to the Volumes landing page * make search field clearable * update cypress test to use correct selector for search field component * gracefully handle errors when searching and filter by tags by default --------- Co-authored-by: Banks Nussman --- .../pr-12553-added-1753197976652.md | 5 + .../e2e/core/volumes/search-volumes.spec.ts | 2 +- .../src/features/Volumes/VolumesLanding.tsx | 104 +++++++----------- 3 files changed, 48 insertions(+), 63 deletions(-) create mode 100644 packages/manager/.changeset/pr-12553-added-1753197976652.md diff --git a/packages/manager/.changeset/pr-12553-added-1753197976652.md b/packages/manager/.changeset/pr-12553-added-1753197976652.md new file mode 100644 index 00000000000..54798459806 --- /dev/null +++ b/packages/manager/.changeset/pr-12553-added-1753197976652.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Improved search to the Volumes landing page ([#12553](https://github.com/linode/manager/pull/12553)) diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts index 34cfd8678b8..7f3374632d7 100644 --- a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts @@ -50,7 +50,7 @@ describe('Search Volumes', () => { cy.findByText(volume2.label).should('not.exist'); // Clear search, confirm both volumes are shown. - cy.findByTestId('clear-volumes-search').click(); + cy.findByLabelText('Clear').click(); cy.findByText(volume1.label).should('be.visible'); cy.findByText(volume2.label).should('be.visible'); diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index 8f8d019d530..ccd400a75c6 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -1,16 +1,10 @@ import { useVolumeQuery, useVolumesQuery } from '@linode/queries'; -import { - CircleProgress, - CloseIcon, - ErrorState, - IconButton, - InputAdornment, - TextField, -} from '@linode/ui'; +import { getAPIFilterFromQuery } from '@linode/search'; +import { CircleProgress, ErrorState, Stack } from '@linode/ui'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; -import * as React from 'react'; -import { debounce } from 'throttle-debounce'; +import React from 'react'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { useIsBlockStorageEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -21,6 +15,7 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableSortCell } from 'src/components/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useOrderV2 } from 'src/hooks/useOrderV2'; @@ -46,31 +41,27 @@ import { VolumesLandingEmptyState } from './VolumesLandingEmptyState'; import { VolumeTableRow } from './VolumeTableRow'; import type { Filter, Volume } from '@linode/api-v4'; -import type { - VolumeAction, - VolumesSearchParams, -} from 'src/routes/volumes/index'; +import type { VolumeAction } from 'src/routes/volumes/index'; export const VolumesLanding = () => { const navigate = useNavigate(); const params = useParams({ strict: false }); - const search: VolumesSearchParams = useSearch({ - from: '/volumes', + const search = useSearch({ + from: '/volumes/', + shouldThrow: false, }); const pagination = usePaginationV2({ currentRoute: '/volumes', preferenceKey: VOLUME_TABLE_PREFERENCE_KEY, searchParams: (prev) => ({ ...prev, - query: search.query, + query: search?.query, }), }); const isVolumeCreationRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_volumes', }); - const { query } = search; - const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { defaultOrder: { @@ -82,12 +73,17 @@ export const VolumesLanding = () => { preferenceKey: VOLUME_TABLE_PREFERENCE_KEY, }); + const { filter: searchFilter, error: searchError } = getAPIFilterFromQuery( + search?.query, + { + searchableFieldsWithoutOperator: ['label', 'tags'], + } + ); + const filter: Filter = { ['+order']: order, ['+order_by']: orderBy, - ...(query && { - label: { '+contains': query }, - }), + ...searchFilter, }; const { @@ -120,22 +116,12 @@ export const VolumesLanding = () => { }); }; - const resetSearch = () => { - navigate({ - search: (prev) => ({ - ...prev, - query: undefined, - }), - to: '/volumes', - }); - }; - - const onSearch = (e: React.ChangeEvent) => { + const onSearch = (query: string) => { navigate({ search: (prev) => ({ ...prev, page: undefined, - query: e.target.value || undefined, + query: query ? query : undefined, }), to: '/volumes', }); @@ -148,11 +134,13 @@ export const VolumesLanding = () => { }); }; + const numberOfColumns = isBlockStorageEncryptionFeatureEnabled ? 7 : 6; + if (isLoading) { return ; } - if (error) { + if (error && !search?.query) { return ( { ); } - if (volumes?.results === 0 && !query) { + if (volumes?.results === 0 && !search?.query) { return ; } return ( - <> + { docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage" entity="Volume" onButtonClick={() => navigate({ to: '/volumes/create' })} - spacingBottom={16} title="Volumes" /> - - {isFetching && } - - - - - - ), - sx: { mb: 3 }, - }} + isSearching={isFetching} label="Search" - onChange={debounce(400, (e) => { - onSearch(e); - })} + onSearch={onSearch} placeholder="Search Volumes" - value={query ?? ''} + value={search?.query ?? ''} /> @@ -250,8 +221,17 @@ export const VolumesLanding = () => { + {search?.query && error && ( + + )} {volumes?.data.length === 0 && ( - + )} {volumes?.data.map((volume) => ( { volume={selectedVolume} volumeError={selectedVolumeError} /> - + ); }; From 6ff644621a5b3c5fdce10eeb331ce9a95ad2a00d Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Sat, 26 Jul 2025 10:27:49 +0530 Subject: [PATCH 06/67] upcoming: [DI-25634] - Added linode_id to label translation in cloudpulse graph (#12558) * upcoming: [DI-25634] - Added linode_id to label translation in cloudpulse graph * upcoming: [DI-26181] - Added condition to map entities only in case of firewall * changeset added * Removed changeset --- .../pr-12558-upcoming-features-1753278473938.md | 5 +++++ .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 7 +++++++ .../CloudPulse/shared/CloudPulseResourcesSelect.tsx | 2 ++ packages/manager/src/mocks/serverHandlers.ts | 1 + .../manager/src/queries/cloudpulse/resources.ts | 13 +++++++++++++ 5 files changed, 28 insertions(+) create mode 100644 packages/manager/.changeset/pr-12558-upcoming-features-1753278473938.md diff --git a/packages/manager/.changeset/pr-12558-upcoming-features-1753278473938.md b/packages/manager/.changeset/pr-12558-upcoming-features-1753278473938.md new file mode 100644 index 00000000000..20c47c2514a --- /dev/null +++ b/packages/manager/.changeset/pr-12558-upcoming-features-1753278473938.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP: add `linode id to label` translation logic for legend rows, add `entities` to `CloudPulseResouces` interface ([#12558](https://github.com/linode/manager/pull/12558)) diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index d66d88f316a..cacdff768b2 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -323,6 +323,13 @@ export const getDimensionName = (props: DimensionNameProperties): string => { return mapResourceIdToName(value, resources); } + if (key === 'linode_id') { + return ( + resources.find((resource) => resource.entities?.[value] !== undefined) + ?.entities?.[value] ?? value + ); + } + if (key === 'metric_name' && hideMetricName) { return ''; } diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index 4172ffec9e6..98fdd6499c7 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -10,6 +10,8 @@ import { deepEqual } from '../Utils/FilterBuilder'; import type { Filter, FilterValue } from '@linode/api-v4'; export interface CloudPulseResources { + engineType?: string; + entities?: Record; id: string; label: string; region?: string; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 18123e01504..71df3800afe 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3171,6 +3171,7 @@ export const handlers = [ metric: { entity_id: '123', metric_name: 'average_cpu_usage', + linode_id: '1', node_id: 'primary-1', }, values: [ diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index ab4bbfbad7a..2cd9b1d8164 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -16,6 +16,18 @@ export const useResourcesQuery = ( enabled, select: (resources) => { return resources.map((resource) => { + const entities: Record = {}; + + // handle separately for firewall resource type + if (resourceType === 'firewall') { + resource.entities?.forEach( + (entity: { id: number; label: string; type: string }) => { + if (entity.type === 'linode') { + entities[String(entity.id)] = entity.label; + } + } + ); + } return { engineType: resource.engine, id: String(resource.id), @@ -23,6 +35,7 @@ export const useResourcesQuery = ( region: resource.region, regions: resource.regions ? resource.regions : [], tags: resource.tags, + entities, }; }); }, From 281894746328521be7766afc6256d5eb72e09cc6 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Mon, 28 Jul 2025 12:53:47 +0530 Subject: [PATCH 07/67] fix: [M3-10316] - Disabled state styling for zebra stripped table rows (#12579) * fix: [M3-10316] - Disabled state styling for zebra stripped table rows * Added changeset: Hover styling of disabled state for zebra stripped table rows * fix typo for changeset description --- packages/manager/.changeset/pr-12579-fixed-1753443210506.md | 5 +++++ packages/ui/src/foundations/themes/dark.ts | 6 +++--- packages/ui/src/foundations/themes/light.ts | 6 ++++-- 3 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-12579-fixed-1753443210506.md diff --git a/packages/manager/.changeset/pr-12579-fixed-1753443210506.md b/packages/manager/.changeset/pr-12579-fixed-1753443210506.md new file mode 100644 index 00000000000..50736becf0a --- /dev/null +++ b/packages/manager/.changeset/pr-12579-fixed-1753443210506.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Hover styling of disabled state for zebra striped table rows ([#12579](https://github.com/linode/manager/pull/12579)) diff --git a/packages/ui/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts index e71bcc3eff2..f3014ce2d05 100644 --- a/packages/ui/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -205,11 +205,11 @@ const MuiTableHeadSvgStyles = { }; const MuiTableZebraHoverStyles = { - // In dark theme, we exclude disabled rows from hover styling to maintain accessibility - '&.MuiTableRow-hover:not(.disabled-row):hover, &.Mui-selected:not(.disabled-row), &.Mui-selected:not(.disabled-row):hover': - { + '&:not(.disabled-row)': { + '&.MuiTableRow-hover:hover, &.Mui-selected, &.Mui-selected:hover': { background: Table.Row.Background.Hover, }, + }, }; const MuiTableZebraStyles = { diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index f648f837203..42f8d9bee23 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -245,8 +245,10 @@ const MuiTableHeadSvgStyles = { }; const MuiTableZebraHoverStyles = { - '&.MuiTableRow-hover:hover, &.Mui-selected, &.Mui-selected:hover': { - background: Table.Row.Background.Hover, + '&:not(.disabled-row)': { + '&.MuiTableRow-hover:hover, &.Mui-selected, &.Mui-selected:hover': { + background: Table.Row.Background.Hover, + }, }, }; From 6077b3d52da0eb5861f79c33eecf35ed088a7271 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Mon, 28 Jul 2025 15:12:38 +0530 Subject: [PATCH 08/67] fix: [M3-10384] - standardize `` error handling across components (#12581) * fix: [M3-10384] - centralize error color logic for FormHelperText * remove redundant error color * Added changeset: Standardize `` error handling across components --- packages/manager/.changeset/pr-12581-fixed-1753687703762.md | 5 +++++ .../LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx | 6 +++--- .../AddInterfaceDrawer/VPC/VPCIPv4Address.tsx | 2 +- .../PhoneVerification/PhoneVerification.styles.ts | 5 ++--- .../PhoneVerification/PhoneVerification.tsx | 2 +- packages/ui/src/components/DatePicker/DateField.tsx | 2 +- packages/ui/src/components/DatePicker/DateTimeField.tsx | 2 +- packages/ui/src/components/DatePicker/TimePicker.tsx | 2 +- packages/ui/src/components/TextField/TextField.tsx | 6 +++--- packages/ui/src/foundations/themes/dark.ts | 3 +++ packages/ui/src/foundations/themes/light.ts | 3 +++ 11 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-12581-fixed-1753687703762.md diff --git a/packages/manager/.changeset/pr-12581-fixed-1753687703762.md b/packages/manager/.changeset/pr-12581-fixed-1753687703762.md new file mode 100644 index 00000000000..a07eca6a281 --- /dev/null +++ b/packages/manager/.changeset/pr-12581-fixed-1753687703762.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Standardize `` error handling across components ([#12581](https://github.com/linode/manager/pull/12581)) 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 b58bf8acff7..2b1816d1584 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 @@ -78,11 +78,11 @@ export const InterfaceType = () => { {fieldState.error && ( ({ - color: `${theme.palette.error.dark} !important`, + sx={{ m: 0, - })} + }} > {fieldState.error.message} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx index e91694e651d..27902f837af 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx @@ -76,7 +76,7 @@ export const VPCIPv4Address = (props: Props) => { sx={{ pl: 0.4 }} /> {fieldState.error?.message && ( - {fieldState.error.message} + {fieldState.error.message} )} )} diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts index c135c36dff5..d451fb39f9e 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts @@ -98,14 +98,13 @@ export const StyledPhoneNumberInput = styled(TextField, { export const StyledFormHelperText = styled(FormHelperText, { label: 'StyledFormHelperText', -})(({ theme }) => ({ +})({ alignItems: 'center', - color: theme.color.red, display: 'flex', left: 5, top: 42, width: '100%', -})); +}); export const StyledButtonContainer = styled(Box, { label: 'StyledButtonContainer', diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx index 117645def60..f381df073db 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx @@ -323,7 +323,7 @@ export const PhoneVerification = ({ /> {sendPhoneVerificationCodeError ? ( - + {sendPhoneVerificationCodeError[0].reason} ) : null} diff --git a/packages/ui/src/components/DatePicker/DateField.tsx b/packages/ui/src/components/DatePicker/DateField.tsx index 6300f34ee09..7892a280e5b 100644 --- a/packages/ui/src/components/DatePicker/DateField.tsx +++ b/packages/ui/src/components/DatePicker/DateField.tsx @@ -81,10 +81,10 @@ export const DateField = ({ /> {errorText && ( theme.palette.error.dark, marginTop: '4px', }} > diff --git a/packages/ui/src/components/DatePicker/DateTimeField.tsx b/packages/ui/src/components/DatePicker/DateTimeField.tsx index 8bbc4d150cf..2332cb54748 100644 --- a/packages/ui/src/components/DatePicker/DateTimeField.tsx +++ b/packages/ui/src/components/DatePicker/DateTimeField.tsx @@ -88,10 +88,10 @@ export const DateTimeField = ({ /> {errorText && ( theme.palette.error.dark, marginTop: '4px', }} > diff --git a/packages/ui/src/components/DatePicker/TimePicker.tsx b/packages/ui/src/components/DatePicker/TimePicker.tsx index 73f50f057d4..b6ceacecc8e 100644 --- a/packages/ui/src/components/DatePicker/TimePicker.tsx +++ b/packages/ui/src/components/DatePicker/TimePicker.tsx @@ -106,10 +106,10 @@ export const TimePicker = ({ /> {errorText && ( theme.palette.error.dark, marginTop: '4px', }} > diff --git a/packages/ui/src/components/TextField/TextField.tsx b/packages/ui/src/components/TextField/TextField.tsx index b40e1305058..9c3db9aad52 100644 --- a/packages/ui/src/components/TextField/TextField.tsx +++ b/packages/ui/src/components/TextField/TextField.tsx @@ -512,8 +512,9 @@ export const TextField = (props: TextFieldProps) => { {errorText && ( ({ + sx={{ ...((editable || hasAbsoluteError) && { position: 'absolute', }), @@ -522,12 +523,11 @@ export const TextField = (props: TextFieldProps) => { wordBreak: 'keep-all', }), alignItems: 'center', - color: theme.tokens.component.TextField.Error.HintText, display: 'flex', left: 5, top: 42, width: '100%', - })} + }} > {errorText} diff --git a/packages/ui/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts index f3014ce2d05..056111ae97e 100644 --- a/packages/ui/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -661,6 +661,9 @@ export const darkTheme: ThemeOptions = { MuiFormHelperText: { styleOverrides: { root: { + '&[class*="error"]': { + color: Select.Error.Border, + }, fontWeight: Font.FontWeight.Semibold, color: Color.Neutrals[40], lineHeight: 1.25, diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index 42f8d9bee23..e5c40121e54 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -933,6 +933,9 @@ export const lightTheme: ThemeOptions = { MuiFormHelperText: { styleOverrides: { root: { + '&[class*="error"]': { + color: Select.Error.Border, + }, fontWeight: Font.FontWeight.Semibold, letterSpacing: 'inherit', maxWidth: 416, From 5dd19ea4cf0ab6b816bfd7b514ec6896d9f18208 Mon Sep 17 00:00:00 2001 From: agorthi-akamai Date: Mon, 28 Jul 2025 16:42:06 +0530 Subject: [PATCH 09/67] test: [DI-26399]- Add Cypress verification tests for CloudPulse NodeBalancer widget (#12568) * test[DI-26399]: add verification tests for NodeBalancer widget * test[DI-26399]: skip flaky time range picker test due to intermittent failure * test[DI-26399]: skip flaky time range picker test due to intermittent failure * test[DI-26399]: skip flaky time range picker test due to intermittent failure * test: [DI-26399]- Add Cypress verification tests for CloudPulse NodeBalancer widget --------- Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> --- .../pr-12568-tests-1753428442028.md | 5 + .../nodebalancer-widget-verification.spec.ts | 499 ++++++++++++++++++ .../cloudpulse/timerange-verification.spec.ts | 39 +- .../cypress/support/constants/widgets.ts | 47 ++ 4 files changed, 577 insertions(+), 13 deletions(-) create mode 100644 packages/manager/.changeset/pr-12568-tests-1753428442028.md create mode 100644 packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts diff --git a/packages/manager/.changeset/pr-12568-tests-1753428442028.md b/packages/manager/.changeset/pr-12568-tests-1753428442028.md new file mode 100644 index 00000000000..f17de316ec0 --- /dev/null +++ b/packages/manager/.changeset/pr-12568-tests-1753428442028.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress verification tests for CloudPulse NodeBalancer widget ([#12568](https://github.com/linode/manager/pull/12568)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts new file mode 100644 index 00000000000..ce5a5d55d1c --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts @@ -0,0 +1,499 @@ +/** + * @file Integration Tests for CloudPulse nodebalancer Dashboard. + */ +import { + linodeFactory, + nodeBalancerFactory, + regionFactory, +} from '@linode/utilities'; +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockCreateCloudPulseJWEToken, + mockCreateCloudPulseMetrics, + mockGetCloudPulseDashboard, + mockGetCloudPulseDashboards, + mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServices, +} from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetNodeBalancers } from 'support/intercepts/nodebalancers'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { generateRandomMetricsData } from 'support/util/cloudpulse'; +import { randomNumber } from 'support/util/random'; + +import { + accountFactory, + cloudPulseMetricsResponseFactory, + dashboardFactory, + dashboardMetricFactory, + kubeLinodeFactory, + widgetFactory, +} from 'src/factories'; +import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidgetUtils'; +import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; + +import type { CloudPulseMetricsResponse } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; +import type { Interception } from 'support/cypress-exports'; +/** + * This test ensures that widget titles are displayed correctly on the dashboard. + * This test suite is dedicated to verifying the functionality and display of widgets on the Cloudpulse dashboard. + * It includes: + * Validating that widgets are correctly loaded and displayed. + * Ensuring that widget titles and data match the expected values. + * Verifying that widget settings, such as granularity and aggregation, are applied correctly. + * Testing widget interactions, including zooming and filtering, to ensure proper behavior. + * Each test ensures that widgets on the dashboard operate correctly and display accurate information. + */ +const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; +const timeDurationToSelect = 'Last 24 Hours'; +const flags: Partial = { + aclp: { beta: true, enabled: true }, + aclpBetaServices: { nodebalancer: { alerts: true, metrics: true } }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'nodebalancer', + }, + ], +}; +const { dashboardName, id, metrics, region, resource, serviceType } = + widgetDetails.nodebalancer; + +const dashboard = dashboardFactory.build({ + label: dashboardName, + service_type: serviceType, + widgets: metrics.map(({ name, title, unit, yLabel }) => { + return widgetFactory.build({ + label: title, + metric: name, + unit, + y_label: yLabel, + }); + }), +}); + +const metricDefinitions = metrics.map(({ name, title, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); + +const mockRegion = regionFactory.build({ + capabilities: ['NodeBalancers'], + id: 'us-east', + label: 'Newark, NJ, USA', + monitors: { + metrics: ['NodeBalancers'], + alerts: [], + }, +}); + +const metricsAPIResponsePayload = cloudPulseMetricsResponseFactory.build({ + data: generateRandomMetricsData(timeDurationToSelect, '5 min'), +}); + +/** + * Generates graph data from a given CloudPulse metrics response and + * extracts average, last, and maximum metric values from the first + * legend row. The values are rounded to two decimal places for + * better readability. + * + * @param responsePayload - The metrics response object containing + * the necessary data for graph generation. + * @param label - The label for the graph, used for display purposes. + * + * @returns An object containing rounded values for average, last, + * + */ + +const getWidgetLegendRowValuesFromResponse = ( + responsePayload: CloudPulseMetricsResponse, + label: string, + unit: string +) => { + // Generate graph data using the provided parameters + const graphData = generateGraphData({ + label, + metricsList: responsePayload, + resources: [ + { + id: '1', + label: resource, + region: 'us-east', + }, + ], + status: 'success', + unit, + }); + + // Destructure metrics data from the first legend row + const { average, last, max } = graphData.legendRowsData[0].data; + + // Round the metrics values to two decimal places + const roundedAverage = formatToolTip(average, unit); + const roundedLast = formatToolTip(last, unit); + const roundedMax = formatToolTip(max, unit); + // Return the rounded values in an object + return { average: roundedAverage, last: roundedLast, max: roundedMax }; +}; +const mockLinode = linodeFactory.build({ + id: kubeLinodeFactory.build().instance_id ?? undefined, + label: resource, + region: 'us-east', +}); +const mockNodeBalancer = nodeBalancerFactory.build({ + label: resource, + region: 'us-east', +}); +// Tests will be modified +describe('Integration Tests for Nodebalancer Dashboard ', () => { + beforeEach(() => { + mockAppendFeatureFlags(flags); + mockGetAccount(accountFactory.build({})); + + mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + mockGetCloudPulseServices([serviceType]).as('fetchServices'); + mockGetCloudPulseDashboard(id, dashboard); + mockCreateCloudPulseJWEToken(serviceType); + mockCreateCloudPulseMetrics(serviceType, metricsAPIResponsePayload).as( + 'getMetrics' + ); + mockGetRegions([mockRegion]); + mockGetLinodes([mockLinode]); + mockGetNodeBalancers([mockNodeBalancer]); + mockGetUserPreferences({}); + + // navigate to the metrics page + cy.visitWithLogin('/metrics'); + + // Wait for the services and dashboard API calls to complete before proceeding + cy.wait(['@fetchServices', '@fetchDashboard']); + + // Selecting a dashboard from the autocomplete input. + ui.autocomplete + .findByLabel('Dashboard') + .should('be.visible') + .type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('be.visible') + .click(); + + // Select a time duration from the autocomplete input. + cy.get('[aria-labelledby="start-date"]').as('startDateInput'); + + cy.get('@startDateInput').click(); + + ui.button.findByTitle('last day').click(); + + cy.get('[data-qa-buttons="apply"]') + .should('be.visible') + .should('be.enabled') + .click(); + + // Select a region from the dropdown. + ui.regionSelect.find().click(); + ui.regionSelect.find().type(`${region}{enter}`); + + // Select a resource from the autocomplete input. + ui.autocomplete + .findByLabel('Nodebalancers') + .should('be.visible') + .type(`${resource}{enter}`); + + ui.autocomplete.findByLabel('Nodebalancers').click(); + + cy.findByText(resource).should('be.visible'); + + // Expand the applied filters section + ui.button.findByTitle('Filters').should('be.visible').click(); + + // Wait for all metrics query requests to resolve. + cy.wait(['@getMetrics', '@getMetrics', '@getMetrics', '@getMetrics']); + }); + it('should apply optional filter (port) and verify API request payloads', () => { + const randomPort = randomNumber(1, 65535).toString(); + + ui.button.findByTitle('Filters').should('be.visible').click(); + + cy.findByPlaceholderText('e.g., 80,443,3000') + .should('be.visible') + .type(randomPort); + + cy.wait(['@getMetrics', '@getMetrics', '@getMetrics', '@getMetrics']); + + cy.get('@getMetrics.all').then((calls) => { + const lastFourCalls = (calls as unknown as Interception[]).slice(-4); + + lastFourCalls.forEach((call) => { + const filters = call.request.body.filters; + expect(filters).to.deep.include({ + dimension_label: 'port', + operator: 'in', + value: randomPort, + }); + }); + }); + }); + + it('should allow users to select their desired granularity and see the most recent data from the API reflected in the graph', () => { + // validate the widget level granularity selection and its metrics + metrics.forEach((testData) => { + const widgetSelector = `[data-qa-widget="${testData.title}"]`; + cy.get(widgetSelector) + .should('be.visible') + .find('h2') + .should('have.text', `${testData.title} (${testData.unit.trim()})`); + cy.get(widgetSelector) + .should('be.visible') + .within(() => { + // check for all available granularity in popper + ui.autocomplete + .findByLabel('Select an Interval') + .should('be.visible') + .click(); + + expectedGranularityArray.forEach((option) => { + ui.autocompletePopper.findByTitle(option).should('exist'); + }); + + mockCreateCloudPulseMetrics( + serviceType, + metricsAPIResponsePayload + ).as('getGranularityMetrics'); + + // find the interval component and select the expected granularity + ui.autocomplete + .findByLabel('Select an Interval') + .should('be.visible') + .type(`${testData.expectedGranularity}{enter}`); // type expected granularity + + // check if the API call is made correctly with time granularity value selected + cy.wait('@getGranularityMetrics').then((interception) => { + expect(interception) + .to.have.property('response') + .with.property('statusCode', 200); + expect(testData.expectedGranularity).to.include( + interception.request.body.time_granularity.value + ); + }); + + // validate the widget areachart is present + cy.get('.recharts-responsive-container').within(() => { + const expectedWidgetValues = getWidgetLegendRowValuesFromResponse( + metricsAPIResponsePayload, + testData.title, + testData.unit + ); + + const graphRowTitle = `[data-qa-graph-row-title="${testData.title} (${testData.unit})"]`; + cy.get(graphRowTitle) + .should('be.visible') + .should('have.text', `${testData.title} (${testData.unit})`); + + cy.log('expectedWidgetValues ', expectedWidgetValues.max); + + cy.get(`[data-qa-graph-column-title="Max"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.max}`); + + cy.get(`[data-qa-graph-column-title="Avg"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.average}`); + + cy.get(`[data-qa-graph-column-title="Last"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.last}`); + }); + }); + }); + }); + it('should allow users to select the desired aggregation and view the latest data from the API displayed in the graph', () => { + // validate the widget level granularity selection and its metrics + + metrics.forEach((testData) => { + const widgetSelector = `[data-qa-widget="${testData.title}"]`; + cy.get(widgetSelector) + .should('be.visible') + .within(() => { + mockCreateCloudPulseMetrics( + serviceType, + metricsAPIResponsePayload + ).as('getAggregationMetrics'); + + // find the interval component and select the expected granularity + ui.autocomplete + .findByLabel('Select an Aggregate Function') + .should('be.visible') + .type(`${testData.expectedAggregation}{enter}`); // type expected granularity + + // check if the API call is made correctly with time granularity value selected + cy.wait('@getAggregationMetrics').then((interception) => { + expect(interception) + .to.have.property('response') + .with.property('statusCode', 200); + expect(testData.expectedAggregation).to.equal( + interception.request.body.metrics[0].aggregate_function + ); + }); + + // validate the widget areachart is present + cy.get('.recharts-responsive-container').within(() => { + const expectedWidgetValues = getWidgetLegendRowValuesFromResponse( + metricsAPIResponsePayload, + testData.title, + testData.unit + ); + const graphRowTitle = `[data-qa-graph-row-title="${testData.title} (${testData.unit})"]`; + cy.get(graphRowTitle) + .should('be.visible') + .should( + 'have.text', + `${testData.title} (${testData.unit.trim()})` + ); + + cy.get(`[data-qa-graph-column-title="Max"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.max}`); + + cy.get(`[data-qa-graph-column-title="Avg"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.average}`); + + cy.get(`[data-qa-graph-column-title="Last"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.last}`); + }); + }); + }); + }); + it('should trigger the global refresh button and verify the corresponding network calls', () => { + // mock the API call for refreshing metrics + mockCreateCloudPulseMetrics(serviceType, metricsAPIResponsePayload).as( + 'refreshMetrics' + ); + + // click the global refresh button + ui.button + .findByAttribute('aria-label', 'Refresh Dashboard Metrics') + .should('be.visible') + .click(); + + // validate the API calls are going with intended payload + cy.get('@refreshMetrics.all') + .should('have.length', 4) + .each((xhr: unknown) => { + const interception = xhr as Interception; + const { body: requestPayload } = interception.request; + const { metrics: metric, relative_time_duration: timeRange } = + requestPayload; + const metricData = metrics.find(({ name }) => name === metric[0].name); + + if (!metricData) { + throw new Error( + `Unexpected metric name '${metric[0].name}' included in the outgoing refresh API request` + ); + } + expect(metric[0].name).to.equal(metricData.name); + expect(timeRange).to.have.property('unit', 'days'); + expect(timeRange).to.have.property('value', 1); + }); + }); + + it('should zoom in and out of all the widgets', () => { + // do zoom in and zoom out test on all the widgets + metrics.forEach((testData) => { + cy.get(`[data-qa-widget="${testData.title}"]`).as('widget'); + cy.get('@widget') + .should('be.visible') + .within(() => { + ui.button + .findByAttribute('aria-label', 'Zoom Out') + .should('be.visible') + .should('be.enabled') + .click(); + cy.get('@widget').should('be.visible'); + cy.get('.recharts-responsive-container').within(() => { + const expectedWidgetValues = getWidgetLegendRowValuesFromResponse( + metricsAPIResponsePayload, + testData.title, + testData.unit + ); + const graphRowTitle = `[data-qa-graph-row-title="${testData.title} (${testData.unit})"]`; + cy.get(graphRowTitle) + .should('be.visible') + .should('have.text', `${testData.title} (${testData.unit})`); + + cy.get(`[data-qa-graph-column-title="Max"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.max}`); + + cy.get(`[data-qa-graph-column-title="Avg"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.average}`); + + cy.get(`[data-qa-graph-column-title="Last"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.last}`); + }); + + // click zoom out and validate the same + ui.button + .findByAttribute('aria-label', 'Zoom In') + .should('be.visible') + .should('be.enabled') + .scrollIntoView() + .click({ force: true }); + cy.get('@widget').should('be.visible'); + + cy.get('.recharts-responsive-container').within(() => { + const expectedWidgetValues = getWidgetLegendRowValuesFromResponse( + metricsAPIResponsePayload, + testData.title, + testData.unit + ); + + const graphRowTitle = `[data-qa-graph-row-title="${testData.title} (${testData.unit})"]`; + cy.get(graphRowTitle) + .should('be.visible') + .should( + 'have.text', + `${testData.title} (${testData.unit.trim()})` + ); + + cy.get(`[data-qa-graph-column-title="Max"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.max}`); + + cy.get(`[data-qa-graph-column-title="Avg"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.average}`); + + cy.get(`[data-qa-graph-column-title="Last"]`) + .should('be.visible') + .should('have.text', `${expectedWidgetValues.last}`); + }); + }); + }); + }); +}); 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 54d342965c0..c2f9eb570be 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ /** * @file Integration Tests for CloudPulse Custom and Preset Verification */ @@ -259,6 +260,7 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura minute: endMinute, } = getDateRangeInGMT(12, 30); + cy.wait(1000); // --- Select start date --- cy.get('[aria-labelledby="start-date"]').as('startDateInput'); cy.get('@startDateInput').click(); @@ -266,17 +268,20 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.findAllByText(startDay).first().click(); cy.findAllByText(endDay).first().click(); }); + ui.button .findByAttribute('aria-label^', 'Choose time') .first() - .should('be.visible') + .should('be.visible', { timeout: 10000 }) // waits up to 10 seconds .as('timePickerButton'); - cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - cy.get('@timePickerButton').click(); + cy.get('@timePickerButton', { timeout: 15000 }) + .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) + .click(); // Selects the start hour, minute, and meridiem (AM/PM) in the time picker. + cy.wait(1000); cy.findByLabelText('Select hours') .as('selectHours') .scrollIntoView({ easing: 'linear' }); @@ -285,15 +290,18 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get(`[aria-label="${startHour} hours"]`).click(); }); + cy.wait(1000); ui.button .findByAttribute('aria-label^', 'Choose time') .first() - .should('be.visible') + .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - cy.get('@timePickerButton').click(); + cy.get('@timePickerButton', { timeout: 15000 }) + .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) + .click(); cy.findByLabelText('Select minutes') .as('selectMinutes') @@ -306,12 +314,14 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura ui.button .findByAttribute('aria-label^', 'Choose time') .first() - .should('be.visible') + .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - cy.get('@timePickerButton').click(); + cy.get('@timePickerButton', { timeout: 15000 }) + .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) + .click(); cy.findByLabelText('Select meridiem') .as('startMeridiemSelect') @@ -322,10 +332,10 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura ui.button .findByAttribute('aria-label^', 'Choose time') .last() - .should('be.visible') + .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); - cy.get('@timePickerButton').click(); + cy.get('@timePickerButton', { timeout: 15000 }).click(); // Selects the start hour, minute, and meridiem (AM/PM) in the time picker. cy.findByLabelText('Select hours').scrollIntoView({ @@ -341,8 +351,9 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura .should('be.visible') .as('timePickerButton'); - cy.get('@timePickerButton').click(); - + cy.get('@timePickerButton', { timeout: 15000 }) + .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) + .click(); cy.findByLabelText('Select minutes').scrollIntoView({ duration: 500, easing: 'linear', @@ -353,10 +364,12 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get('[aria-label^="Choose time"]') .last() - .should('be.visible') + .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); - cy.get('@timePickerButton').click(); + cy.get('@timePickerButton', { timeout: 15000 }) + .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) + .click(); cy.findByLabelText('Select meridiem') .as('endMeridiemSelect') diff --git a/packages/manager/cypress/support/constants/widgets.ts b/packages/manager/cypress/support/constants/widgets.ts index 30c97edaf94..fbf5d4e1291 100644 --- a/packages/manager/cypress/support/constants/widgets.ts +++ b/packages/manager/cypress/support/constants/widgets.ts @@ -98,4 +98,51 @@ export const widgetDetails = { resource: 'linode-resource', serviceType: 'linode', }, + nodebalancer: { + dashboardName: 'NodeBalancer Dashboard', + id: 1, + metrics: [ + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_cpu_utilization_percent', + title: 'CPU Utilization', + unit: '%', + yLabel: 'system_cpu_utilization_ratio', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_memory_usage_by_resource', + title: 'Memory Usage', + unit: 'B', + yLabel: 'system_memory_usage_bytes', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_network_io_by_resource', + title: 'Network Traffic', + unit: 'B', + yLabel: 'system_network_io_bytes_total', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_disk_OPS_total', + title: 'Disk I/O', + unit: 'OPS', + yLabel: 'system_disk_operations_total', + }, + ], + region: 'Newark, NJ, USA (us-east)', + resource: 'NodeBalancer-resource', + serviceType: 'nodebalancer', + port: 1, + protocols: ['TCP', 'UDP'], + }, }; From 4532307b7df77b319e81d0987abb54f79eacc155 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Mon, 28 Jul 2025 19:22:10 +0530 Subject: [PATCH 10/67] fix: [M3-9993, M3-10147] - Disabled Region select in Nodebalancer create flow and aligned CloseIcon for Textfield (#12571) * fix: [M3-9993, M3-10147] - Disabled Region select in Nodebalancer create flow and aligned CloseIcon for Textfield * Added changeset: Alignment of Closeicon in Textfields and Disabled Region select in NB create flow for restricted user * fixed focus state * Added changeset: Disabled Region select in NB create flow for restricted user * fixed PR description --- .../manager/.changeset/pr-12571-fixed-1753356045864.md | 5 +++++ .../manager/.changeset/pr-12571-fixed-1753697731353.md | 5 +++++ .../src/components/MultipleIPInput/MultipleIPInput.tsx | 5 +++++ .../src/features/NodeBalancers/NodeBalancerCreate.tsx | 1 + .../manager/src/features/VPCs/VPCCreate/SubnetNode.tsx | 8 ++++---- 5 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-12571-fixed-1753356045864.md create mode 100644 packages/manager/.changeset/pr-12571-fixed-1753697731353.md diff --git a/packages/manager/.changeset/pr-12571-fixed-1753356045864.md b/packages/manager/.changeset/pr-12571-fixed-1753356045864.md new file mode 100644 index 00000000000..f6d8beae635 --- /dev/null +++ b/packages/manager/.changeset/pr-12571-fixed-1753356045864.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Alignment of Closeicon in Textfields ([#12571](https://github.com/linode/manager/pull/12571)) diff --git a/packages/manager/.changeset/pr-12571-fixed-1753697731353.md b/packages/manager/.changeset/pr-12571-fixed-1753697731353.md new file mode 100644 index 00000000000..0e18b6d928c --- /dev/null +++ b/packages/manager/.changeset/pr-12571-fixed-1753697731353.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disabled Region select in NB create flow for restricted user ([#12571](https://github.com/linode/manager/pull/12571)) diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index baca2bc1224..2a89c34e545 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -291,6 +291,11 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { data-testid="button" disabled={disabled} onClick={() => removeInput(idx)} + sx={(theme) => ({ + height: 20, + width: 20, + marginTop: `${theme.spacingFunction(8)} !important`, + })} > diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 688d6af3f2c..ef64ca8c470 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -678,6 +678,7 @@ const NodeBalancerCreate = () => { { }; const StyledButton = styled(Button, { label: 'StyledButton' })(({ theme }) => ({ - '& :hover, & :focus': { - backgroundColor: theme.color.grey2, - }, '& > span': { padding: 2, }, + height: 20, + width: 20, + borderRadius: '50%', color: theme.textColors.tableHeader, - marginTop: theme.spacing(6), + marginTop: 52, minHeight: 'auto', minWidth: 'auto', padding: 0, From bb043d84fe1bb69040257625c04a1b088ba0f767 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 28 Jul 2025 21:56:19 +0530 Subject: [PATCH 11/67] change: [M3-10348, M3-10013] - Add/update inline docs for ACLP Alerts logic and fix Longview UI alignment issue (#12578) * Add and update code comments * Fix longview UI alignment issue * Added changeset: Longview UI alignment issue * Added changeset: Add/update inline docs for ACLP Alerts logic --- .../manager/.changeset/pr-12578-fixed-1753437741673.md | 5 +++++ .../pr-12578-upcoming-features-1753437780675.md | 5 +++++ .../manager/src/features/Linodes/AclpPreferenceToggle.tsx | 7 +++++++ .../Longview/shared/InstallationInstructions.styles.ts | 2 +- packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts | 8 +++----- 5 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-12578-fixed-1753437741673.md create mode 100644 packages/manager/.changeset/pr-12578-upcoming-features-1753437780675.md diff --git a/packages/manager/.changeset/pr-12578-fixed-1753437741673.md b/packages/manager/.changeset/pr-12578-fixed-1753437741673.md new file mode 100644 index 00000000000..0528c33d1a8 --- /dev/null +++ b/packages/manager/.changeset/pr-12578-fixed-1753437741673.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Longview UI alignment issue ([#12578](https://github.com/linode/manager/pull/12578)) diff --git a/packages/manager/.changeset/pr-12578-upcoming-features-1753437780675.md b/packages/manager/.changeset/pr-12578-upcoming-features-1753437780675.md new file mode 100644 index 00000000000..715a24a47f4 --- /dev/null +++ b/packages/manager/.changeset/pr-12578-upcoming-features-1753437780675.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add/update inline docs for ACLP Alerts logic ([#12578](https://github.com/linode/manager/pull/12578)) diff --git a/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx b/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx index e23bc557376..831695dd3db 100644 --- a/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx +++ b/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx @@ -67,6 +67,13 @@ const preferenceConfig: Record< }, }; +/** + * - For Alerts, the toggle uses local state, not preferences. We do this because each Linode should manage its own alert mode individually. + * - Create Linode: Toggle defaults to false (legacy mode). It's a simple UI toggle with no persistence. + * - Edit Linode: Toggle defaults based on useIsLinodeAclpSubscribed (true if the Linode is already subscribed to ACLP). Still local state - not saved to preferences. + * + * - For Metrics, we use account-level preferences, since it's a global setting shared across all Linodes. + */ export const AclpPreferenceToggle = (props: AclpPreferenceToggleType) => { const { isAlertsBetaMode, onAlertsModeChange, type } = props; diff --git a/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts b/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts index 4a8de82114e..98d7e1e943d 100644 --- a/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts +++ b/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts @@ -16,7 +16,7 @@ export const StyledInstructionGrid = styled(Grid, { content: "'|'", left: `calc(-${theme.spacing(1)} + 2px)`, position: 'absolute', - top: `calc(${theme.spacing(1)} - 3px)`, + top: `calc(${theme.spacing(1)} - 8px)`, }, marginLeft: theme.spacing(2), paddingLeft: theme.spacing(2), diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts index 2f4eb5b07de..df71aa5b692 100644 --- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts +++ b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts @@ -7,14 +7,12 @@ type AclpStage = 'beta' | 'ga'; * * ### Cases: * - Legacy alerts = 0, Beta alerts = [] - * - Show default Legacy UI (disabled) for Beta - * - Show default Beta UI (disabled) for GA + * - Show default Legacy UI (disabled) for Beta stage + * - Show default Beta UI (disabled) for GA stage * - Legacy alerts > 0, Beta alerts = [] * - Show default Legacy UI (enabled) * - Legacy alerts = 0, Beta alerts has values (either system, user, or both) - * - Show default Beta UI - * - Legacy alerts > 0, Beta alerts has values (either system, user, or both) - * - Show default Beta UI + * - Show default Beta UI (enabled) * * @param linodeId - The ID of the Linode * @param stage - The current ACLP stage: 'beta' or 'ga' From a0b6aa5103cdda11b05d6eafd5cd738ff226bab6 Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Mon, 28 Jul 2025 22:05:52 +0530 Subject: [PATCH 12/67] upcoming: [DI-25992] - Updated AlertRegions component to extend the functionality (#12582) * upcoming: [DI-25992] - Updated AlertRegions component to extend the functionality * Added changeset * Fix typo issue --- ...r-12582-upcoming-features-1753455419647.md | 5 + .../Alerts/AlertRegions/AlertRegions.test.tsx | 36 +++++- .../Alerts/AlertRegions/AlertRegions.tsx | 64 +++++++++-- .../AlertRegions/DisplayAlertRegions.test.tsx | 11 +- .../AlertRegions/DisplayAlertRegions.tsx | 104 ++++++++++++++---- .../AlertsResources/AlertsResources.tsx | 9 +- .../Utils/AlertMaxSelectionText.test.tsx | 17 +++ .../Alerts/Utils/AlertMaxSelectionText.tsx | 18 +++ .../AlertSelectedInfoNotice.tsx} | 36 +++--- .../features/CloudPulse/Alerts/Utils/utils.ts | 65 +++++++++++ .../features/CloudPulse/Alerts/constants.ts | 5 + 11 files changed, 315 insertions(+), 55 deletions(-) create mode 100644 packages/manager/.changeset/pr-12582-upcoming-features-1753455419647.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.tsx rename packages/manager/src/features/CloudPulse/Alerts/{AlertsResources/AlertsResourcesNotice.tsx => Utils/AlertSelectedInfoNotice.tsx} (74%) diff --git a/packages/manager/.changeset/pr-12582-upcoming-features-1753455419647.md b/packages/manager/.changeset/pr-12582-upcoming-features-1753455419647.md new file mode 100644 index 00000000000..e81fc7c072b --- /dev/null +++ b/packages/manager/.changeset/pr-12582-upcoming-features-1753455419647.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP: add checkbox functionality in `AlertRegions`. ([#12582](https://github.com/linode/manager/pull/12582)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx index f54a12cb28b..a5d746fc5dd 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx @@ -1,10 +1,12 @@ import { regionFactory } from '@linode/utilities'; -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { databaseFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { REGION_GROUP_INFO_MESSAGE } from '../constants'; import { AlertRegions } from './AlertRegions'; import type { AlertServiceType } from '@linode/api-v4'; @@ -38,7 +40,7 @@ vi.mock('src/hooks/useFlags', async (importOriginal) => ({ queryMocks.useRegionsQuery.mockReturnValue({ data: regions, isLoading: false, - isError: false, + isErrro: false, }); queryMocks.useResourcesQuery.mockReturnValue({ @@ -57,11 +59,41 @@ const component = ( describe('Alert Regions', () => { it('Should render the filters and notices ', () => { renderWithTheme(component); + const text = screen.getByText(REGION_GROUP_INFO_MESSAGE); const regionSearch = screen.getByTestId('region-search'); const showSelectedOnly = screen.getByTestId('show-selected-only'); + expect(text).toBeInTheDocument(); expect(regionSearch).toBeInTheDocument(); expect(showSelectedOnly).toBeInTheDocument(); }); + + it('should select all regions when the select all checkbox is checked', async () => { + renderWithTheme(component); + + const selectAllCheckbox = within( + screen.getByTestId('select-all-checkbox') + ).getByRole('checkbox'); + await userEvent.click(selectAllCheckbox); + + expect(selectAllCheckbox).toBeChecked(); + + const notice = screen.getByTestId('selection_notice'); + + expect(notice.textContent).toBe('1 of 1 regions are selected.'); + }); + + it('should show only header on click of show selected only', async () => { + renderWithTheme(component); + + const checkbox = within(screen.getByTestId('show-selected-only')).getByRole( + 'checkbox' + ); + + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + expect(screen.getAllByRole('row').length).toBe(1); // Only header row should be visible + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx index c360cb33a0b..17d39dd93fa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx @@ -6,12 +6,18 @@ import React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { type AlertFormMode } from '../constants'; +import { + type AlertFormMode, + REGION_GROUP_INFO_MESSAGE, + type SelectDeselectAll, +} from '../constants'; import { AlertListNoticeMessages } from '../Utils/AlertListNoticeMessages'; -import { getSupportedRegions } from '../Utils/utils'; +import { AlertSelectedInfoNotice } from '../Utils/AlertSelectedInfoNotice'; +import { getFilteredRegions } from '../Utils/utils'; import { DisplayAlertRegions } from './DisplayAlertRegions'; -import type { AlertServiceType, Filter, Region } from '@linode/api-v4'; +import type { AlertRegion } from './DisplayAlertRegions'; +import type { AlertServiceType, Filter } from '@linode/api-v4'; interface AlertRegionsProps { /** @@ -40,9 +46,7 @@ export const AlertRegions = React.memo((props: AlertRegionsProps) => { const { serviceType, handleChange, value = [], errorText, mode } = props; const [searchText, setSearchText] = React.useState(''); const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); - - // Todo: State variable will be added when checkbox functionality implemented - const [, setSelectedRegions] = React.useState(value); + const [selectedRegions, setSelectedRegions] = React.useState(value); const [showSelected, setShowSelected] = React.useState(false); const resourceFilterMap: Record = { @@ -84,27 +88,50 @@ export const AlertRegions = React.memo((props: AlertRegionsProps) => { [handleChange] ); - const filteredRegionsWithStatus: Region[] = React.useMemo( + const filteredRegionsWithStatus: AlertRegion[] = React.useMemo( () => - getSupportedRegions({ + getFilteredRegions({ serviceType, + selectedRegions, resources, regions, }), - [regions, resources, serviceType] + [regions, resources, selectedRegions, serviceType] + ); + + const handleSelectAll = React.useCallback( + (action: SelectDeselectAll) => { + let regionIds: string[] = []; + if (action === 'Select All') { + regionIds = filteredRegionsWithStatus?.map((region) => region.id) ?? []; + } + + setSelectedRegions(regionIds); + if (handleChange) { + handleChange(regionIds); + } + }, + [filteredRegionsWithStatus, handleChange] ); if (isRegionsLoading || isResourcesLoading) { return ; } const filteredRegionsBySearchText = filteredRegionsWithStatus.filter( - ({ label }) => label.toLowerCase().includes(searchText.toLowerCase()) + ({ label, checked }) => + label.toLowerCase().includes(searchText.toLowerCase()) && + ((mode && checked) || !mode) ); return ( {mode === 'view' && Regions} + + { )} + {mode !== 'view' && ( + + )} 0 && + selectedRegions.length === filteredRegionsWithStatus.length + } + isSomeSelected={ + selectedRegions.length > 0 && + selectedRegions.length !== filteredRegionsWithStatus.length + } mode={mode} regions={filteredRegionsBySearchText} showSelected={showSelected} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.test.tsx index 8fbfe8c6b0c..1d78fed424f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.test.tsx @@ -7,14 +7,21 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { DisplayAlertRegions } from './DisplayAlertRegions'; -const regions = regionFactory.buildList(10); +const regions = regionFactory.buildList(10).map(({ id, label }) => ({ + id, + label, + checked: false, + count: Math.random(), +})); const handleChange = vi.fn(); +const handleSelectAll = vi.fn(); describe('DisplayAlertRegions', () => { it('should render the regions table', () => { renderWithTheme( @@ -30,6 +37,7 @@ describe('DisplayAlertRegions', () => { it('should display checkbox and label', () => { renderWithTheme( @@ -48,6 +56,7 @@ describe('DisplayAlertRegions', () => { it('should select checkbox when clicked', async () => { renderWithTheme( diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.tsx index 9df2c56bb9c..c5a41bdd1e7 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.tsx @@ -10,15 +10,45 @@ import { TableContentWrapper } from 'src/components/TableContentWrapper/TableCon import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; -import type { AlertFormMode } from '../constants'; -import type { Region } from '@linode/api-v4'; +import type { AlertFormMode, SelectDeselectAll } from '../constants'; + +export interface AlertRegion { + /** + * Indicates if the region is selected. + * This is used to determine if the region should be checked in the UI. + */ + checked: boolean; + /** + * The number of associated entities in the region. + */ + count: number; + /** + * Id of the region + */ + id: string; + /** + * Label of the region. + */ + label: string; +} interface DisplayAlertRegionProps { + /** + * Function to handle the selection of all regions. + */ + handleSelectAll: (action: SelectDeselectAll) => void; /** * Function to handle the change in selection of a region. */ handleSelectionChange: (regionId: string, isChecked: boolean) => void; - + /** + * Indicates if all regions are selected. + */ + isAllSelected?: boolean; + /** + * Indicates if some regions are selected. + */ + isSomeSelected?: boolean; /** * Flag to indicate the mode of the form */ @@ -26,7 +56,7 @@ interface DisplayAlertRegionProps { /** * List of regions to be displayed. */ - regions?: Region[]; + regions?: AlertRegion[]; /** * To indicate whether to show only selected regions or not. */ @@ -35,7 +65,15 @@ interface DisplayAlertRegionProps { export const DisplayAlertRegions = React.memo( (props: DisplayAlertRegionProps) => { - const { regions, handleSelectionChange, mode } = props; + const { + regions, + handleSelectionChange, + isSomeSelected, + isAllSelected, + showSelected, + handleSelectAll, + mode, + } = props; return ( @@ -60,8 +98,14 @@ export const DisplayAlertRegions = React.memo( {}} + indeterminate={isSomeSelected && !isAllSelected} + onChange={(_, checked) => + handleSelectAll( + checked ? 'Select All' : 'Deselect All' + ) + } /> @@ -76,6 +120,16 @@ export const DisplayAlertRegions = React.memo( > Region + {}} + label="Associated Entities" + > + Associated Entities + @@ -83,25 +137,29 @@ export const DisplayAlertRegions = React.memo( length={regions?.length ?? 0} loading={false} > - {paginatedData.map(({ label, id }) => { - return ( - - {mode !== 'view' && ( + {paginatedData + ?.filter(({ checked }) => (showSelected ? checked : true)) + .map(({ label, id, checked, count }) => { + return ( + + {mode !== 'view' && ( + + + handleSelectionChange(id, status) + } + /> + + )} + - - handleSelectionChange(id, status) - } - /> + {label} ({id}) - )} - - - {label} ({id}) - - - ); - })} + {count} + + ); + })}
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 50778292c78..07f586e6cd1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -18,8 +18,8 @@ import { getSupportedRegionIds, scrollToElement, } from '../Utils/AlertResourceUtils'; +import { AlertSelectedInfoNotice } from '../Utils/AlertSelectedInfoNotice'; import { AlertResourcesFilterRenderer } from './AlertsResourcesFilterRenderer'; -import { AlertsResourcesNotice } from './AlertsResourcesNotice'; import { databaseTypeClassMap, getSearchPlaceholderText, @@ -454,11 +454,12 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { resources && resources.length > 0 && ( - )} diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.test.tsx new file mode 100644 index 00000000000..3d9b4f78dbd --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertMaxSelectionText } from './AlertMaxSelectionText'; + +describe('AlertMaxSelectionText', () => { + it('displays correct max selection text based on passed maxSelectionCount', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('warning-tip')).toBeInTheDocument(); + expect(getByTestId('warning-tip')).toHaveTextContent( + 'You can select up to 2 entities.' + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.tsx b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.tsx new file mode 100644 index 00000000000..3016971ab43 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.tsx @@ -0,0 +1,18 @@ +import { Typography } from '@linode/ui'; +import React from 'react'; + +interface AlertMaxSelectionTextProps { + /** + * The maximum selection count that needs to displayed in the text + */ + maxSelectionCount: number; +} + +export const AlertMaxSelectionText = (props: AlertMaxSelectionTextProps) => { + const { maxSelectionCount } = props; + return ( + + You can select up to {maxSelectionCount} entities. + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertSelectedInfoNotice.tsx similarity index 74% rename from packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx rename to packages/manager/src/features/CloudPulse/Alerts/Utils/AlertSelectedInfoNotice.tsx index c5b9660832c..72e85b49be6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertSelectedInfoNotice.tsx @@ -4,46 +4,52 @@ import React from 'react'; import { AlertMaxSelectionText } from './AlertMaxSelectionText'; -import type { SelectDeselectAll } from './AlertsResources'; +import type { SelectDeselectAll } from '../constants'; -interface AlertResourceNoticeProps { +interface AlertSelectedInfoNoticeProps { /** * Callback to handle selection changes (select all or deselect all). */ handleSelectionChange: (action: SelectDeselectAll) => void; /** - * The maximum number of resources that can be selected based on service type. + * The maximum number of elements that can be selected. */ maxSelectionCount?: number; /** - * The number of currently selected resources. + * The property that is selected. Ex: regions, entities etc */ - selectedResources: number; + property: string; /** - * The total number of available resources. + * The number of currently selected elements. */ - totalResources: number; + selectedCount: number; + + /** + * The total number of available elements. + */ + totalCount: number; } -export const AlertsResourcesNotice = React.memo( - (props: AlertResourceNoticeProps) => { +export const AlertSelectedInfoNotice = React.memo( + (props: AlertSelectedInfoNoticeProps) => { const { handleSelectionChange, maxSelectionCount, - selectedResources, - totalResources, + selectedCount, + totalCount, + property, } = props; const isSelectAll = maxSelectionCount !== undefined - ? selectedResources === 0 - : selectedResources < totalResources; + ? selectedCount === 0 + : selectedCount < totalCount; const buttonText = isSelectAll ? 'Select All' : 'Deselect All'; const isButtonDisabled = isSelectAll && maxSelectionCount !== undefined - ? totalResources > maxSelectionCount + ? totalCount > maxSelectionCount : false; return ( @@ -54,7 +60,7 @@ export const AlertsResourcesNotice = React.memo( fontFamily: theme.tokens.alias.Typography.Body.Bold, })} > - {selectedResources} of {totalResources} entities are selected. + {selectedCount} of {totalCount} {property} are selected. { singleLineErrorSeparator: string; } +interface FilterRegionProps { + /** + * The list of regions + */ + regions?: Region[]; + /** + * The list of resources + */ + resources?: CloudPulseResources[]; + /** + * The selected region ids + */ + selectedRegions: string[]; + /** + * The service type for which the regions are being filtered + */ + serviceType: AlertServiceType | null; +} + interface SupportedRegionsProps { /** * The list of regions @@ -453,6 +473,51 @@ export const handleMultipleError = ( } }; +/** + * + * @param props The props required to filter the regions + * @returns The filtered regions based on the selected regions and resources + */ +export const getFilteredRegions = (props: FilterRegionProps): AlertRegion[] => { + const { regions, resources, selectedRegions, serviceType } = props; + + const supportedRegionsFromResources = getSupportedRegions({ + regions, + resources, + serviceType, + }); + + // map region to its resources count + const regionToResourceCount = + resources?.reduce( + (previous, { region }) => { + if (!region) return previous; + return { + ...previous, + [region]: (previous[region] ?? 0) + 1, + }; + }, + {} as { [region: string]: number } + ) ?? {}; + + return supportedRegionsFromResources.map(({ label, id }) => { + const data = { label, id }; + + if (selectedRegions.includes(id)) { + return { + ...data, + checked: true, + count: regionToResourceCount[id] ?? 0, + }; + } + return { + ...data, + checked: false, + count: regionToResourceCount[id] ?? 0, + }; + }); +}; + /** * * @param props The props required to get the supported regions diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index b1fded774cb..098d522c3aa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -201,8 +201,13 @@ export const ALERT_SCOPE_TOOLTIP_TEXT = export const ALERT_SCOPE_TOOLTIP_CONTEXTUAL = 'Indicates whether the alert applies to all entities in the account, entities in specific regions, or just this entity.'; +export const REGION_GROUP_INFO_MESSAGE = + 'This alert applies to all entities associated with selected regions, and will be applied to any new entities that are added. The alert is triggered per entity rather than being based on the aggregated data for all entities.'; + export type AlertFormMode = 'create' | 'edit' | 'view'; +export type SelectDeselectAll = 'Deselect All' | 'Select All'; + export const DELETE_ALERT_SUCCESS_MESSAGE = 'Alert successfully deleted.'; export const PORTS_TRAILING_COMMA_ERROR_MESSAGE = From ef3a4e4201f4f2cde296a2f6412c3de4f7a348db Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:53:32 -0400 Subject: [PATCH 13/67] fix: [M3-9986] - Selected Geographical Area state in Distributed Region select (#12584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Fix selected Geographical Area persistence issue when switching between Core and Distributed Region tabs ## How to test 🧪 ### Prerequisites (How to setup test environment) - Ensure gecko feature flag is enabled and your account has gecko customer tags (reach out for more info) ### Verification steps (How to verify changes) - [ ] Checkout this branch or preview link and go to the Linode Create page - [ ] Click on the Distributed Region tab, select a value other than All, click on the Core Region tab, switch back to the Distributed tab - [ ] You should see the Geographical Area persist the value you selected before switching and the Regions are still filtered accordingly --- .../pr-12584-fixed-1753473403068.md | 5 + .../LinodeCreate/TwoStepRegion.test.tsx | 110 +++++++++--------- .../Linodes/LinodeCreate/TwoStepRegion.tsx | 3 + 3 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 packages/manager/.changeset/pr-12584-fixed-1753473403068.md diff --git a/packages/manager/.changeset/pr-12584-fixed-1753473403068.md b/packages/manager/.changeset/pr-12584-fixed-1753473403068.md new file mode 100644 index 00000000000..d124212d7e7 --- /dev/null +++ b/packages/manager/.changeset/pr-12584-fixed-1753473403068.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Geographical Area state in Distributed Region select in Linode Create flow ([#12584](https://github.com/linode/manager/pull/12584)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.test.tsx index 998b134cf49..87dc7b09080 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.test.tsx @@ -1,3 +1,4 @@ +import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import React from 'react'; @@ -6,36 +7,14 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { TwoStepRegion } from './TwoStepRegion'; -const queryMocks = vi.hoisted(() => ({ - useNavigate: vi.fn(), - useParams: vi.fn(), - useSearch: vi.fn(), -})); - -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useNavigate: queryMocks.useNavigate, - useSearch: queryMocks.useSearch, - useParams: queryMocks.useParams, - }; -}); - describe('TwoStepRegion', () => { - beforeEach(() => { - queryMocks.useNavigate.mockReturnValue(vi.fn()); - queryMocks.useSearch.mockReturnValue({}); - queryMocks.useParams.mockReturnValue({}); - }); - it('should render a heading and docs link', () => { - const { getAllByText, getByText } = renderWithThemeAndHookFormContext({ + renderWithThemeAndHookFormContext({ component: , }); - const heading = getAllByText('Region')[0]; - const link = getByText(DOCS_LINK_LABEL_DC_PRICING); + const heading = screen.getAllByText('Region')[0]; + const link = screen.getByText(DOCS_LINK_LABEL_DC_PRICING); expect(heading).toBeVisible(); expect(heading.tagName).toBe('H2'); @@ -46,36 +25,36 @@ describe('TwoStepRegion', () => { }); it('should render two tabs, Core and Distributed', () => { - const { getAllByRole } = renderWithThemeAndHookFormContext({ + renderWithThemeAndHookFormContext({ component: , }); - const tabs = getAllByRole('tab'); - expect(tabs[0]).toHaveTextContent('Core'); - expect(tabs[1]).toHaveTextContent('Distributed'); + const [coreTab, distributedTab] = screen.getAllByRole('tab'); + + expect(coreTab).toHaveTextContent('Core'); + expect(distributedTab).toHaveTextContent('Distributed'); }); it('should render a Region Select for the Core tab', () => { - const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + renderWithThemeAndHookFormContext({ component: , }); - const select = getByPlaceholderText('Select a Region'); + const regionSelect = screen.getByPlaceholderText('Select a Region'); - expect(select).toBeVisible(); - expect(select).toBeEnabled(); + expect(regionSelect).toBeVisible(); + expect(regionSelect).toBeEnabled(); }); it('should only display core regions in the Core tab region select', async () => { - const { getByPlaceholderText, getByRole } = - renderWithThemeAndHookFormContext({ - component: , - }); + renderWithThemeAndHookFormContext({ + component: , + }); - const select = getByPlaceholderText('Select a Region'); - await userEvent.click(select); + const regionSelect = screen.getByPlaceholderText('Select a Region'); + await userEvent.click(regionSelect); - const dropdown = getByRole('listbox'); + const dropdown = screen.getByRole('listbox'); expect(dropdown.innerHTML).toContain('US, Newark'); expect(dropdown.innerHTML).not.toContain( 'US, Gecko Distributed Region Test' @@ -83,36 +62,57 @@ describe('TwoStepRegion', () => { }); it('should only display distributed regions in the Distributed tab region select', async () => { - const { getAllByRole, getByPlaceholderText, getByRole } = - renderWithThemeAndHookFormContext({ - component: , - }); + renderWithThemeAndHookFormContext({ + component: , + }); - const tabs = getAllByRole('tab'); - await userEvent.click(tabs[1]); + const distributedTab = screen.getByRole('tab', { name: 'Distributed' }); + await userEvent.click(distributedTab); - const select = getByPlaceholderText('Select a Region'); - await userEvent.click(select); + const regionSelect = screen.getByPlaceholderText('Select a Region'); + await userEvent.click(regionSelect); - const dropdown = getByRole('listbox'); + const dropdown = screen.getByRole('listbox'); expect(dropdown.innerHTML).toContain('US, Gecko Distributed Region Test'); expect(dropdown.innerHTML).not.toContain('US, Newark'); }); it('should render a Geographical Area select with All pre-selected and a Region Select for the Distributed tab', async () => { - const { getAllByRole } = renderWithThemeAndHookFormContext({ + renderWithThemeAndHookFormContext({ component: , }); - const tabs = getAllByRole('tab'); - await userEvent.click(tabs[1]); + const [, distributedTab] = screen.getAllByRole('tab'); + await userEvent.click(distributedTab); - const inputs = getAllByRole('combobox'); - const geographicalAreaSelect = inputs[0]; - const regionSelect = inputs[1]; + const [geographicalAreaSelect, regionSelect] = + screen.getAllByRole('combobox'); expect(geographicalAreaSelect).toHaveAttribute('value', 'All'); expect(regionSelect).toHaveAttribute('placeholder', 'Select a Region'); expect(regionSelect).toBeEnabled(); }); + + it('should persist the selected Geographical Area when switching between the Core and Distributed tabs', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + const [coreTab, distributedTab] = screen.getAllByRole('tab'); + await userEvent.click(distributedTab); + + const geographicalAreaSelect = screen.getByLabelText('Geographical Area'); + // Open the dropdown + await userEvent.click(geographicalAreaSelect); + + const lastMonthOption = screen.getByText('North America'); + await userEvent.click(lastMonthOption); + expect(geographicalAreaSelect).toHaveAttribute('value', 'North America'); + + // Geographical area selection should persist after switching tabs + await userEvent.click(coreTab); + await userEvent.click(distributedTab); + const geographicalAreaSelect2 = screen.getByLabelText('Geographical Area'); + expect(geographicalAreaSelect2).toHaveAttribute('value', 'North America'); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx index c92b038ba8f..c198b855511 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx @@ -140,6 +140,9 @@ export const TwoStepRegion = (props: CombinedProps) => { } }} options={GEOGRAPHICAL_AREA_OPTIONS} + value={GEOGRAPHICAL_AREA_OPTIONS.find( + (option) => option.value === regionFilter + )} /> Date: Tue, 29 Jul 2025 18:51:23 +0530 Subject: [PATCH 14/67] change: [M3-10097] - Akamai Design System: Link Component (#12569) * change: [M3-10097] - Akamai Design System: Link Component * fix external link hover color * Added changeset: Akamai Design System: Link Component * feedback @tzmiivsk-akamai * updated the docs icon --- .../.changeset/pr-12569-changed-1753705894806.md | 5 +++++ packages/manager/src/assets/icons/docs.svg | 8 +++----- packages/manager/src/assets/icons/external-link.svg | 2 +- packages/manager/src/components/DocsLink/DocsLink.tsx | 3 +-- packages/manager/src/components/Link.styles.ts | 11 +++++++++-- .../KubernetesClusterDetail/KubeSummaryPanel.tsx | 4 ++-- packages/ui/src/foundations/themes/dark.ts | 4 ++-- packages/ui/src/foundations/themes/light.ts | 7 +++---- 8 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 packages/manager/.changeset/pr-12569-changed-1753705894806.md diff --git a/packages/manager/.changeset/pr-12569-changed-1753705894806.md b/packages/manager/.changeset/pr-12569-changed-1753705894806.md new file mode 100644 index 00000000000..449b1f2896f --- /dev/null +++ b/packages/manager/.changeset/pr-12569-changed-1753705894806.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Akamai Design System: Link Component ([#12569](https://github.com/linode/manager/pull/12569)) diff --git a/packages/manager/src/assets/icons/docs.svg b/packages/manager/src/assets/icons/docs.svg index 787f090ffd2..142cff7eaf0 100644 --- a/packages/manager/src/assets/icons/docs.svg +++ b/packages/manager/src/assets/icons/docs.svg @@ -1,5 +1,3 @@ - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/external-link.svg b/packages/manager/src/assets/icons/external-link.svg index 12218264094..54f1d8ae3ae 100644 --- a/packages/manager/src/assets/icons/external-link.svg +++ b/packages/manager/src/assets/icons/external-link.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/manager/src/components/DocsLink/DocsLink.tsx b/packages/manager/src/components/DocsLink/DocsLink.tsx index a01381d1cda..ec0e0d78efa 100644 --- a/packages/manager/src/components/DocsLink/DocsLink.tsx +++ b/packages/manager/src/components/DocsLink/DocsLink.tsx @@ -53,9 +53,8 @@ const StyledDocsLink = styled(Link, { })(({ theme }) => ({ ...theme.applyLinkStyles, '& svg': { - marginRight: theme.spacing(), + marginRight: theme.spacingFunction(4), position: 'relative', - top: -2, }, alignItems: 'center', display: 'flex', diff --git a/packages/manager/src/components/Link.styles.ts b/packages/manager/src/components/Link.styles.ts index dbe1a1ee379..5e35642b325 100644 --- a/packages/manager/src/components/Link.styles.ts +++ b/packages/manager/src/components/Link.styles.ts @@ -14,8 +14,8 @@ export const useStyles = makeStyles()( iconContainer: { '& svg': { color: theme.textColors.linkActiveLight, - height: 12, - width: 12, + height: 16, + width: 16, }, color: theme.palette.primary.main, display: 'inline-block', @@ -25,10 +25,17 @@ export const useStyles = makeStyles()( // nifty trick to avoid the icon from wrapping by itself after the last word transform: 'translateX(18px)', width: 14, + top: '3px', }, root: { alignItems: 'baseline', color: theme.textColors.linkActiveLight, + '&:hover': { + color: theme.textColors.linkHover, + '& svg': { + color: theme.textColors.linkHover, + }, + }, }, }) ); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index 54628f8ee7d..c25df231353 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -6,10 +6,10 @@ import { Typography, } from '@linode/ui'; import { Hidden } from '@linode/ui'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import ExternalLinkIcon from 'src/assets/icons/external-link.svg'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; @@ -185,7 +185,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => { disabled={ Boolean(dashboardError) || !dashboard || isClusterReadOnly } - endIcon={} + endIcon={} onClick={() => window.open(dashboard?.url, '_blank')} > Kubernetes Dashboard diff --git a/packages/ui/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts index 056111ae97e..2bd064f3868 100644 --- a/packages/ui/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -156,12 +156,12 @@ const iconCircleAnimation = { // Used for styling html buttons to look like our generic links const genericLinkStyle = { '&:hover': { - color: Action.Primary.Hover, + color: Alias.Content.Text.Link.Hover, textDecoration: 'underline', }, background: 'none', border: 'none', - color: Action.Primary.Default, + color: Alias.Content.Text.Link.Default, cursor: 'pointer', font: 'inherit', padding: 0, diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index e5c40121e54..2bfd4c96e8a 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -171,19 +171,18 @@ const iconCircleHoverEffect = { // Used for styling html buttons to look like our generic links const genericLinkStyle = { '&:disabled': { - color: Action.Primary.Disabled, + color: Alias.Content.Text.Link.Disabled, cursor: 'not-allowed', }, '&:hover:not(:disabled)': { backgroundColor: 'transparent', - color: Action.Primary.Hover, + color: Alias.Content.Text.Link.Hover, textDecoration: 'underline', }, background: 'none', border: 'none', - color: Action.Primary.Default, + color: Alias.Content.Text.Link.Default, cursor: 'pointer', - font: 'inherit', minWidth: 0, padding: 0, }; From c22c202640306da5d3c1f96f6ac0733cded6a7b6 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:03:04 -0400 Subject: [PATCH 15/67] upcoming: [M3-9863] - Add Subnet IPv6 Prefix Length to VPC create page (#12563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Add ability to specify a Subnet IPv6 Prefix (`/52 - /62`) in the VPC create page if the selected VPC is Dual Stack ## Changes 🔄 - Add IPv6 support to `MultipleSubnetInput` and `SubnetNode` - Refactoring of shared code into a `useVPCDualStack` hook - Update `createVPCSchema` to support IPv6 subnets ### Verification steps (How to verify changes) - [ ] Ensure the VPC IPv6 feature flag is enabled and your account has the VPC Dual Stack account capability - [ ] Go to the VPC Create page and select Dual Stack - [ ] You should see a `IPv6 Prefix Length` option in the Subnets section with `/56` selected as the default - [ ] You should see the helper text `Number of Linodes` instead of a `Number of Available IP Addresses` helper text - [ ] Test adding/removing multiple subnets and switching from IPv4 and DualStack - [ ] Click Create VPC. You should see `ipv6: [{range: "/X"}]` in the Network Payload under subnets and the auto-allocated IPv6 address with the prefix length from the API response - [ ] There should be no regressions with VPC IPv4 ``` pnpm test SubnetNode ``` --- packages/api-v4/src/vpcs/types.ts | 2 +- ...r-12563-upcoming-features-1753372466262.md | 5 + .../FormComponents/VPCTopSectionContent.tsx | 231 ++++++++++-------- .../VPCs/VPCCreate/MultipleSubnetInput.tsx | 9 +- .../VPCs/VPCCreate/SubnetNode.test.tsx | 104 ++++++-- .../features/VPCs/VPCCreate/SubnetNode.tsx | 40 ++- .../VPCs/VPCDetail/SubnetCreateDrawer.tsx | 37 +-- packages/manager/src/hooks/useVPCDualStack.ts | 42 ++++ .../pr-12563-changed-1753372533337.md | 5 + packages/validation/src/vpcs.schema.ts | 8 +- 10 files changed, 319 insertions(+), 164 deletions(-) create mode 100644 packages/manager/.changeset/pr-12563-upcoming-features-1753372466262.md create mode 100644 packages/manager/src/hooks/useVPCDualStack.ts create mode 100644 packages/validation/.changeset/pr-12563-changed-1753372533337.md diff --git a/packages/api-v4/src/vpcs/types.ts b/packages/api-v4/src/vpcs/types.ts index 0fd7519e1ba..8bd11c29d41 100644 --- a/packages/api-v4/src/vpcs/types.ts +++ b/packages/api-v4/src/vpcs/types.ts @@ -1,4 +1,4 @@ -interface VPCIPv6 { +export interface VPCIPv6 { range?: string; } diff --git a/packages/manager/.changeset/pr-12563-upcoming-features-1753372466262.md b/packages/manager/.changeset/pr-12563-upcoming-features-1753372466262.md new file mode 100644 index 00000000000..a6cefc86704 --- /dev/null +++ b/packages/manager/.changeset/pr-12563-upcoming-features-1753372466262.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add subnet IPv6 to VPC create page ([#12563](https://github.com/linode/manager/pull/12563)) diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index d773355e15b..21fc0594029 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -1,4 +1,3 @@ -import { useAccount } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { Box, @@ -11,13 +10,15 @@ import { Typography, } from '@linode/ui'; import { Radio, RadioGroup } from '@linode/ui'; -import { - getQueryParamsFromQueryString, - isFeatureEnabledV2, -} from '@linode/utilities'; +import { getQueryParamsFromQueryString } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import * as React from 'react'; -import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import { + Controller, + useFieldArray, + useFormContext, + useWatch, +} from 'react-hook-form'; // eslint-disable-next-line no-restricted-imports import { useLocation } from 'react-router-dom'; @@ -27,6 +28,7 @@ import { Link } from 'src/components/Link'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { useFlags } from 'src/hooks/useFlags'; +import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { VPC_CREATE_FORM_VPC_HELPER_TEXT } from '../../constants'; @@ -60,25 +62,17 @@ export const VPCTopSectionContent = (props: Props) => { control, formState: { errors }, } = useFormContext(); - const { append, fields, remove } = useFieldArray({ + + const { update } = useFieldArray({ control, - name: 'ipv6', + name: 'subnets', }); - const isDualStackSelected = fields.some((f) => f.range); + const subnets = useWatch({ control, name: 'subnets' }); + const vpcIPv6 = useWatch({ control, name: 'ipv6' }); - const { data: account } = useAccount(); - const isDualStackEnabled = isFeatureEnabledV2( - 'VPC Dual Stack', - Boolean(flags.vpcIpv6), - account?.capabilities ?? [] - ); - - const isEnterpriseCustomer = isFeatureEnabledV2( - 'VPC IPv6 Large Prefixes', - Boolean(flags.vpcIpv6), - account?.capabilities ?? [] - ); + const { isDualStackEnabled, isDualStackSelected, isEnterpriseCustomer } = + useVPCDualStack(vpcIPv6); return ( <> @@ -152,100 +146,113 @@ export const VPCTopSectionContent = (props: Props) => { {isDualStackEnabled && ( Networking IP Stack - - - { - remove(0); - }} - renderIcon={() => } - renderVariant={() => ( - ( + + + - The VPC uses IPv4 addresses only. The VPC can use the - entire RFC 1918 specified range for subnetting. - - } - width={250} + heading="IPv4" + onClick={() => { + field.onChange([]); + subnets?.forEach((subnet, idx) => + update(idx, { + ...subnet, + ipv6: undefined, + }) + ); + }} + renderIcon={() => } + renderVariant={() => ( + + The VPC uses IPv4 addresses only. The VPC can use + the entire RFC 1918 specified range for subnetting. + + } + width={250} + /> + )} + subheadings={[]} + sxCardBase={{ gap: 0 }} + sxCardBaseIcon={{ svg: { fontSize: '20px' } }} /> - )} - subheadings={[]} - sxCardBase={{ gap: 0 }} - sxCardBaseIcon={{ svg: { fontSize: '20px' } }} - /> - { - if (fields.length === 0) { - append({ - range: '/52', - }); - } - }} - renderIcon={() => } - renderVariant={() => ( - { + field.onChange([ + { + range: '/52', + }, + ]); + subnets?.forEach((subnet, idx) => + update(idx, { + ...subnet, + ipv6: subnet.ipv6 ?? [{ range: '/56' }], + }) + ); }} - text={ - - - The VPC supports both IPv4 and IPv6 addresses. - - - For IPv4, the VPC can use the entire RFC 1918 - specified range for subnetting. - - - For IPv6, the VPC is assigned an IPv6 prefix length of{' '} - /52 by default. - - - } - width={250} + renderIcon={() => } + renderVariant={() => ( + + + The VPC supports both IPv4 and IPv6 addresses. + + + For IPv4, the VPC can use the entire RFC 1918 + specified range for subnetting. + + + 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' } }} - /> - - + + + )} + /> )} {isDualStackSelected && isEnterpriseCustomer && ( ( + render={({ field, fieldState }) => ( { - remove(0); - append({ - range: value, - }); - }} - value={fields[0].range} + onChange={(_, value) => field.onChange([{ range: value }])} + value={field.value} > VPC IPv6 Prefix Length @@ -253,13 +260,23 @@ export const VPCTopSectionContent = (props: Props) => { {errors.ipv6 && ( )} <> - } label="/52" value="/52" /> - } label="/48" value="/48" /> + } + label="/52" + value="/52" + /> + } + label="/48" + value="/48" + /> )} diff --git a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx index 959a6ed681b..5a3455657ed 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.tsx @@ -1,8 +1,9 @@ import { Button, Divider } from '@linode/ui'; import Grid from '@mui/material/Grid'; import * as React from 'react'; -import { useFieldArray, useFormContext } from 'react-hook-form'; +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; import { DEFAULT_SUBNET_IPV4_VALUE, getRecommendedSubnetIPv4, @@ -27,10 +28,14 @@ export const MultipleSubnetInput = (props: Props) => { name: 'subnets', }); + const vpcIPv6 = useWatch({ control, name: 'ipv6' }); + const [lastRecommendedIPv4, setLastRecommendedIPv4] = React.useState( DEFAULT_SUBNET_IPV4_VALUE ); + const { shouldDisplayIPv6, recommendedIPv6 } = useVPCDualStack(vpcIPv6); + const handleAddSubnet = () => { const recommendedIPv4 = getRecommendedSubnetIPv4( lastRecommendedIPv4, @@ -39,6 +44,7 @@ export const MultipleSubnetInput = (props: Props) => { setLastRecommendedIPv4(recommendedIPv4); append({ ipv4: recommendedIPv4, + ipv6: recommendedIPv6, label: '', }); }; @@ -56,6 +62,7 @@ export const MultipleSubnetInput = (props: Props) => { idx={subnetIdx} isCreateVPCDrawer={isDrawer} remove={remove} + shouldDisplayIPv6={shouldDisplayIPv6} /> ); diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx index e150623430e..8eb96819f2c 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx @@ -1,3 +1,4 @@ +import { screen } from '@testing-library/react'; import * as React from 'react'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; @@ -8,12 +9,14 @@ const props = { idx: 0, isCreateVPCDrawer: false, remove: vi.fn(), + shouldDisplayIPv6: false, }; const formOptions = { defaultValues: { description: '', label: '', + ipv6: null, region: '', subnets: [ { @@ -26,7 +29,7 @@ const formOptions = { describe('SubnetNode', () => { it('should show the correct subnet mask', async () => { - const { getByDisplayValue, getByText } = renderWithThemeAndHookFormContext({ + renderWithThemeAndHookFormContext({ component: , useFormOptions: { defaultValues: { @@ -36,23 +39,22 @@ describe('SubnetNode', () => { }, }); - getByDisplayValue('10.0.0.0/24'); - getByText('Number of Available IP Addresses: 252'); + screen.getByDisplayValue('10.0.0.0/24'); + screen.getByText('Number of Available IP Addresses: 252'); }); it('should not show a subnet mask for an ip without a mask', async () => { - const { getByDisplayValue, queryByText } = - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: formOptions, - }); - - getByDisplayValue('10.0.0.0'); - expect(queryByText('Number of Available IP Addresses:')).toBeNull(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: formOptions, + }); + + screen.getByDisplayValue('10.0.0.0'); + expect(screen.queryByText('Number of Available IP Addresses:')).toBeNull(); }); it('should show a label and ip textfield inputs at minimum', () => { - const { getByText } = renderWithThemeAndHookFormContext({ + renderWithThemeAndHookFormContext({ component: , useFormOptions: { defaultValues: { @@ -62,12 +64,12 @@ describe('SubnetNode', () => { }, }); - getByText('Subnet Label'); - getByText('Subnet IP Address Range'); + screen.getByText('Subnet Label'); + screen.getByText('Subnet IP Address Range'); }); it('should show a removable button if not a drawer', () => { - const { getByTestId } = renderWithThemeAndHookFormContext({ + renderWithThemeAndHookFormContext({ component: , useFormOptions: { defaultValues: { @@ -77,11 +79,11 @@ describe('SubnetNode', () => { }, }); - expect(getByTestId('delete-subnet-0')).toBeInTheDocument(); + expect(screen.getByTestId('delete-subnet-0')).toBeInTheDocument(); }); it('should not show a removable button if a drawer for the first subnet', () => { - const { queryByTestId } = renderWithThemeAndHookFormContext({ + renderWithThemeAndHookFormContext({ component: , useFormOptions: { defaultValues: { @@ -91,6 +93,72 @@ describe('SubnetNode', () => { }, }); - expect(queryByTestId('delete-subnet-0')).toBeNull(); + expect(screen.queryByTestId('delete-subnet-0')).toBeNull(); + }); + + it('should not show IPv6 Prefix Length dropdown if shouldDisplayIPv6 is false', () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + ...formOptions.defaultValues, + subnets: [ + { + ipv4: '10.0.0.0/24', + label: 'subnet 0', + }, + ], + }, + }, + }); + + expect(screen.queryByText('IPv6 Prefix Length')).not.toBeInTheDocument(); + }); + + it('should show IPv6 Prefix Length dropdown with /56 selected by default if shouldDisplayIPv6 is true', () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + ...formOptions.defaultValues, + ipv6: [{ range: '/52' }], + subnets: [ + { + ipv4: '10.0.0.0/24', + ipv6: [{ range: '/56' }], + label: 'subnet 0', + }, + ], + }, + }, + }); + + expect(screen.getByText('IPv6 Prefix Length')).toBeVisible(); + const select = screen.getByRole('combobox'); + expect(select).toHaveValue('/56'); + }); + + it('should display number of linodes helper text instead of number of available IPs if shouldDisplayIPv6 is true', () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + ...formOptions.defaultValues, + ipv6: [{ range: '/52' }], + subnets: [ + { + ipv4: '10.0.0.0/24', + ipv6: [{ range: '/56' }], + label: 'subnet 0', + }, + ], + }, + }, + }); + + expect(screen.getByText('Number of Linodes: 252')).toBeVisible(); + expect( + screen.queryByText('Number of Available IP Addresses: 252') + ).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx index b5afe5d3ef0..daaf3fbde61 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx @@ -1,4 +1,4 @@ -import { Button, CloseIcon, TextField } from '@linode/ui'; +import { Button, CloseIcon, Select, TextField } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -6,7 +6,9 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { calculateAvailableIPv4sRFC1918, + calculateAvailableIPv6Linodes, RESERVED_IP_NUMBER, + SUBNET_IPV6_PREFIX_LENGTHS, } from 'src/utilities/subnets'; import type { CreateVPCPayload } from '@linode/api-v4'; @@ -16,11 +18,11 @@ interface Props { idx: number; isCreateVPCDrawer?: boolean; remove: (index?: number | number[]) => void; + shouldDisplayIPv6?: boolean; } -// @TODO VPC: currently only supports IPv4, must update when/if IPv6 is also supported export const SubnetNode = (props: Props) => { - const { disabled, idx, isCreateVPCDrawer, remove } = props; + const { disabled, idx, shouldDisplayIPv6, isCreateVPCDrawer, remove } = props; const { control } = useFormContext(); @@ -28,7 +30,7 @@ export const SubnetNode = (props: Props) => { const numberOfAvailIPs = calculateAvailableIPv4sRFC1918(ipv4 ?? ''); - const availableIPHelperText = numberOfAvailIPs + const availableIPv4HelperText = numberOfAvailIPs ? `Number of Available IP Addresses: ${ numberOfAvailIPs > 4 ? (numberOfAvailIPs - RESERVED_IP_NUMBER).toLocaleString() @@ -36,6 +38,10 @@ export const SubnetNode = (props: Props) => { }` : undefined; + const numberOfAvailableIPv4Linodes = numberOfAvailIPs + ? numberOfAvailIPs - RESERVED_IP_NUMBER + : 0; + const showRemoveButton = !(isCreateVPCDrawer && idx === 0); return ( @@ -70,7 +76,7 @@ export const SubnetNode = (props: Props) => { aria-label="Enter an IPv4" disabled={disabled} errorText={fieldState.error?.message} - helperText={availableIPHelperText} + helperText={!shouldDisplayIPv6 && availableIPv4HelperText} inputId={`subnet-ipv4-${idx}`} label="Subnet IP Address Range" onBlur={field.onBlur} @@ -79,6 +85,30 @@ export const SubnetNode = (props: Props) => { /> )} /> + {shouldDisplayIPv6 && ( + ( + - Number of Linodes:{' '} - {Math.min( - numberOfAvailableIPv4Linodes, - calculateAvailableIPv6Linodes(field.value) - )} - - } + helperText={`Number of Linodes: ${Math.min( + numberOfAvailableIPv4Linodes, + calculateAvailableIPv6Linodes(field.value) + )}`} label="IPv6 Prefix Length" onChange={(_, option) => field.onChange(option.value)} options={SUBNET_IPV6_PREFIX_LENGTHS} diff --git a/packages/manager/src/hooks/useVPCDualStack.ts b/packages/manager/src/hooks/useVPCDualStack.ts new file mode 100644 index 00000000000..862d4062bd5 --- /dev/null +++ b/packages/manager/src/hooks/useVPCDualStack.ts @@ -0,0 +1,42 @@ +import { useAccount } from '@linode/queries'; +import { isFeatureEnabledV2 } from '@linode/utilities'; + +import { useFlags } from './useFlags'; + +import type { VPCIPv6 } from '@linode/api-v4'; + +export const useVPCDualStack = (ipv6?: VPCIPv6[]) => { + const { data: account } = useAccount(); + const flags = useFlags(); + + const isDualStackSelected = Boolean(ipv6 && ipv6.length > 0); + + const isDualStackEnabled = isFeatureEnabledV2( + 'VPC Dual Stack', + Boolean(flags.vpcIpv6), + account?.capabilities ?? [] + ); + + const isEnterpriseCustomer = isFeatureEnabledV2( + 'VPC IPv6 Large Prefixes', + Boolean(flags.vpcIpv6), + account?.capabilities ?? [] + ); + + const shouldDisplayIPv6 = isDualStackEnabled && isDualStackSelected; + const recommendedIPv6 = shouldDisplayIPv6 + ? [ + { + range: '/56', + }, + ] + : undefined; + + return { + isDualStackEnabled, + isDualStackSelected, + isEnterpriseCustomer, + shouldDisplayIPv6, + recommendedIPv6, + }; +}; diff --git a/packages/validation/.changeset/pr-12563-changed-1753372533337.md b/packages/validation/.changeset/pr-12563-changed-1753372533337.md new file mode 100644 index 00000000000..dd4ff779d5b --- /dev/null +++ b/packages/validation/.changeset/pr-12563-changed-1753372533337.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Update `createVPCSchema` to support IPv6 subnets ([#12563](https://github.com/linode/manager/pull/12563)) diff --git a/packages/validation/src/vpcs.schema.ts b/packages/validation/src/vpcs.schema.ts index 0b77e6ac880..eeebb0a56e3 100644 --- a/packages/validation/src/vpcs.schema.ts +++ b/packages/validation/src/vpcs.schema.ts @@ -271,7 +271,13 @@ export const createVPCSchema = object({ label: labelValidation.required(LABEL_REQUIRED), description: string(), region: string().required('Region is required'), - subnets: array().of(createSubnetSchemaIPv4), + subnets: array() + .of(createSubnetSchemaIPv4) + .when('ipv6', { + is: (value: unknown) => value === undefined, + then: () => array().of(createSubnetSchemaIPv4), + otherwise: () => array().of(createSubnetSchemaWithIPv6), + }), ipv6: array().of(createVPCIPv6Schema).max(1).optional(), }); From a4f000467942b21c94848bf9ac7485a0acd31f13 Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Tue, 29 Jul 2025 15:40:41 -0400 Subject: [PATCH 16/67] test: [M3-10076]- Add tests for ACLP alerts in Linode create flow (#12540) * draft * fix tests * checkpoint * fix tests * add alerts to create flow * check code snippet * Added changeset: Tests for ACLP alerts in Linode create flow * remove comment * added test. test for test summary * updated strings, fixed tests for code snippet * remove obsolete TODO * mock network interface * edits after pr review * remove isAclpAlertsBeta user preference * rename file * update after removal of user prefs * cleanup * qa tests after setup changes * Added changeset: Tests for ACLP alerts in Linode create flow * Delete packages/manager/.changeset/pr-12394-tests-1750864002769.md * refactor w/ strings from constants file * delete unused code changes --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> --- .../pr-12540-tests-1752873012740.md | 5 + .../e2e/core/linodes/alerts-create.spec.ts | 494 ++++++++++++++++++ 2 files changed, 499 insertions(+) create mode 100644 packages/manager/.changeset/pr-12540-tests-1752873012740.md create mode 100644 packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts diff --git a/packages/manager/.changeset/pr-12540-tests-1752873012740.md b/packages/manager/.changeset/pr-12540-tests-1752873012740.md new file mode 100644 index 00000000000..532e301508e --- /dev/null +++ b/packages/manager/.changeset/pr-12540-tests-1752873012740.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Tests for ACLP alerts in Linode create flow ([#12540](https://github.com/linode/manager/pull/12540)) diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts new file mode 100644 index 00000000000..1c2761ba27f --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts @@ -0,0 +1,494 @@ +import { regionAvailabilityFactory, regionFactory } from '@linode/utilities'; +import { mockGetAccountSettings } from 'support/intercepts/account'; +import { mockGetAlertDefinition } from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { interceptCreateLinode } from 'support/intercepts/linodes'; +import { + mockGetRegionAvailability, + mockGetRegions, +} from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { randomLabel, randomString } from 'support/util/random'; + +import { accountSettingsFactory, alertFactory } from 'src/factories'; +import { + ALERTS_BETA_MODE_BANNER_TEXT, + ALERTS_BETA_MODE_BUTTON_TEXT, + ALERTS_LEGACY_MODE_BANNER_TEXT, + ALERTS_LEGACY_MODE_BUTTON_TEXT, +} from 'src/features/Linodes/constants'; + +describe('Create flow when beta alerts enabled by region and feature flag', function () { + beforeEach(() => { + const mockEnabledRegion = regionFactory.build({ + capabilities: ['Linodes'], + monitors: { + alerts: ['Linodes'], + }, + }); + const mockDisabledRegion = regionFactory.build({ + capabilities: ['Linodes'], + monitors: { + alerts: [], + }, + }); + const mockRegions = [mockEnabledRegion, mockDisabledRegion]; + cy.wrap(mockRegions).as('mockRegions'); + mockGetRegions(mockRegions).as('getRegions'); + mockAppendFeatureFlags({ + aclpBetaServices: { + linode: { + alerts: true, + metrics: false, + }, + }, + }).as('getFeatureFlags'); + // mock network interface type in case test account has setting that disables
 snippet
+    const mockInitialAccountSettings = accountSettingsFactory.build({
+      interfaces_for_new_linodes: 'legacy_config_default_but_linode_allowed',
+    });
+    mockGetAccountSettings(mockInitialAccountSettings).as('getSettings');
+  });
+
+  it('Alerts panel becomes visible after switching to region w/ alerts enabled', function () {
+    const disabledRegion = this.mockRegions[1];
+    mockGetRegionAvailability(disabledRegion.id, []).as(
+      'getRegionAvailability'
+    );
+    cy.visitWithLogin('/linodes/create');
+    cy.wait(['@getFeatureFlags', '@getRegions']);
+
+    ui.regionSelect.find().click();
+    ui.regionSelect.find().type(`${disabledRegion.label}{enter}`);
+    cy.wait('@getRegionAvailability');
+
+    // Alerts section is not visible until enabled region is selected
+    cy.get('[data-qa-panel="Alerts"]').should('not.exist');
+    ui.regionSelect.find().click();
+    ui.regionSelect.find().clear();
+    const enabledRegion = this.mockRegions[0];
+    ui.regionSelect.find().type(`${enabledRegion.label}{enter}`);
+
+    // Alerts section is visible after enabled region is selected
+    cy.contains('Additional Options').should('be.visible');
+    cy.get('[data-qa-panel="Alerts"]').should('be.visible');
+  });
+
+  it('create flow defaults to legacy alerts', function () {
+    interceptCreateLinode().as('createLinode');
+    cy.visitWithLogin('/linodes/create');
+    cy.wait(['@getFeatureFlags', '@getSettings', '@getRegions']);
+    ui.regionSelect.find().click();
+    const enabledRegion = this.mockRegions[0];
+    mockGetRegionAvailability(enabledRegion.id, []).as('getRegionAvailability');
+    ui.regionSelect.find().type(`${enabledRegion.label}{enter}`);
+
+    // legacy alerts panel appears
+    cy.wait('@getRegionAvailability');
+    cy.get('[data-qa-panel="Alerts"]')
+      .should('be.visible')
+      .within(() => {
+        ui.accordionHeading.findByTitle('Alerts');
+        ui.accordionHeading
+          .findByTitle('Alerts')
+          .should('be.visible')
+          .should('be.enabled')
+          .click();
+
+        // legacy alert form
+        // inputs are ON but readonly, cant be added to POST
+        cy.get('[data-qa-alerts-panel="true"]').each((panel) => {
+          cy.wrap(panel).within(() => {
+            ui.toggle
+              .find()
+              .should('have.attr', 'data-qa-toggle', 'true')
+              .should('be.visible')
+              .should('be.disabled');
+            // numeric inputs are disabled
+            cy.get('[type="number"]')
+              .should('be.visible')
+              .should('be.disabled');
+          });
+        });
+      });
+
+    // enter plan and password form fields to enable "View Code Snippets" button
+    cy.get('[data-qa-tp="Linode Plan"]').scrollIntoView();
+    cy.get('[data-qa-tp="Linode Plan"]')
+      .should('be.visible')
+      .within(() => {
+        cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click();
+      });
+    cy.get('[type="password"]').should('be.visible').scrollIntoView();
+    cy.get('[id="root-password"]').type(randomString(12));
+    cy.scrollTo('bottom');
+    ui.button
+      .findByTitle('View Code Snippets')
+      .should('be.visible')
+      .should('be.enabled')
+      .click();
+    ui.dialog
+      .findByTitle('Create Linode')
+      .should('be.visible')
+      .within(() => {
+        cy.get('pre code').should('be.visible');
+        // 'alert' is not present anywhere in snippet
+        cy.contains('alert').should('not.exist');
+        // cURL tab
+        ui.tabList.findTabByTitle('cURL').should('be.visible').click();
+        cy.contains('alert').should('not.exist');
+        ui.button
+          .findByTitle('Close')
+          .should('be.visible')
+          .should('be.enabled')
+          .click();
+      });
+    cy.scrollTo('bottom');
+    ui.button
+      .findByTitle('Create Linode')
+      .should('be.visible')
+      .should('be.enabled')
+      .click();
+    cy.wait('@createLinode').then((intercept) => {
+      const alerts = intercept.request.body['alerts'];
+      expect(alerts).to.eq(undefined);
+    });
+  });
+
+  it('create flow after switching to beta alerts', function () {
+    const alertDefinitions = [
+      alertFactory.build({
+        description: randomLabel(),
+        entity_ids: ['1', '2', '3'],
+        label: randomLabel(),
+        service_type: 'linode',
+        severity: 1,
+        status: 'enabled',
+        type: 'system',
+      }),
+      alertFactory.build({
+        description: randomLabel(),
+        entity_ids: ['1', '2', '3'],
+        label: randomLabel(),
+        service_type: 'linode',
+        severity: 1,
+        status: 'enabled',
+        type: 'system',
+      }),
+      alertFactory.build({
+        description: randomLabel(),
+        entity_ids: ['1', '2', '3'],
+        label: randomLabel(),
+        service_type: 'linode',
+        severity: 1,
+        status: 'enabled',
+        type: 'user',
+      }),
+    ];
+    mockGetAlertDefinition('linode', alertDefinitions).as(
+      'getAlertDefinitions'
+    );
+    interceptCreateLinode().as('createLinode');
+    cy.visitWithLogin('/linodes/create');
+    cy.wait(['@getFeatureFlags', '@getSettings', '@getRegions']);
+    ui.regionSelect.find().click();
+    const enabledRegion = this.mockRegions[0];
+    mockGetRegionAvailability(enabledRegion.id, []).as('getRegionAvailability');
+    ui.regionSelect.find().type(`${enabledRegion.label}{enter}`);
+
+    // legacy alerts panel appears
+    cy.wait('@getRegionAvailability');
+    cy.get('[data-qa-panel="Alerts"]')
+      .should('be.visible')
+      .within(() => {
+        ui.accordionHeading.findByTitle('Alerts');
+        ui.accordionHeading
+          .findByTitle('Alerts')
+          .should('be.visible')
+          .should('be.enabled')
+          .click();
+        ui.accordion.findByTitle('Alerts').within(() => {
+          // switch to beta
+          // alerts are off/false but enabled, can switch to on/true
+          ui.button
+            .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+            .should('be.visible')
+            .should('be.enabled')
+            .click();
+        });
+      });
+    cy.wait(['@getAlertDefinitions']);
+
+    // verify summary at bottom displays 0 alerts selected
+    cy.scrollTo('bottom');
+    cy.get('[data-qa-linode-create-summary="true"]')
+      .should('be.visible')
+      .within(() => {
+        cy.contains('Alerts Assigned');
+        cy.contains('0');
+      });
+    // scroll back up to alerts table, select beta alerts
+    cy.get('table[data-testid="alert-table"]').scrollIntoView();
+    cy.get('table[data-testid="alert-table"]')
+      .should('be.visible')
+      .find('tbody > tr')
+      .should('have.length', 3)
+      .each((row, index) => {
+        // match alert definitions to table cell contents
+        cy.wrap(row).within(() => {
+          cy.get('td')
+            .eq(0)
+            .within(() => {
+              // select each alert
+              ui.toggle
+                .find()
+                .should('have.attr', 'data-qa-toggle', 'false')
+                .should('be.visible')
+                .should('be.enabled')
+                .click();
+              // value is now on/true
+              ui.toggle.find().should('have.attr', 'data-qa-toggle', 'true');
+            });
+          cy.get('td')
+            .eq(1)
+            .within(() => {
+              cy.findByText(alertDefinitions[index].label).should('be.visible');
+            });
+          cy.get('td')
+            .eq(2)
+            .within(() => {
+              const rule = alertDefinitions[index].rule_criteria.rules[0];
+              const str = `${rule.label} = ${rule.threshold} ${rule.unit}`;
+              cy.findByText(str).should('be.visible');
+            });
+          cy.get('td')
+            .eq(3)
+            .within(() => {
+              cy.findByText(alertDefinitions[index].type, {
+                exact: false,
+              }).should('be.visible');
+            });
+        });
+      });
+
+    // enter plan and password form fields to enable "View Code Snippets" button
+    cy.get('[data-qa-tp="Linode Plan"]').scrollIntoView();
+    cy.get('[data-qa-tp="Linode Plan"]')
+      .should('be.visible')
+      .within(() => {
+        cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click();
+      });
+    cy.get('[type="password"]').should('be.visible').scrollIntoView();
+    cy.get('[id="root-password"]').type(randomString(12));
+    cy.scrollTo('bottom');
+    ui.button
+      .findByTitle('View Code Snippets')
+      .should('be.visible')
+      .should('be.enabled')
+      .click();
+    ui.dialog
+      .findByTitle('Create Linode')
+      .should('be.visible')
+      .within(() => {
+        cy.get('pre code').should('be.visible');
+        /** alert in code snippet
+         * "alerts": {
+         *    "system": [
+         *             1,
+         *             2,
+         *      ],
+         *      "user": [
+         *             2
+         *      ]
+         * }
+         */
+        const strAlertSnippet = `alerts '{"system": [${alertDefinitions[0].id},${alertDefinitions[1].id}],"user":[${alertDefinitions[2].id}]}`;
+        cy.contains(strAlertSnippet).should('be.visible');
+        // cURL tab
+        ui.tabList.findTabByTitle('cURL').should('be.visible').click();
+        // hard to consolidate text within multiple spans in 

+        cy.get('pre code').within(() => {
+          cy.contains('alerts');
+          cy.contains('system');
+          cy.contains('user');
+        });
+        ui.button
+          .findByTitle('Close')
+          .should('be.visible')
+          .should('be.enabled')
+          .click();
+      });
+    // verify alerts counter in summary displays number selected
+    cy.scrollTo('bottom');
+    // summary displays number of alerts ("+3")
+    cy.get('[data-qa-linode-create-summary="true"]')
+      .should('be.visible')
+      .within(() => {
+        cy.contains('Alerts Assigned');
+        cy.contains(`+${alertDefinitions.length}`);
+      });
+    // window scrolls to top, RegionSelect displays error msg
+    ui.button
+      .findByTitle('Create Linode')
+      .should('be.visible')
+      .should('be.enabled')
+      .click();
+    cy.wait('@createLinode').then((intercept) => {
+      const alerts = intercept.request.body['alerts'];
+      expect(alerts.system.length).to.equal(2);
+      expect(alerts.system[0]).to.eq(alertDefinitions[0].id);
+      expect(alerts.system[1]).to.eq(alertDefinitions[1].id);
+      expect(alerts.user.length).to.equal(1);
+      expect(alerts.user[0]).to.eq(alertDefinitions[2].id);
+    });
+  });
+
+  it('can toggle from legacy to beta alerts and back to legacy', function () {
+    cy.visitWithLogin('/linodes/create');
+    cy.wait(['@getFeatureFlags', '@getRegions']);
+    ui.regionSelect.find().click();
+    const enabledRegion = this.mockRegions[0];
+    ui.regionSelect.find().type(`${enabledRegion.label}{enter}`);
+
+    // legacy alerts are visible
+    ui.accordionHeading
+      .findByTitle('Alerts')
+      .should('be.visible')
+      .should('be.enabled')
+      .click();
+    ui.accordion.findByTitle('Alerts').within(() => {
+      cy.get('[data-testid="notice-info"]')
+        .should('be.visible')
+        .within(() => {
+          cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT);
+        });
+    });
+    // legacy alert form, inputs are ON but readonly
+    cy.get('[data-qa-alerts-panel="true"]').each((panel) => {
+      cy.wrap(panel).within(() => {
+        ui.toggle
+          .find()
+          .should('have.attr', 'data-qa-toggle', 'true')
+          .should('be.visible')
+          .should('be.disabled');
+        // numeric inputs are disabled
+        cy.get('[type="number"]').should('be.visible').should('be.disabled');
+      });
+    });
+
+    // upgrade from legacy alerts to beta alerts
+    ui.button
+      .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+      .should('be.visible')
+      .should('be.enabled')
+      .click();
+    cy.get('[data-qa-panel="Alerts"]')
+      .should('be.visible')
+      .within(() => {
+        cy.get('[data-testid="betaChip"]').should('be.visible');
+        cy.get('[data-testid="notice-info"]')
+          .should('be.visible')
+          .within(() => {
+            cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+          });
+        // possible to downgrade from ACLP alerts to legacy alerts
+        ui.button
+          .findByTitle(ALERTS_BETA_MODE_BUTTON_TEXT)
+          .should('be.visible')
+          .should('be.enabled');
+      });
+  });
+
+  it('alerts not present for region where alerts disabled', function () {
+    const createLinodeErrorMsg = 'region is not valid';
+    interceptCreateLinode().as('createLinode');
+    cy.visitWithLogin('/linodes/create');
+    cy.wait(['@getRegions']);
+    ui.regionSelect.find().click();
+    const disabledRegion = this.mockRegions[1];
+
+    const mockRegionAvailability = [
+      regionAvailabilityFactory.build({
+        available: true,
+        region: disabledRegion.id,
+      }),
+    ];
+    mockGetRegionAvailability(disabledRegion.id, mockRegionAvailability).as(
+      'getRegionAvailability'
+    );
+    ui.regionSelect.find().type(`${disabledRegion.label}{enter}`);
+
+    cy.wait('@getRegionAvailability');
+    // enter plan and password form fields to enable "View Code Snippets" button
+    cy.get('[data-qa-tp="Linode Plan"]').scrollIntoView();
+    cy.get('[data-qa-tp="Linode Plan"]')
+      .should('be.visible')
+      .within(() => {
+        cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click();
+      });
+    cy.get('[type="password"]').should('be.visible').scrollIntoView();
+    cy.get('[id="root-password"]').type(randomString(12));
+    // no alerts panel
+    cy.get('[data-qa-panel="Alerts"]').should('not.exist');
+    cy.scrollTo('bottom');
+    ui.button
+      .findByTitle('Create Linode')
+      .should('be.visible')
+      .should('be.enabled')
+      .click();
+
+    cy.wait('@createLinode').then((intercept) => {
+      const body = intercept.response?.body;
+      const alerts = body['alerts'];
+      expect(alerts).to.eq(undefined);
+      const error = body.errors[0];
+      expect(error.field).to.eq('region');
+      expect(error.reason).to.eq(createLinodeErrorMsg);
+    });
+    // window scrolls to top, RegionSelect displays error msg
+    // Creation fails but not bc of factors related to this test setup
+    cy.get('[data-qa-textfield-error-text="Region"]')
+      .should('be.visible')
+      .should('have.text', createLinodeErrorMsg);
+  });
+});
+
+describe('aclpBetaServices feature flag disabled', function () {
+  it('Alerts not present when feature flag disabled', function () {
+    const mockEnabledRegion = regionFactory.build({
+      capabilities: ['Linodes'],
+      monitors: {
+        alerts: ['Linodes'],
+      },
+    });
+    const mockRegions = [mockEnabledRegion];
+    cy.wrap(mockRegions).as('mockRegions');
+    mockGetRegions(mockRegions).as('getRegions');
+    mockAppendFeatureFlags({
+      aclpBetaServices: {
+        linode: {
+          alerts: false,
+          metrics: false,
+        },
+      },
+    }).as('getFeatureFlags');
+    interceptCreateLinode().as('createLinode');
+    cy.visitWithLogin('/linodes/create');
+    cy.wait(['@getRegions']);
+    ui.regionSelect.find().click();
+
+    const mockRegionAvailability = [
+      regionAvailabilityFactory.build({
+        available: true,
+        region: mockEnabledRegion.id,
+      }),
+    ];
+    mockGetRegionAvailability(mockEnabledRegion.id, mockRegionAvailability).as(
+      'getRegionAvailability'
+    );
+    ui.regionSelect.find().type(`${mockEnabledRegion.label}{enter}`);
+    cy.wait('@getRegionAvailability');
+
+    cy.get('[data-qa-panel="Alerts"]').should('not.exist');
+  });
+});

From 115ee9db20d13cc458ec764e1a777573b24f19a4 Mon Sep 17 00:00:00 2001
From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com>
Date: Tue, 29 Jul 2025 16:31:15 -0400
Subject: [PATCH 17/67] =?UTF-8?q?upcoming:=20[M3-9864]=20=E2=80=93=20Add?=
 =?UTF-8?q?=20new=20columns=20to=20VPC=20Subnet=20&=20Subnet=20linodes=20t?=
 =?UTF-8?q?ables=20(#12305)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 ...r-12305-upcoming-features-1753137015399.md |   5 +
 .../VPCDetail/SubnetAssignLinodesDrawer.tsx   |   4 +-
 .../VPCs/VPCDetail/SubnetLinodeRow.test.tsx   |  88 +++++++++-
 .../VPCs/VPCDetail/SubnetLinodeRow.tsx        | 163 ++++++++++++++----
 .../SubnetLinodeRowFirewallsCell.tsx          |   4 +-
 .../VPCDetail/SubnetUnassignLinodesDrawer.tsx |   5 +-
 .../VPCs/VPCDetail/VPCSubnetsTable.test.tsx   |  65 ++++++-
 .../VPCs/VPCDetail/VPCSubnetsTable.tsx        |  65 ++++---
 .../manager/src/features/VPCs/utils.test.ts   |   8 +-
 packages/manager/src/features/VPCs/utils.ts   |   5 +-
 .../presets/crud/handlers/linodes/linodes.ts  |   6 +
 ...r-12305-upcoming-features-1753219916941.md |   5 +
 .../src/factories/linodeConfigInterface.ts    |  13 +-
 .../src/factories/linodeInterface.ts          |  14 ++
 14 files changed, 370 insertions(+), 80 deletions(-)
 create mode 100644 packages/manager/.changeset/pr-12305-upcoming-features-1753137015399.md
 create mode 100644 packages/utilities/.changeset/pr-12305-upcoming-features-1753219916941.md

diff --git a/packages/manager/.changeset/pr-12305-upcoming-features-1753137015399.md b/packages/manager/.changeset/pr-12305-upcoming-features-1753137015399.md
new file mode 100644
index 00000000000..1a3c911fdfc
--- /dev/null
+++ b/packages/manager/.changeset/pr-12305-upcoming-features-1753137015399.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Add IPv6 columns to VPC Subnet and Subnet Linodes tables ([#12305](https://github.com/linode/manager/pull/12305))
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
index 4edd73568fc..db5e5d12129 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
@@ -44,8 +44,8 @@ import {
   REGIONAL_LINODE_MESSAGE,
 } from '../constants';
 import {
+  getLinodeInterfaceIPv4Ranges,
   getLinodeInterfacePrimaryIPv4,
-  getLinodeInterfaceRanges,
   getVPCInterfacePayload,
   transformLinodeInterfaceErrorsToFormikErrors,
 } from '../utils';
@@ -376,7 +376,7 @@ export const SubnetAssignLinodesDrawer = (
           : '',
         vpcRanges: newInterface?.current
           ? 'vpc' in newInterface.current
-            ? getLinodeInterfaceRanges(newInterface.current)
+            ? getLinodeInterfaceIPv4Ranges(newInterface.current)
             : newInterface.current?.ip_ranges
           : [],
       };
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx
index 62c49050d89..f6d872d42e7 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx
@@ -110,6 +110,8 @@ describe('SubnetLinodeRow', () => {
   });
 
   it('should display linode label, reboot status, VPC IPv4 address, associated firewalls, IPv4 chip, and Reboot and Unassign buttons', async () => {
+    // @TODO VPC IPv6: This assertion assumes the VPC IPv6 feature flag is off. Once the feature is fully rolled out, update the checks to ensure the
+    // VPC IPv6 and VPC IPv6 Ranges cells are displayed/populated.
     const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' });
     const subnetFactory1 = subnetFactory.build({ id: 1, label: 'subnet-1' });
     const config = linodeConfigFactory.build({
@@ -119,7 +121,7 @@ describe('SubnetLinodeRow', () => {
       data: config,
     });
 
-    const { getByLabelText, getByRole, getByText, findByText } =
+    const { getByLabelText, getByRole, getByText, findByText, queryByText } =
       renderWithTheme(
         wrapWithTableBody(
            {
             subnet={subnetFactory1}
             subnetId={1}
             subnetInterfaces={[{ active: true, config_id: config.id, id: 1 }]}
-          />
+          />,
+          { flags: { vpcIpv6: false } }
         )
       );
 
@@ -142,6 +145,10 @@ describe('SubnetLinodeRow', () => {
 
     expect(getByText('10.0.0.0')).toBeVisible();
 
+    // VPC IPv6 and VPC IPv6 Ranges columns not present, so contents of those cells should not be in the document
+    expect(queryByText('2001:db8::1')).toBeNull();
+    expect(queryByText('2001:db8::/64')).toBeNull();
+
     const plusChipButton = getByRole('button', { name: '+1' });
     expect(plusChipButton).toHaveTextContent('+1');
 
@@ -198,6 +205,83 @@ describe('SubnetLinodeRow', () => {
     expect(firewall).toBeVisible();
   });
 
+  it('should display the VPC IPv6 and VPC IPv6 Ranges when vpcIpv6 feature flag is enabled (config/legacy interface)', async () => {
+    const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' });
+    const subnetFactory1 = subnetFactory.build({ id: 1, label: 'subnet-1' });
+    const config = linodeConfigFactory.build({
+      interfaces: [linodeConfigInterfaceFactoryWithVPC.build({ id: 1 })],
+    });
+    queryMocks.useLinodeInterfaceQuery.mockReturnValue({});
+    queryMocks.useLinodeConfigQuery.mockReturnValue({
+      data: config,
+    });
+
+    const handlePowerActionsLinode = vi.fn();
+    const handleUnassignLinode = vi.fn();
+
+    const { findByText } = renderWithTheme(
+      wrapWithTableBody(
+        ,
+        {
+          flags: { vpcIpv6: true },
+        }
+      )
+    );
+
+    // VPC IPv6 and VPC IPv6 Ranges columns present, so contents of those cells should be populated
+    await findByText('2001:db8::1');
+    await findByText('2001:db8::/64');
+  });
+
+  it('should display the VPC IPv6 and VPC IPv6 Ranges when vpcIpv6 feature flag is enabled (Linode Interface)', async () => {
+    const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' });
+    const vpcLinodeInterface = linodeInterfaceFactoryVPC.build({
+      id: 1,
+    });
+    queryMocks.useLinodeInterfaceQuery.mockReturnValue({
+      data: vpcLinodeInterface,
+    });
+    queryMocks.useLinodeConfigQuery.mockReturnValue({});
+
+    const handlePowerActionsLinode = vi.fn();
+    const handleUnassignLinode = vi.fn();
+
+    const { getByTestId } = renderWithTheme(
+      wrapWithTableBody(
+        ,
+        {
+          flags: { vpcIpv6: true },
+        }
+      )
+    );
+
+    // VPC IPv6 and VPC IPv6 Ranges columns present, so contents of those cells should be populated
+    expect(getByTestId('vpc-ipv6-cell')).toHaveTextContent(
+      '2600:3c03::f03c:91ff:fe0a:109a'
+    );
+    expect(getByTestId('linode-ipv6-ranges-cell')).toHaveTextContent(
+      '2600:3c03::f03c:91ff:fe0a:109a'
+    );
+  });
+
   it('should not display reboot linode button if the linode has all active interfaces', async () => {
     const linodeFactory1 = linodeFactory.build({ id: 1, label: 'linode-1' });
     const subnetFactory1 = subnetFactory.build({ id: 1, label: 'subnet-1' });
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx
index 4d000a5e633..d74de1133da 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx
@@ -11,6 +11,7 @@ import { StatusIcon } from 'src/components/StatusIcon/StatusIcon';
 import { TableCell } from 'src/components/TableCell';
 import { TableRow } from 'src/components/TableRow';
 import { getLinodeIconStatus } from 'src/features/Linodes/LinodesLanding/utils';
+import { useFlags } from 'src/hooks/useFlags';
 import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip';
 
 import { useInterfaceDataForLinode } from '../../../hooks/useInterfaceDataForLinode';
@@ -20,8 +21,9 @@ import {
 } from '../constants';
 import {
   hasUnrecommendedConfiguration as _hasUnrecommendedConfiguration,
+  getLinodeInterfaceIPv4Ranges,
+  getLinodeInterfaceIPv6Ranges,
   getLinodeInterfacePrimaryIPv4,
-  getLinodeInterfaceRanges,
   hasUnrecommendedConfigurationLinodeInterface,
 } from '../utils';
 import { SubnetLinodeActionMenu } from './SubnetLinodeActionMenu';
@@ -68,6 +70,8 @@ export const SubnetLinodeRow = (props: Props) => {
     subnetInterfaces,
   } = props;
 
+  const flags = useFlags();
+
   const subnetInterfaceData =
     subnetInterfaces.find((interfaceData) => interfaceData.active) ??
     subnetInterfaces[0];
@@ -186,7 +190,7 @@ export const SubnetLinodeRow = (props: Props) => {
 
   return (
     
-      
+      
         {labelCell}
       
       
@@ -208,23 +212,49 @@ export const SubnetLinodeRow = (props: Props) => {
         )}
       
       
-        
-          {getSubnetLinodeIPv4CellString(
+        
+          {getSubnetLinodeIPCellString({
             interfaceData,
-            interfaceLoading,
-            interfaceError ?? undefined
-          )}
+            ipType: 'ipv4',
+            loading: interfaceLoading,
+            error: interfaceError ?? undefined,
+          })}
         
       
       
-        
-          {getIPRangesCellContents(
+        
+          {getIPRangesCellContents({
             interfaceData,
-            interfaceLoading,
-            interfaceError ?? undefined
-          )}
+            ipType: 'ipv4',
+            loading: interfaceLoading,
+            error: interfaceError ?? undefined,
+          })}
         
       
+      {flags.vpcIpv6 && (
+        <>
+          
+            
+              {getSubnetLinodeIPCellString({
+                interfaceData,
+                ipType: 'ipv6',
+                loading: interfaceLoading,
+                error: interfaceError ?? undefined,
+              })}
+            
+          
+          
+            
+              {getIPRangesCellContents({
+                interfaceData,
+                ipType: 'ipv6',
+                loading: interfaceLoading,
+                error: interfaceError ?? undefined,
+              })}
+            
+          
+        
+      )}
       
         {isLinodeInterface ? (
            {
           
         )}
       
-      
+      
         {!isVPCLKEEnterpriseCluster && (
            {
   );
 };
 
-const getSubnetLinodeIPv4CellString = (
-  interfaceData: Interface | LinodeInterface | undefined,
-  loading: boolean,
-  error?: APIError[]
+type InterfaceDataTypes = Interface | LinodeInterface | undefined;
+interface IPCellStringInputs {
+  error?: APIError[];
+  interfaceData: InterfaceDataTypes;
+  ipType: 'ipv4' | 'ipv6';
+  loading: boolean;
+}
+
+const getSubnetLinodeIPCellString = (
+  ipCellStringInputs: IPCellStringInputs
 ): JSX.Element | string => {
+  const { error, interfaceData, ipType, loading } = ipCellStringInputs;
   if (loading) {
     return 'Loading...';
   }
 
   if (error) {
-    return 'Error retrieving VPC IPv4s';
+    return `Error retrieving VPC ${ipType === 'ipv4' ? 'IPv4s' : 'IPv6s'}`;
   }
 
   if (!interfaceData) {
@@ -271,37 +308,60 @@ const getSubnetLinodeIPv4CellString = (
   }
 
   if ('purpose' in interfaceData) {
-    return getIPv4LinkForConfigInterface(interfaceData);
+    // presence of `purpose` property indicates it is a Config Profile/legacy interface
+    return ipType === 'ipv4'
+      ? getIPLinkForConfigInterface(interfaceData, 'ipv4')
+      : getIPLinkForConfigInterface(interfaceData, 'ipv6');
   } else {
-    const primaryIPv4 = getLinodeInterfacePrimaryIPv4(interfaceData);
-    return {primaryIPv4 ?? 'None'};
+    if (ipType === 'ipv4') {
+      const primaryIPv4 = getLinodeInterfacePrimaryIPv4(interfaceData);
+      return {primaryIPv4 ?? 'None'};
+    }
+
+    return (
+      
+        {interfaceData.vpc?.ipv6?.slaac[0]?.address ?? '—'}
+      
+    );
   }
 };
 
-const getIPv4LinkForConfigInterface = (
-  configInterface: Interface | undefined
+const getIPLinkForConfigInterface = (
+  configInterface: Interface | undefined,
+  ipType: 'ipv4' | 'ipv6'
 ): JSX.Element => {
   return (
     // eslint-disable-next-line react/jsx-no-useless-fragment
     <>
       {configInterface && (
-        {configInterface.ipv4?.vpc}
+        
+          {ipType === 'ipv4'
+            ? configInterface.ipv4?.vpc
+            : (configInterface.ipv6?.slaac[0]?.address ?? '—')}
+        
       )}
     
   );
 };
 
+interface IPRangesCellStringInputs {
+  error?: APIError[];
+  interfaceData: InterfaceDataTypes;
+  ipType: 'ipv4' | 'ipv6';
+  loading: boolean;
+}
+
 const getIPRangesCellContents = (
-  interfaceData: Interface | LinodeInterface | undefined,
-  loading: boolean,
-  error?: APIError[]
+  ipRangesCellStringInputs: IPRangesCellStringInputs
 ): JSX.Element | string => {
+  const { error, interfaceData, ipType, loading } = ipRangesCellStringInputs;
+
   if (loading) {
     return 'Loading...';
   }
 
   if (error) {
-    return 'Error retrieving VPC IPv4s';
+    return `Error retrieving VPC ${ipType === 'ipv4' ? 'IPv4' : 'IPv6'}s`;
   }
 
   if (!interfaceData) {
@@ -309,18 +369,45 @@ const getIPRangesCellContents = (
   }
 
   if ('purpose' in interfaceData) {
-    return determineNoneSingleOrMultipleWithChip(
-      interfaceData?.ip_ranges ?? []
-    );
+    // presence of `purpose` property indicates it is a Config Profile/legacy interface
+    if (ipType === 'ipv4') {
+      return determineNoneSingleOrMultipleWithChip(
+        interfaceData.ip_ranges ?? []
+      );
+    }
+
+    const ipv6Ranges =
+      interfaceData.ipv6?.ranges
+        .map((rangeObj) => rangeObj.range)
+        .filter((range) => range !== undefined) ?? [];
+
+    const noneSingleOrMultipleWithChipIPV6 =
+      determineNoneSingleOrMultipleWithChip(ipv6Ranges);
+
+    // For IPv6 columns, we want to display em dashes instead of 'None' in the cells to help indicate the VPC/subnet does not support IPv6
+    return noneSingleOrMultipleWithChipIPV6 === 'None'
+      ? '—'
+      : noneSingleOrMultipleWithChipIPV6;
   } else {
-    const linodeInterfaceVPCRanges = getLinodeInterfaceRanges(interfaceData);
-    return determineNoneSingleOrMultipleWithChip(
+    const linodeInterfaceVPCRanges =
+      ipType === 'ipv4'
+        ? getLinodeInterfaceIPv4Ranges(interfaceData)
+        : getLinodeInterfaceIPv6Ranges(interfaceData);
+
+    const noneSingleOrMultipleWithChip = determineNoneSingleOrMultipleWithChip(
       linodeInterfaceVPCRanges ?? []
     );
+
+    // For IPv6 columns, we want to display em dashes instead of 'None' in the cells to help indicate the VPC/subnet does not support IPv6
+    return ipType === 'ipv6' && noneSingleOrMultipleWithChip === 'None'
+      ? '—'
+      : noneSingleOrMultipleWithChip;
   }
 };
 
 export const SubnetLinodeTableRowHead = (
+  vpcIPv6FeatureFlag: boolean = false
+) => (
   
     Linode
     Status
@@ -330,6 +417,16 @@ export const SubnetLinodeTableRowHead = (
     
       VPC IPv4 Ranges
     
+    {vpcIPv6FeatureFlag && (
+      <>
+        
+          VPC IPv6
+        
+        
+          VPC IPv6 Ranges
+        
+      
+    )}
     
       Firewalls
     
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRowFirewallsCell.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRowFirewallsCell.tsx
index a850e3ff599..060e37f5e79 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRowFirewallsCell.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRowFirewallsCell.tsx
@@ -19,7 +19,7 @@ export const ConfigInterfaceFirewallCell = (props: { linodeId: number }) => {
   } = useLinodeFirewallsQuery(linodeId);
 
   return (
-    
+    
       {getFirewallsCellString(
         attachedFirewalls?.data ?? [],
         isLoading,
@@ -41,7 +41,7 @@ export const LinodeInterfaceFirewallCell = (props: {
   } = useLinodeInterfaceFirewallsQuery(linodeId, interfaceId);
 
   return (
-    
+    
       {getFirewallsCellString(
         attachedFirewalls?.data ?? [],
         isLoading,
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx
index be8b859eb17..b47c77189b9 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx
@@ -24,8 +24,8 @@ import { useUnassignLinode } from 'src/hooks/useUnassignLinode';
 import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets';
 
 import {
+  getLinodeInterfaceIPv4Ranges,
   getLinodeInterfacePrimaryIPv4,
-  getLinodeInterfaceRanges,
 } from '../utils';
 import { SubnetLinodeActionNotice } from './SubnetLinodeActionNotice';
 
@@ -157,7 +157,8 @@ export const SubnetUnassignLinodesDrawer = React.memo(
                       configId: null,
                       vpcIPv4:
                         getLinodeInterfacePrimaryIPv4(vpcLinodeInterface),
-                      vpcRanges: getLinodeInterfaceRanges(vpcLinodeInterface),
+                      vpcRanges:
+                        getLinodeInterfaceIPv4Ranges(vpcLinodeInterface),
                       interfaceId: vpcLinodeInterface.id,
                     };
                   }
diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx
index 32887a86ffe..fc57ec8f1fb 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx
@@ -1,3 +1,4 @@
+import { screen, waitForElementToBeRemoved } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 
@@ -35,6 +36,8 @@ vi.mock('@linode/queries', async () => {
   };
 });
 
+const loadingTestId = 'circle-progress';
+
 describe('VPC Subnets table', () => {
   beforeEach(() => {
     queryMocks.useFirewallSettingsQuery.mockReturnValue({
@@ -171,21 +174,73 @@ describe('VPC Subnets table', () => {
       },
     });
 
+    // @TODO VPC IPv6: Remove this flag mock once VPC IPv6 is fully rolled out, and update
+    // the assertion to expect the IPv6 columns are present
     const { getByLabelText, getByText } = renderWithTheme(
       
+      />,
+      { flags: { vpcIpv6: false } }
     );
 
     const expandTableButton = getByLabelText(`expand ${subnet.label} row`);
     await userEvent.click(expandTableButton);
 
-    expect(getByText('Linode')).toBeVisible();
-    expect(getByText('Status')).toBeVisible();
-    expect(getByText('VPC IPv4')).toBeVisible();
-    expect(getByText('Firewalls')).toBeVisible();
+    getByText('Linode');
+    getByText('Status');
+    getByText('VPC IPv4');
+    getByText('Firewalls');
+
+    expect(screen.queryByText('VPC IPv6')).not.toBeInTheDocument();
+    expect(screen.queryByText('VPC IPv6 Ranges')).not.toBeInTheDocument();
+  });
+
+  // @TODO VPC IPv6: Remove this assertion once VPC IPv6 is fully rolled out
+  it('renders VPC IPv6 and VPC IPv6 Ranges columns in Linode table when vpcIpv6 feature flag is enabled', async () => {
+    const subnet = subnetFactory.build({
+      linodes: [subnetAssignedLinodeDataFactory.build({ id: 1 })],
+    });
+
+    queryMocks.useSubnetsQuery.mockReturnValue({
+      data: {
+        data: [subnet],
+      },
+    });
+
+    renderWithTheme(
+      ,
+      { flags: { vpcIpv6: true } }
+    );
+
+    const loadingState = screen.queryByTestId(loadingTestId);
+    if (loadingState) {
+      await waitForElementToBeRemoved(loadingState);
+    }
+
+    const expandTableButton = screen.getAllByRole('button')[3];
+    await userEvent.click(expandTableButton);
+
+    renderWithTheme(
+      ,
+      { flags: { vpcIpv6: true } }
+    );
+
+    expect(screen.getByText('Linode')).toBeVisible();
+    expect(screen.getByText('Status')).toBeVisible();
+    expect(screen.getByText('VPC IPv4')).toBeVisible();
+    expect(screen.getByText('VPC IPv6')).toBeVisible();
+    expect(screen.getByText('VPC IPv6 Ranges')).toBeVisible();
+    expect(screen.getByText('Firewalls')).toBeVisible();
   });
 
   it(
diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx
index bd19cdef66d..611533b2b42 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx
@@ -28,6 +28,7 @@ import { TableSortCell } from 'src/components/TableSortCell';
 import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer';
 import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils';
 import { SubnetActionMenu } from 'src/features/VPCs/VPCDetail/SubnetActionMenu';
+import { useFlags } from 'src/hooks/useFlags';
 import { useOrderV2 } from 'src/hooks/useOrderV2';
 import { usePaginationV2 } from 'src/hooks/usePaginationV2';
 
@@ -62,6 +63,8 @@ export const VPCSubnetsTable = (props: Props) => {
   const theme = useTheme();
   const { enqueueSnackbar } = useSnackbar();
 
+  const flags = useFlags();
+
   const navigate = useNavigate();
   const params = useParams({ strict: false });
   const location = useLocation();
@@ -86,7 +89,7 @@ export const VPCSubnetsTable = (props: Props) => {
   });
   const { query } = search;
 
-  const flags = useIsNodebalancerVPCEnabled();
+  const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled();
 
   const pagination = usePaginationV2({
     currentRoute: VPC_DETAILS_ROUTE,
@@ -291,11 +294,14 @@ export const VPCSubnetsTable = (props: Props) => {
           Subnet ID
         
       
-      Subnet IP Range
+      
+        Subnet {flags.vpcIpv6 ? 'IPv4' : 'IP'} Range
+      
+      {flags.vpcIpv6 && Subnet IPv6 Range}
       
         {`${flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'}`}
+        >{`${isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'}`}
       
       
     
@@ -309,9 +315,12 @@ export const VPCSubnetsTable = (props: Props) => {
             {subnet.id}
           
           {subnet.ipv4}
+          {flags.vpcIpv6 && (
+            {subnet.ipv6?.[0]?.range ?? '—'}
+          )}
           
             
-              {`${flags.isNodebalancerVPCEnabled ? subnet.linodes.length + subnet.nodebalancers.length : subnet.linodes.length}`}
+              {`${isNodebalancerVPCEnabled ? subnet.linodes.length + subnet.nodebalancers.length : subnet.linodes.length}`}
             
           
           
@@ -338,7 +347,7 @@ export const VPCSubnetsTable = (props: Props) => {
                 color: theme.tokens.color.Neutrals.White,
               }}
             >
-              {SubnetLinodeTableRowHead}
+              {SubnetLinodeTableRowHead(flags.vpcIpv6)}
             
             
               {subnet.linodes.length > 0 ? (
@@ -355,31 +364,33 @@ export const VPCSubnetsTable = (props: Props) => {
                   />
                 ))
               ) : (
-                
+                
               )}
             
           
-          {flags.isNodebalancerVPCEnabled &&
-            subnet.nodebalancers?.length > 0 && (
-              
-                
-                  {SubnetNodebalancerTableRowHead}
-                
-                
-                  {subnet.nodebalancers.map((nb) => (
-                    
-                  ))}
-                
-              
- )} + {isNodebalancerVPCEnabled && subnet.nodebalancers?.length > 0 && ( + + + {SubnetNodebalancerTableRowHead} + + + {subnet.nodebalancers.map((nb) => ( + + ))} + +
+ )} ); diff --git a/packages/manager/src/features/VPCs/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts index 11cf3a75b63..5ffa9a83075 100644 --- a/packages/manager/src/features/VPCs/utils.test.ts +++ b/packages/manager/src/features/VPCs/utils.test.ts @@ -12,8 +12,8 @@ import { } from 'src/factories/subnets'; import { + getLinodeInterfaceIPv4Ranges, getLinodeInterfacePrimaryIPv4, - getLinodeInterfaceRanges, getUniqueLinodesFromSubnets, getUniqueResourcesFromSubnets, getVPCInterfacePayload, @@ -298,9 +298,9 @@ describe('Linode Interface utility functions', () => { }); it('gets the VPC Linode Interface ranges', () => { - expect(getLinodeInterfaceRanges(linodeInterfaceFactoryVPC.build())).toEqual( - ['10.0.0.1'] - ); + expect( + getLinodeInterfaceIPv4Ranges(linodeInterfaceFactoryVPC.build()) + ).toEqual(['10.0.0.1']); }); describe('getVPCInterfacePayload', () => { diff --git a/packages/manager/src/features/VPCs/utils.ts b/packages/manager/src/features/VPCs/utils.ts index 31a171caef3..b456dab83cd 100644 --- a/packages/manager/src/features/VPCs/utils.ts +++ b/packages/manager/src/features/VPCs/utils.ts @@ -103,9 +103,12 @@ export const getIsVPCLKEEnterpriseCluster = (vpc: VPC) => export const getLinodeInterfacePrimaryIPv4 = (iface: LinodeInterface) => iface.vpc?.ipv4?.addresses.find((address) => address.primary)?.address; -export const getLinodeInterfaceRanges = (iface: LinodeInterface) => +export const getLinodeInterfaceIPv4Ranges = (iface: LinodeInterface) => iface.vpc?.ipv4?.ranges.map((range) => range.range); +export const getLinodeInterfaceIPv6Ranges = (iface: LinodeInterface) => + iface.vpc?.ipv6?.ranges.map((range) => range.range); + // TODO: update this when converting to react-hook-form // gets the VPC Interface payload depending on whether we want a Linode Interface or Config Interface payload export const getVPCInterfacePayload = (inputs: { diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts index d127a10010d..d11e5ddec81 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts @@ -179,11 +179,17 @@ export const createLinode = (mockState: MockState) => [ (iface: CreateLinodeInterfacePayload) => iface.vpc ); + const prelimVPCInterface = linodeInterfaceFactoryVPC.build(); // Created just to pull a few values from + const vpcInterface = linodeInterfaceFactoryVPC.build({ ...vpcIfacePayload, default_route: { ipv4: true, }, + vpc: { + ipv4: prelimVPCInterface.vpc?.ipv4, + ipv6: prelimVPCInterface.vpc?.ipv6, + }, created: DateTime.now().toISO(), updated: DateTime.now().toISO(), }); diff --git a/packages/utilities/.changeset/pr-12305-upcoming-features-1753219916941.md b/packages/utilities/.changeset/pr-12305-upcoming-features-1753219916941.md new file mode 100644 index 00000000000..39c7baf2227 --- /dev/null +++ b/packages/utilities/.changeset/pr-12305-upcoming-features-1753219916941.md @@ -0,0 +1,5 @@ +--- +"@linode/utilities": Upcoming Features +--- + +Update linodeConfigInterfaceFactoryWithVPC and linodeInterfaceFactoryVPC with IPv6 data ([#12305](https://github.com/linode/manager/pull/12305)) diff --git a/packages/utilities/src/factories/linodeConfigInterface.ts b/packages/utilities/src/factories/linodeConfigInterface.ts index 1f789459240..3daeb50a58b 100644 --- a/packages/utilities/src/factories/linodeConfigInterface.ts +++ b/packages/utilities/src/factories/linodeConfigInterface.ts @@ -24,8 +24,17 @@ export const linodeConfigInterfaceFactoryWithVPC = }, ipv6: { is_public: false, - ranges: [], - slaac: [], + ranges: [ + { + range: '2001:db8::/64', + }, + ], + slaac: [ + { + address: '2001:db8::1', + range: '2001:db8::/64', + }, + ], }, label: Factory.each((i) => `interface-${i}`), purpose: 'vpc', diff --git a/packages/utilities/src/factories/linodeInterface.ts b/packages/utilities/src/factories/linodeInterface.ts index 79ca9f11d36..1e9ed2824a6 100644 --- a/packages/utilities/src/factories/linodeInterface.ts +++ b/packages/utilities/src/factories/linodeInterface.ts @@ -68,6 +68,20 @@ export const linodeInterfaceFactoryVPC = ], ranges: [{ range: '10.0.0.1' }], }, + ipv6: { + is_public: false, + ranges: [ + { + range: '2600:3c03::f03c:91ff:fe0a:109a', + }, + ], + slaac: [ + { + address: '2600:3c03::f03c:91ff:fe0a:109a', + range: '2600:3c03::/64', + }, + ], + }, subnet_id: 1, vpc_id: 1, }, From 63fa9c14646c4cb8ba250768a12c127984fc2f27 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:25:27 -0700 Subject: [PATCH 18/67] upcoming: [M3-10203] - Add IPv6 (dual stack) support to LKE-E create flow (#12594) * Add stack_type to api-v4 package * Add stack type radio button selection to networking panel * Add stack_type to parent RHF * Mock stack_type in POST, fix payload * Add changesets --- ...r-12594-upcoming-features-1753822524437.md | 5 ++ packages/api-v4/src/kubernetes/types.ts | 8 +++ ...r-12594-upcoming-features-1753822274393.md | 5 ++ .../CreateCluster/ClusterNetworkingPanel.tsx | 57 ++++++++++++++++++- .../CreateCluster/CreateCluster.tsx | 19 ++++++- .../mocks/presets/crud/handlers/kubernetes.ts | 1 + 6 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12594-upcoming-features-1753822524437.md create mode 100644 packages/manager/.changeset/pr-12594-upcoming-features-1753822274393.md diff --git a/packages/api-v4/.changeset/pr-12594-upcoming-features-1753822524437.md b/packages/api-v4/.changeset/pr-12594-upcoming-features-1753822524437.md new file mode 100644 index 00000000000..70306b85364 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12594-upcoming-features-1753822524437.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add type and update cluster payload and interface to support optional stack_type field for LKE-E ([#12594](https://github.com/linode/manager/pull/12594)) diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index b9774c3abec..5eea395460d 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -13,6 +13,8 @@ export type Label = { export type NodePoolUpdateStrategy = 'on_recycle' | 'rolling_update'; +export type KubernetesStackType = 'ipv4' | 'ipv4-ipv6'; + export interface Taint { effect: KubernetesTaintEffect; key: string; @@ -27,6 +29,11 @@ export interface KubernetesCluster { k8s_version: string; label: string; region: string; + /** + * Upcoming Feature Notice - LKE-E:** this property may not be available to all customers + * and may change in subsequent releases. + */ + stack_type?: KubernetesStackType; status: string; // @todo enum this /** * Upcoming Feature Notice - LKE-E:** this property may not be available to all customers @@ -147,5 +154,6 @@ export interface CreateKubeClusterPayload { label?: string; // Label will be assigned by the API if not provided node_pools: CreateNodePoolDataBeta[]; region?: string; // Will be caught by Yup if undefined + stack_type?: KubernetesStackType; // For LKE-E; will default to 'ipv4' tier?: KubernetesTier; // For LKE-E: Will be assigned 'standard' by the API if not provided } diff --git a/packages/manager/.changeset/pr-12594-upcoming-features-1753822274393.md b/packages/manager/.changeset/pr-12594-upcoming-features-1753822274393.md new file mode 100644 index 00000000000..77a2fca159b --- /dev/null +++ b/packages/manager/.changeset/pr-12594-upcoming-features-1753822274393.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add IP Version (IPv4/IPv6) support to LKE-E cluster create flow ([#12594](https://github.com/linode/manager/pull/12594)) diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx index 232b5e30cba..24449792412 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx @@ -1,8 +1,61 @@ -import { Stack, Typography } from '@linode/ui'; +import { + Divider, + FormControlLabel, + Radio, + RadioGroup, + Stack, + Typography, +} from '@linode/ui'; import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { FormLabel } from 'src/components/FormLabel'; + +import { useIsLkeEnterpriseEnabled } from '../kubeUtils'; export const ClusterNetworkingPanel = () => { - return ( + const { isLkeEnterprisePhase2FeatureEnabled } = useIsLkeEnterpriseEnabled(); + + const { control } = useFormContext(); + + return isLkeEnterprisePhase2FeatureEnabled ? ( + <> + } spacing={4}> + ( + field.onChange(e.target.value)} + value={field.value ?? null} + > + IP Version + } label="IPv4" value="ipv4" /> + } + label="IPv4 + IPv6" + value="ipv4-ipv6" + /> + + )} + /> + + + ({ + font: theme.tokens.alias.Typography.Label.Bold.S, + })} + > + VPC + + + Allow for private communications within and across clusters in the + same data center. + + + + ) : ( VPC & Firewall diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index b9bc7ba15a2..b18501ce3a7 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -82,6 +82,7 @@ import type { CreateKubeClusterPayload, CreateNodePoolDataBeta, KubeNodePoolResponseBeta, + KubernetesStackType, KubernetesTier, } from '@linode/api-v4/lib/kubernetes'; import type { Region } from '@linode/api-v4/lib/regions'; @@ -90,6 +91,7 @@ import type { ExtendedIP } from 'src/utilities/ipUtils'; type FormValues = { nodePools: KubeNodePoolResponseBeta[]; + stack_type: KubernetesStackType | null; }; export interface NodePoolConfigDrawerHandlerParams { @@ -141,12 +143,20 @@ export const CreateCluster = () => { const [selectedType, setSelectedType] = React.useState(); const [selectedPoolIndex, setSelectedPoolIndex] = React.useState(); + const { + isLkeEnterpriseLAFeatureEnabled, + isLkeEnterpriseLAFlagEnabled, + isLkeEnterprisePhase2FeatureEnabled, + } = useIsLkeEnterpriseEnabled(); + // Use React Hook Form for node pools to make updating pools and their configs easier. // TODO - Future: use RHF for the rest of the form and replace FormValues with CreateKubeClusterPayload. const { control, ...form } = useForm({ defaultValues: { nodePools: [], + stack_type: isLkeEnterprisePhase2FeatureEnabled ? 'ipv4' : null, }, + shouldUnregister: true, }); const nodePools = useWatch({ control, name: 'nodePools' }); const { update } = useFieldArray({ @@ -227,9 +237,6 @@ export const CreateCluster = () => { const { mutateAsync: createKubernetesClusterBeta } = useCreateKubernetesClusterBetaMutation(); - const { isLkeEnterpriseLAFeatureEnabled, isLkeEnterpriseLAFlagEnabled } = - useIsLkeEnterpriseEnabled(); - const { isLoadingVersions, versions: versionData, @@ -272,6 +279,8 @@ export const CreateCluster = () => { pick(['type', 'count', 'update_strategy']) ) as CreateNodePoolDataBeta[]; + const stackType = form.getValues('stack_type'); + const _ipv4 = ipV4Addr .map((ip) => { return ip.address; @@ -320,6 +329,10 @@ export const CreateCluster = () => { payload = { ...payload, tier: selectedTier }; } + if (isLkeEnterprisePhase2FeatureEnabled && stackType) { + payload = { ...payload, stack_type: stackType }; + } + const createClusterFn = isUsingBetaEndpoint ? createKubernetesClusterBeta : createKubernetesCluster; diff --git a/packages/manager/src/mocks/presets/crud/handlers/kubernetes.ts b/packages/manager/src/mocks/presets/crud/handlers/kubernetes.ts index 1d9f0d29fa4..a88cb827be8 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/kubernetes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/kubernetes.ts @@ -223,6 +223,7 @@ export const createKubernetesCluster = (mockState: MockState) => [ created: DateTime.now().toISO(), updated: DateTime.now().toISO(), tier: payload.tier, + stack_type: payload.stack_type, }); const createNodePoolPromises = (payload.node_pools || []).map( From 5f3402e91319d2c9a7bfa37a0def70ba15cc9825 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Wed, 30 Jul 2025 12:06:25 +0200 Subject: [PATCH 19/67] [M3-10358] - Improve Create Linode code splitting & routing (#12554) * Initial commit - update all tabs to use segments * more coverage * fix units * fix e2e & units * couple more test fixes * Added changeset: Improve Create Linode code splitting & routing * feedback @bnussman-akamai * Add backward compatibility for param routes * missing instance * Missed two ore instances * last one! --------- Co-authored-by: Alban Bailly Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> --- .../pr-12554-tech-stories-1753272667661.md | 5 + .../general/account-login-redirect.spec.ts | 8 +- .../images/create-linode-from-image.spec.ts | 6 +- .../e2e/core/linodes/clone-linode.spec.ts | 2 +- ...te-linode-with-dc-specific-pricing.spec.ts | 2 +- .../e2e/core/linodes/rebuild-linode.spec.ts | 2 +- .../core/oneClickApps/one-click-apps.spec.ts | 8 +- .../smoke-community-stackscripts.spec.ts | 4 +- .../manager/cypress/support/ui/constants.ts | 10 +- .../support/ui/locators/common-locators.ts | 2 +- packages/manager/src/GoTo.tsx | 2 +- .../src/components/PrimaryNav/PrimaryNav.tsx | 2 +- .../FirewallLanding/CreateFirewallDrawer.tsx | 12 +- .../ImagesLanding/ImagesLanding.test.tsx | 7 +- .../Images/ImagesLanding/ImagesLanding.tsx | 3 +- .../features/Linodes/LinodeCreate/Actions.tsx | 9 +- .../ApiAwarenessModal/CurlTabPanel.tsx | 6 +- .../ApiAwarenessModal/LinodeCLIPanel.tsx | 6 +- .../LinodeCreate/Details/Details.test.tsx | 4 +- .../Linodes/LinodeCreate/Details/Details.tsx | 6 +- .../Details/PlacementGroupPanel.tsx | 6 +- .../Linodes/LinodeCreate/Firewall.tsx | 8 +- .../features/Linodes/LinodeCreate/Plan.tsx | 6 +- .../Linodes/LinodeCreate/Region.test.tsx | 8 +- .../features/Linodes/LinodeCreate/Region.tsx | 31 ++-- .../Tabs/Backups/backupsLazyRoute.ts | 7 + .../LinodeCreate/Tabs/Clone/cloneLazyRoute.ts | 7 + .../Tabs/Marketplace/marketPlaceLazyRoute.ts | 9 ++ .../Tabs/StackScripts/StackScriptImages.tsx | 6 +- .../StackScripts/StackScriptSelection.tsx | 18 ++- .../StackScriptSelectionList.test.tsx | 7 +- .../StackScripts/StackScriptSelectionList.tsx | 30 ++-- .../StackScripts/stackScriptsLazyRoute.ts | 9 ++ .../LinodeCreate/Tabs/imagesLazyRoutes.ts | 7 + .../Tabs/operatingSystemsLazyRoute.ts | 7 + .../Tabs/utils/useGetLinodeCreateType.ts | 47 ++++++ .../Linodes/LinodeCreate/TwoStepRegion.tsx | 6 +- .../UserData/UserDataHeading.test.tsx | 15 +- .../LinodeCreate/UserData/UserDataHeading.tsx | 6 +- .../Linodes/LinodeCreate/VLAN/VLAN.tsx | 6 +- .../features/Linodes/LinodeCreate/VPC/VPC.tsx | 6 +- .../Linodes/LinodeCreate/index.test.tsx | 6 +- .../features/Linodes/LinodeCreate/index.tsx | 137 +++++++++--------- .../LinodeCreate/shared/LinodeSelectTable.tsx | 25 ++-- .../Linodes/LinodeCreate/utilities.test.tsx | 14 -- .../Linodes/LinodeCreate/utilities.ts | 99 ++----------- .../LinodeBackup/LinodeBackups.tsx | 3 +- .../Linodes/LinodesLanding/AppsSection.tsx | 12 +- .../LinodeActionMenu.test.tsx | 1 - .../LinodeActionMenu/LinodeActionMenu.tsx | 2 +- .../LinodeActionMenu/LinodeActionMenuUtils.ts | 1 - .../LinodesLandingEmptyState.tsx | 2 +- .../StackScriptActionMenu.tsx | 23 +-- .../features/StackScripts/stackScriptUtils.ts | 8 +- .../TopMenu/CreateMenu/CreateMenu.tsx | 2 +- .../FormComponents/SubnetContent.tsx | 14 +- .../FormComponents/VPCTopSectionContent.tsx | 13 +- packages/manager/src/hooks/useCreateVPC.ts | 18 +-- packages/manager/src/routes/linodes/index.ts | 123 +++++++++++++++- 59 files changed, 481 insertions(+), 380 deletions(-) create mode 100644 packages/manager/.changeset/pr-12554-tech-stories-1753272667661.md create mode 100644 packages/manager/src/features/Linodes/LinodeCreate/Tabs/Backups/backupsLazyRoute.ts create mode 100644 packages/manager/src/features/Linodes/LinodeCreate/Tabs/Clone/cloneLazyRoute.ts create mode 100644 packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/marketPlaceLazyRoute.ts create mode 100644 packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/stackScriptsLazyRoute.ts create mode 100644 packages/manager/src/features/Linodes/LinodeCreate/Tabs/imagesLazyRoutes.ts create mode 100644 packages/manager/src/features/Linodes/LinodeCreate/Tabs/operatingSystemsLazyRoute.ts create mode 100644 packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts diff --git a/packages/manager/.changeset/pr-12554-tech-stories-1753272667661.md b/packages/manager/.changeset/pr-12554-tech-stories-1753272667661.md new file mode 100644 index 00000000000..8d686f52a3b --- /dev/null +++ b/packages/manager/.changeset/pr-12554-tech-stories-1753272667661.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Improve Create Linode code splitting & routing ([#12554](https://github.com/linode/manager/pull/12554)) diff --git a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts index 8bec02bed7d..31a056ded31 100644 --- a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts +++ b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts @@ -48,14 +48,14 @@ describe('account login redirect', () => { * This test validates that the encoded redirect param is valid and can be properly decoded when the user is redirected to our application. */ it('should redirect the user to the page they were on if the redirect param is present and valid', () => { - cy.visitWithLogin('/linodes/create?type=Images'); - cy.url().should('contain', '/linodes/create'); + cy.visitWithLogin('/linodes/create/images'); + cy.url().should('contain', '/linodes/create/images'); cy.clearLocalStorage(tokenLocalStorageKey); cy.reload(); cy.url().should( 'contain', - 'returnTo%253D%252Flinodes%252Fcreate%253Ftype%253DImages' + 'returnTo%253D%252Flinodes%252Fcreate%252Fimages' ); cy.url().then((url) => { // We need to decode the URL twice to get the original redirect URL. @@ -63,7 +63,7 @@ describe('account login redirect', () => { const decodedOnce = decodeURIComponent(url); const decodedTwice = decodeURIComponent(decodedOnce); - expect(decodedTwice).to.contain('/linodes/create?type=Images'); + expect(decodedTwice).to.contain('/linodes/create/images'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index 22e776ed49e..40aef7bf9ed 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -80,7 +80,7 @@ describe('create linode from image, mocked data', () => { ]; mockGetAllImages([]).as('getImages'); - cy.visitWithLogin('/linodes/create?type=Images'); + cy.visitWithLogin('/linodes/create/images'); cy.wait('@getImages'); noImagesMessages.forEach((message: string) => { cy.findByText(message, { exact: false }).should('be.visible'); @@ -88,12 +88,12 @@ describe('create linode from image, mocked data', () => { }); it('creates linode from image on images tab', () => { - createLinodeWithImageMock('/linodes/create?type=Images', false); + createLinodeWithImageMock('/linodes/create/images', false); }); it('creates linode from preselected image on images tab', () => { createLinodeWithImageMock( - `/linodes/create/?type=Images&imageID=${mockImage.id}`, + `/linodes/create/images?imageID=${mockImage.id}`, true ); }); diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 88337c0c2b3..6fbb52b8e7d 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -59,7 +59,7 @@ import type { Event, Linode } from '@linode/api-v4'; const getLinodeCloneUrl = (linode: Linode): string => { const regionQuery = `®ionID=${linode.region}`; const typeQuery = linode.type ? `&typeID=${linode.type}` : ''; - return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone%20Linode${typeQuery}`; + return `/linodes/create/clone?linodeID=${linode.id}${regionQuery}${typeQuery}`; }; authenticate(); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts index 6229f2a2b96..a8b5a11bcd2 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts @@ -71,7 +71,7 @@ describe('Create Linode with DC-specific pricing', () => { mockCreateLinode(mockLinode).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + cy.get('[data-qa-header="OS"]').should('have.text', 'OS'); ui.button.findByTitle('Create Linode').click(); diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index daaf7eb21ce..dbab679f216 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -444,7 +444,7 @@ describe('rebuild linode', () => { mockGetAllImages([image]); mockGetImage(image.id, image); - cy.visitWithLogin(`/linodes/${linode.id}?rebuild=true`); + cy.visitWithLogin(`/linodes/${linode.id}/metrics/?rebuild=true`); findRebuildDialog(linode.label).within(() => { // Select an Image 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 67f44a6b5a1..bd5f13f4f28 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 @@ -23,7 +23,7 @@ describe('OneClick Apps (OCA)', () => { cy.tag('method:e2e', 'env:marketplaceApps'); interceptGetStackScripts().as('getStackScripts'); - cy.visitWithLogin(`/linodes/create?type=One-Click`); + cy.visitWithLogin(`/linodes/create/marketplace`); cy.wait('@getStackScripts').then((xhr) => { const stackScripts: StackScript[] = xhr.response?.body.data ?? []; @@ -60,7 +60,7 @@ describe('OneClick Apps (OCA)', () => { cy.tag('method:e2e', 'env:marketplaceApps'); interceptGetStackScripts().as('getStackScripts'); - cy.visitWithLogin(`/linodes/create?type=One-Click`); + cy.visitWithLogin(`/linodes/create/marketplace`); cy.wait('@getStackScripts').then((xhr) => { const stackScripts: StackScript[] = xhr.response?.body.data ?? []; @@ -171,7 +171,7 @@ describe('OneClick Apps (OCA)', () => { mockGetStackScripts([stackscript]).as('getStackScripts'); mockGetStackScript(stackscript.id, stackscript); - cy.visitWithLogin(`/linodes/create?type=One-Click`); + cy.visitWithLogin(`/linodes/create/marketplace`); cy.wait('@getStackScripts'); @@ -253,7 +253,7 @@ describe('OneClick Apps (OCA)', () => { cy.tag('method:e2e', 'env:marketplaceApps'); interceptGetStackScripts().as('getStackScripts'); - cy.visitWithLogin(`/linodes/create?type=One-Click`); + cy.visitWithLogin(`/linodes/create/marketplace`); cy.wait('@getStackScripts').then((xhr) => { // Check the content of the app list 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 eda280ed210..26b17237855 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 @@ -307,7 +307,7 @@ describe('Community Stackscripts integration tests', () => { .click(); cy.url().should( 'endWith', - `linodes/create?type=StackScripts&subtype=Community&stackScriptID=${stackScriptId}` + `linodes/create/stackscripts?subtype=Community&stackScriptID=${stackScriptId}` ); }); @@ -331,7 +331,7 @@ describe('Community Stackscripts integration tests', () => { .click(); cy.url().should( 'endWith', - `linodes/create?type=StackScripts&subtype=Community&stackScriptID=${stackScriptId}` + `linodes/create/stackscripts?subtype=Community&stackScriptID=${stackScriptId}` ); // Input VPN information diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index e4bb5251d6a..d2ef557df87 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -6,7 +6,7 @@ export const loadAppNoLogin = (path: string) => waitForAppLoad(path, false); export const routes = { account: '/account', createLinode: '/linodes/create', - createLinodeOCA: '/linodes/create?type=One-Click', + createLinodeOCA: '/linodes/create/marketplace', linodeLanding: '/linodes', profile: '/profile', support: '/support', @@ -49,7 +49,7 @@ export const pages: Page[] = [ }, ], name: 'Linode/Create/OS', - url: `${routes.createLinode}?type=OS`, + url: `${routes.createLinode}/os`, }, { assertIsLoaded: () => cy.findByText('Select App').should('be.visible'), @@ -89,18 +89,18 @@ export const pages: Page[] = [ { assertIsLoaded: () => cy.findByText('Choose an Image').should('be.visible'), name: 'Linode/Create/FromImages', - url: `${routes.createLinode}?type=Images`, + url: `${routes.createLinode}/images`, }, { assertIsLoaded: () => cy.findByText('Select Backup').should('be.visible'), name: 'Linode/Create/FromBackup', - url: `${routes.createLinode}?type=Backups`, + url: `${routes.createLinode}/backups`, }, { assertIsLoaded: () => cy.findByText('Select Linode to Clone From').should('be.visible'), name: 'Linode/Create/Clone', - url: `${routes.createLinode}?type=Clone%20Linode`, + url: `${routes.createLinode}/clone`, }, { assertIsLoaded: () => cy.findByText('My Profile').should('be.visible'), diff --git a/packages/manager/cypress/support/ui/locators/common-locators.ts b/packages/manager/cypress/support/ui/locators/common-locators.ts index d904a5d2700..15fcc44ec42 100644 --- a/packages/manager/cypress/support/ui/locators/common-locators.ts +++ b/packages/manager/cypress/support/ui/locators/common-locators.ts @@ -29,7 +29,7 @@ export const topMenuCreateItemsLocator = { /** Top menu create dropdown items Linodes Link. */ linodesLink: '[href="/linodes/create"]', /** Top menu create dropdown items Marketplace(One-Click) Link. */ - marketplaceOneClickLink: '[href="/linodes/create?type=One-Click"]', + marketplaceOneClickLink: '[href="/linodes/create/marketplace"]', /** Top menu create dropdown items NodeBalancers Link. */ nodeBalancersLink: '[href="/nodebalancers/create"]', /** Top menu create dropdown items Volumes Link. */ diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index d3187103a34..8314f43cd16 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -101,7 +101,7 @@ export const GoTo = React.memo(() => { { display: 'Marketplace', - href: '/linodes/create?type=One-Click', + href: '/linodes/create/marketplace', }, { display: 'Account', diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index cbc9573592e..1ad111ebac0 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -164,7 +164,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { { attr: { 'data-qa-one-click-nav-btn': true }, display: 'Marketplace', - href: '/linodes/create?type=One-Click', + href: '/linodes/create/marketplace', }, ], name: 'Compute', diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 10a7f416db7..a3b827b3b4b 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -10,17 +10,15 @@ import { TextField, Typography, } from '@linode/ui'; -import { getQueryParamsFromQueryString } from '@linode/utilities'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; -// eslint-disable-next-line no-restricted-imports -import { useLocation } from 'react-router-dom'; import { ErrorMessage } from 'src/components/ErrorMessage'; import { createFirewallFromTemplate } from 'src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useGetLinodeCreateType } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; @@ -30,7 +28,6 @@ import { TemplateFirewallFields } from './TemplateFirewallFields'; import type { CreateFirewallFormValues } from './formUtilities'; import type { Firewall, FirewallDeviceEntityType } from '@linode/api-v4'; -import type { LinodeCreateQueryParams } from 'src/features/Linodes/types'; import type { LinodeCreateFormEventOptions } from 'src/utilities/analytics/types'; export interface CreateFirewallDrawerProps { @@ -69,14 +66,11 @@ export const CreateFirewallDrawer = (props: CreateFirewallDrawerProps) => { const { enqueueSnackbar } = useSnackbar(); - const location = useLocation(); + const createType = useGetLinodeCreateType(); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); - const queryParams = getQueryParamsFromQueryString( - location.search - ); const firewallFormEventOptions: LinodeCreateFormEventOptions = { - createType: queryParams.type ?? 'OS', + createType: createType ?? 'OS', headerName: createFirewallText, interaction: 'click', label: '', diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx index dc98f2359ac..f777e7d154d 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -195,10 +195,9 @@ describe('Images Landing Table', () => { await userEvent.click(actionMenu); await userEvent.click(getByText('Deploy to New Linode')); - expect(router.state.location.pathname).toBe('/linodes/create'); + expect(router.state.location.pathname).toBe('/linodes/create/images'); expect(router.state.location.search).toStrictEqual({ - type: 'Images', imageID: image.id, }); }); @@ -250,8 +249,8 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(, { - initialRoute: '/images' + const { getByText, queryByTestId } = renderWithTheme(, { + initialRoute: '/images', }); const loadingElement = queryByTestId(loadingTestId); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 1bde7299a6b..76856ace0d6 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -362,9 +362,8 @@ export const ImagesLanding = () => { const handleDeployNewLinode = (imageId: string) => { navigate({ - to: '/linodes/create', + to: '/linodes/create/images', search: { - type: 'Images', imageID: imageId, }, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx index 1238fc5c7a6..bceb76f7062 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx @@ -4,6 +4,7 @@ import React, { useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useGetLinodeCreateType } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType'; import { useFlags } from 'src/hooks/useFlags'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; @@ -13,7 +14,6 @@ import { ApiAwarenessModal } from './ApiAwarenessModal/ApiAwarenessModal'; import { getDoesEmployeeNeedToAssignFirewall, getLinodeCreatePayload, - useLinodeCreateQueryParams, } from './utilities'; import type { LinodeCreateFormValues } from './utilities'; @@ -23,8 +23,7 @@ interface ActionProps { } export const Actions = ({ isAlertsBetaMode }: ActionProps) => { - const { params } = useLinodeCreateQueryParams(); - + const createType = useGetLinodeCreateType(); const [isAPIAwarenessModalOpen, setIsAPIAwarenessModalOpen] = useState(false); const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); @@ -50,7 +49,7 @@ export const Actions = ({ isAlertsBetaMode }: ActionProps) => { 'create_linode', ]); - const isCloneMode = params.type === 'Clone Linode'; + const isCloneMode = createType === 'Clone Linode'; const isDisabled = isCloneMode ? !permissions.clone_linode : !accountPermissions.create_linode; @@ -66,7 +65,7 @@ export const Actions = ({ isAlertsBetaMode }: ActionProps) => { const onOpenAPIAwareness = async () => { sendApiAwarenessClickEvent('Button', 'View Code Snippets'); sendLinodeCreateFormInputEvent({ - createType: params.type ?? 'OS', + createType: createType ?? 'OS', interaction: 'click', label: 'View Code Snippets', }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/CurlTabPanel.tsx b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/CurlTabPanel.tsx index fc12b737311..3cfbcff7379 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/CurlTabPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/CurlTabPanel.tsx @@ -9,7 +9,7 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; import { generateCurlCommand } from 'src/utilities/codesnippets/generate-cURL'; -import { useLinodeCreateQueryParams } from '../utilities'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import type { LinodeCreateFormValues } from '../utilities'; import type { CreateLinodeRequest } from '@linode/api-v4/lib/linodes'; @@ -26,8 +26,8 @@ export const CurlTabPanel = ({ index, payLoad, title }: CurlTabPanelProps) => { const { getValues } = useFormContext(); const sourceLinodeID = getValues('linode.id'); - const { params } = useLinodeCreateQueryParams(); - const linodeCLIAction = params.type === 'Clone Linode' ? 'clone' : 'create'; + const createType = useGetLinodeCreateType(); + const linodeCLIAction = createType === 'Clone Linode' ? 'clone' : 'create'; const path = linodeCLIAction === 'create' ? '/linode/instances' diff --git a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/LinodeCLIPanel.tsx b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/LinodeCLIPanel.tsx index 049020065ee..54eb99581f1 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/LinodeCLIPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/LinodeCLIPanel.tsx @@ -8,7 +8,7 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; import { generateCLICommand } from 'src/utilities/codesnippets/generate-cli'; -import { useLinodeCreateQueryParams } from '../utilities'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import type { LinodeCreateFormValues } from '../utilities'; import type { CreateLinodeRequest } from '@linode/api-v4/lib/linodes'; @@ -24,13 +24,13 @@ export const LinodeCLIPanel = ({ payLoad, title, }: LinodeCLIPanelProps) => { - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); // @TODO - Linode Interfaces // DX support (CLI, integrations, sdks) for Linode Interfaces is not yet available. Remove this when it is. const showDXCodeSnippets = payLoad.interface_generation !== 'linode'; - const linodeCLIAction = params.type; + const linodeCLIAction = createType; const { getValues } = useFormContext(); const sourceLinodeID = getValues('linode.id'); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.test.tsx index 7be92eb06b1..5569ad4ad11 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.test.tsx @@ -84,9 +84,7 @@ describe('Linode Create Details', () => { const { queryByText } = renderWithThemeAndHookFormContext({ component:
, options: { - MemoryRouter: { - initialEntries: ['/linodes/create?type=Clone+Linode'], - }, + initialRoute: '/linodes/create/clone', }, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.tsx index 598c6923ff8..8694c0fea78 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.tsx @@ -6,7 +6,7 @@ import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; -import { useLinodeCreateQueryParams } from '../utilities'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import { PlacementGroupPanel } from './PlacementGroupPanel'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -15,7 +15,7 @@ export const Details = () => { const { control } = useFormContext(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const { permissions } = usePermissions('account', ['create_linode']); @@ -36,7 +36,7 @@ export const Details = () => { /> )} /> - {params.type !== 'Clone Linode' && ( + {createType !== 'Clone Linode' && ( { const regionId = useWatch({ name: 'region' }); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const placementGroupFormEventOptions: LinodeCreateFormEventOptions = { - createType: params.type ?? 'OS', + createType: createType ?? 'OS', headerName: 'Details', interaction: 'change', label: 'Placement Group', diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx index 21b2b56e3c1..63afa31726b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx @@ -14,7 +14,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; -import { useLinodeCreateQueryParams } from './utilities'; +import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType'; import type { CreateLinodeRequest } from '@linode/api-v4'; import type { LinodeCreateFormEventOptions } from 'src/utilities/analytics/types'; @@ -30,7 +30,7 @@ export const Firewall = () => { const flags = useFlags(); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); const secureVMFirewallBanner = @@ -42,7 +42,7 @@ export const Firewall = () => { ]); const firewallFormEventOptions: LinodeCreateFormEventOptions = { - createType: params.type ?? 'OS', + createType: createType ?? 'OS', headerName: 'Firewall', interaction: 'click', label: 'Firewall', @@ -58,7 +58,7 @@ export const Firewall = () => { sendLinodeCreateFormInputEvent({ - createType: params.type ?? 'OS', + createType: createType ?? 'OS', headerName: 'Firewall', interaction: 'click', label: 'Learn more', diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx index 0f5395b3483..c55ae9c1868 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx @@ -9,7 +9,7 @@ import { sendLinodeCreateFlowDocsClickEvent } from 'src/utilities/analytics/cust import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { extendType } from 'src/utilities/extendType'; -import { useLinodeCreateQueryParams } from './utilities'; +import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType'; import type { LinodeCreateFormValues } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -24,7 +24,7 @@ export const Plan = () => { const { data: regions } = useRegionsQuery(); const { data: types } = useAllTypes(); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const { permissions } = usePermissions('account', ['create_linode']); @@ -39,7 +39,7 @@ export const Plan = () => { onClick={() => { sendLinodeCreateFlowDocsClickEvent('Choosing a Plan'); sendLinodeCreateFormInputEvent({ - createType: params.type ?? 'OS', + createType: createType ?? 'OS', headerName: 'Linode Plan', interaction: 'click', label: 'Choosing a Plan', diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Region.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Region.test.tsx index 293e8773ec9..282f274d46a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Region.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Region.test.tsx @@ -116,6 +116,9 @@ describe('Region', () => { }); it('renders a warning if the user selects a region with different pricing when cloning', async () => { + queryMocks.useLocation.mockReturnValue({ + pathname: '/linodes/create/clone', + }); const regionA = regionFactory.build({ capabilities: ['Linodes'] }); const regionB = regionFactory.build({ capabilities: ['Linodes'] }); @@ -160,6 +163,9 @@ describe('Region', () => { }); it('renders a warning if the user tries to clone across datacenters', async () => { + queryMocks.useLocation.mockReturnValue({ + pathname: '/linodes/create/clone', + }); const regionA = regionFactory.build({ capabilities: ['Linodes'] }); const regionB = regionFactory.build({ capabilities: ['Linodes'] }); @@ -196,7 +202,7 @@ describe('Region', () => { ).toBeVisible(); }); - //TODO: this is an expected failure until we fix the filtering + // TODO: this is an expected failure until we fix the filtering it.skip('should disable distributed regions if the selected image does not have the `distributed-sites` capability', async () => { const image = imageFactory.build({ capabilities: [] }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx index e3846ffbc41..53f436e21e3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx @@ -25,11 +25,9 @@ import { import { isLinodeTypeDifferentPriceInSelectedRegion } from 'src/utilities/pricing/linodes'; import { getDisabledRegions } from './Region.utils'; +import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType'; import { TwoStepRegion } from './TwoStepRegion'; -import { - getGeneratedLinodeLabel, - useLinodeCreateQueryParams, -} from './utilities'; +import { getGeneratedLinodeLabel } from './utilities'; import type { LinodeCreateFormValues } from './utilities'; import type { Region as RegionType } from '@linode/api-v4'; @@ -41,7 +39,7 @@ export const Region = React.memo(() => { const flags = useFlags(); const queryClient = useQueryClient(); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const { control, @@ -82,7 +80,7 @@ export const Region = React.memo(() => { ); const showTwoStepRegion = - isGeckoLAEnabled && isDistributedRegionSupported(params.type ?? 'OS'); + isGeckoLAEnabled && isDistributedRegionSupported(createType ?? 'OS'); const onChange = async (region: RegionType) => { const values = getValues(); @@ -167,7 +165,7 @@ export const Region = React.memo(() => { // Auto-generate the Linode label because the region is included in the generated label const label = await getGeneratedLinodeLabel({ queryClient, - tab: params.type ?? 'OS', + tab: createType ?? 'OS', values: { ...values, region: region.id }, }); @@ -176,17 +174,17 @@ export const Region = React.memo(() => { // Begin tracking the Linode Create form. sendLinodeCreateFormStartEvent({ - createType: params.type ?? 'OS', + createType: createType ?? 'OS', }); }; const showCrossDataCenterCloneWarning = - params.type === 'Clone Linode' && + createType === 'Clone Linode' && selectedLinode && selectedLinode.region !== field.value; const showClonePriceWarning = - params.type === 'Clone Linode' && + createType === 'Clone Linode' && isLinodeTypeDifferentPriceInSelectedRegion({ regionA: selectedLinode?.region, regionB: field.value, @@ -194,8 +192,7 @@ export const Region = React.memo(() => { }); const hideDistributedRegions = - !flags.gecko2?.enabled || - !isDistributedRegionSupported(params.type ?? 'OS'); + !flags.gecko2?.enabled || !isDistributedRegionSupported(createType ?? 'OS'); const disabledRegions = getDisabledRegions({ regions: regions ?? [], @@ -211,9 +208,7 @@ export const Region = React.memo(() => { onChange={onChange} regionFilter={ // We don't want the Image Service Gen2 work to abide by Gecko feature flags - hideDistributedRegions && params.type !== 'Images' - ? 'core' - : undefined + hideDistributedRegions && createType !== 'Images' ? 'core' : undefined } textFieldProps={{ onBlur: field.onBlur }} value={field.value} @@ -230,7 +225,7 @@ export const Region = React.memo(() => { label={DOCS_LINK_LABEL_DC_PRICING} onClick={() => sendLinodeCreateFormInputEvent({ - createType: params.type ?? 'OS', + createType: createType ?? 'OS', headerName: 'Region', interaction: 'click', label: DOCS_LINK_LABEL_DC_PRICING, @@ -257,9 +252,7 @@ export const Region = React.memo(() => { onChange={(e, region) => onChange(region)} regionFilter={ // We don't want the Image Service Gen2 work to abide by Gecko feature flags - hideDistributedRegions && params.type !== 'Images' - ? 'core' - : undefined + hideDistributedRegions && createType !== 'Images' ? 'core' : undefined } regions={regions ?? []} textFieldProps={{ onBlur: field.onBlur }} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Backups/backupsLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Backups/backupsLazyRoute.ts new file mode 100644 index 00000000000..a097f93c5b2 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Backups/backupsLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { Backups } from './Backups'; + +export const backupsLazyRoute = createLazyRoute('/linodes/create/backups')({ + component: Backups, +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Clone/cloneLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Clone/cloneLazyRoute.ts new file mode 100644 index 00000000000..eccf49dd75a --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Clone/cloneLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { Clone } from './Clone'; + +export const cloneLazyRoute = createLazyRoute('/linodes/create/clone')({ + component: Clone, +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/marketPlaceLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/marketPlaceLazyRoute.ts new file mode 100644 index 00000000000..9ad7a17e79c --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/marketPlaceLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { Marketplace } from './Marketplace'; + +export const marketPlaceLazyRoute = createLazyRoute( + '/linodes/create/marketplace' +)({ + component: Marketplace, +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptImages.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptImages.tsx index 9fff703f417..c5fda5f1250 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptImages.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptImages.tsx @@ -5,7 +5,7 @@ import { Controller, useWatch } from 'react-hook-form'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; -import { useLinodeCreateQueryParams } from '../../utilities'; +import { useGetLinodeCreateType } from '../utils/useGetLinodeCreateType'; import type { CreateLinodeRequest, Image } from '@linode/api-v4'; @@ -14,7 +14,7 @@ export const StackScriptImages = () => { name: 'stackscript_id', }); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const hasStackScriptSelected = stackscriptId !== null && stackscriptId !== undefined; @@ -34,7 +34,7 @@ export const StackScriptImages = () => { const helperText = !hasStackScriptSelected ? `Select ${ - params.type === 'One-Click' ? 'an app' : 'a StackScript' + createType === 'One-Click' ? 'an app' : 'a StackScript' } to see compatible Images.` : undefined; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelection.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelection.tsx index e34a1cfd045..0bd4d9a9344 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelection.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelection.tsx @@ -1,4 +1,5 @@ import { Notice, Paper, Typography } from '@linode/ui'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; import { useFormContext } from 'react-hook-form'; @@ -8,21 +9,26 @@ import { TabList } from 'src/components/Tabs/TabList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; -import { useLinodeCreateQueryParams } from '../../utilities'; import { StackScriptSelectionList } from './StackScriptSelectionList'; import { getStackScriptTabIndex, tabs } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; export const StackScriptSelection = () => { - const { params, updateParams } = useLinodeCreateQueryParams(); + const navigate = useNavigate(); + const search = useSearch({ + from: '/linodes/create/stackscripts', + }); const { formState, reset } = useFormContext(); const onTabChange = (index: number) => { // Update the "subtype" query param. (This switches between "Community" and "Account" tabs). - updateParams({ - stackScriptID: undefined, - subtype: tabs[index], + navigate({ + to: `/linodes/create/stackscripts`, + search: { + subtype: tabs[index], + stackScriptID: undefined, + }, }); // Reset the selected image, the selected StackScript, and the StackScript data when changing tabs. reset((prev) => ({ @@ -43,7 +49,7 @@ export const StackScriptSelection = () => { )} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.test.tsx index d5849204891..acf8037383a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.test.tsx @@ -70,12 +70,7 @@ describe('StackScriptSelectionList', () => { const { findByLabelText, getByText } = renderWithThemeAndHookFormContext({ component: , options: { - initialRoute: '/linodes/create', - MemoryRouter: { - initialEntries: [ - '/linodes/create?type=StackScripts&subtype=Account&stackScriptID=921609', - ], - }, + initialRoute: '/linodes/create/stackscripts', }, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx index 1cb4c92700c..eacbd01a048 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -15,7 +15,7 @@ import { TooltipIcon, } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; -import { useLocation } from '@tanstack/react-router'; +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'; import React, { useState } from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { Waypoint } from 'react-waypoint'; @@ -33,10 +33,7 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { StackScriptSearchHelperText } from 'src/features/StackScripts/Partials/StackScriptSearchHelperText'; import { useOrderV2 } from 'src/hooks/useOrderV2'; -import { - getGeneratedLinodeLabel, - useLinodeCreateQueryParams, -} from '../../utilities'; +import { getGeneratedLinodeLabel } from '../../utilities'; import { StackScriptDetailsDialog } from './StackScriptDetailsDialog'; import { StackScriptSelectionRow } from './StackScriptSelectionRow'; import { getDefaultUDFData } from './UserDefinedFields/utilities'; @@ -54,6 +51,10 @@ interface Props { export const StackScriptSelectionList = ({ type }: Props) => { const [query, setQuery] = useState(); + const search = useSearch({ + strict: false, + }); + const navigate = useNavigate(); const location = useLocation(); const queryClient = useQueryClient(); @@ -65,8 +66,10 @@ export const StackScriptSelectionList = ({ type }: Props) => { orderBy: 'deployments_total', }, from: location.pathname.includes('/linodes/create') - ? '/linodes/create' - : '/linodes/$linodeId', + ? '/linodes/create/stackscripts' + : location.pathname === '/linodes' + ? '/linodes' + : '/linodes/$linodeId', }, preferenceKey: 'linode-clone-stackscripts', }); @@ -87,13 +90,11 @@ export const StackScriptSelectionList = ({ type }: Props) => { const [selectedStackScriptId, setSelectedStackScriptId] = useState(); - const { params, updateParams } = useLinodeCreateQueryParams(); - - const hasPreselectedStackScript = Boolean(params.stackScriptID); + const hasPreselectedStackScript = Boolean(search.stackScriptID); const { data: stackscript, isLoading: isSelectedStackScriptLoading } = useStackScriptQuery( - params.stackScriptID ? Number(params.stackScriptID) : -1, + search.stackScriptID ? Number(search.stackScriptID) : -1, hasPreselectedStackScript ); @@ -156,7 +157,12 @@ export const StackScriptSelectionList = ({ type }: Props) => { onClick={() => { field.onChange(null); setValue('image', null); - updateParams({ stackScriptID: undefined }); + navigate({ + to: `/linodes/create/stackscripts`, + search: { + stackScriptID: undefined, + }, + }); }} > Choose Another StackScript diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/stackScriptsLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/stackScriptsLazyRoute.ts new file mode 100644 index 00000000000..ffbc0dae948 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/stackScriptsLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { StackScripts } from './StackScripts'; + +export const stackScriptsLazyRoute = createLazyRoute( + '/linodes/create/stackscripts' +)({ + component: StackScripts, +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/imagesLazyRoutes.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/imagesLazyRoutes.ts new file mode 100644 index 00000000000..198f2d8310b --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/imagesLazyRoutes.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { Images } from './Images'; + +export const imagesLazyRoute = createLazyRoute('/linodes/create/images')({ + component: Images, +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/operatingSystemsLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/operatingSystemsLazyRoute.ts new file mode 100644 index 00000000000..d2bbf8e5d1f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/operatingSystemsLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { OperatingSystems } from 'src/features/Linodes/LinodeCreate/Tabs/OperatingSystems'; + +export const operatingSystemsLazyRoute = createLazyRoute('/linodes/create/os')({ + component: OperatingSystems, +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts new file mode 100644 index 00000000000..aafbbbe43a3 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts @@ -0,0 +1,47 @@ +import { useLocation } from '@tanstack/react-router'; + +import type { LinodeCreateType } from '@linode/utilities'; +import type { LinkProps } from '@tanstack/react-router'; + +type LinodeCreatePathSegments = + | 'backups' + | 'clone' + | 'images' + | 'marketplace' + | 'os' + | 'stackscripts'; + +export const linodesCreateTypesMap = new Map< + LinodeCreateType, + LinodeCreatePathSegments +>([ + ['Backups', 'backups'], + ['Clone Linode', 'clone'], + ['Images', 'images'], + ['One-Click', 'marketplace'], + ['OS', 'os'], + ['StackScripts', 'stackscripts'], +]); + +export const linodesCreateTypes = Array.from(linodesCreateTypesMap.keys()); + +export const useGetLinodeCreateType = () => { + const { pathname } = useLocation() as { pathname: LinkProps['to'] }; + + switch (pathname) { + case '/linodes/create/backups': + return 'Backups'; + case '/linodes/create/clone': + return 'Clone Linode'; + case '/linodes/create/images': + return 'Images'; + case '/linodes/create/marketplace': + return 'One-Click'; + case '/linodes/create/os': + return 'OS'; + case '/linodes/create/stackscripts': + return 'StackScripts'; + default: + return 'OS'; + } +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx index c198b855511..1b7eaa011c7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx @@ -16,7 +16,7 @@ import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAn import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; -import { useLinodeCreateQueryParams } from './utilities'; +import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType'; import type { Region as RegionType } from '@linode/api-v4'; import type { @@ -73,7 +73,7 @@ export const TwoStepRegion = (props: CombinedProps) => { React.useState('distributed'); const { data: regions } = useRegionsQuery(); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const flags = useFlags(); const { isGeckoLAEnabled } = useIsGeckoEnabled( flags.gecko2?.enabled, @@ -89,7 +89,7 @@ export const TwoStepRegion = (props: CombinedProps) => { label={DOCS_LINK_LABEL_DC_PRICING} onClick={() => sendLinodeCreateFormInputEvent({ - createType: params.type ?? 'OS', + createType: createType ?? 'OS', headerName: 'Region', interaction: 'click', label: DOCS_LINK_LABEL_DC_PRICING, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.test.tsx index 3c7b7722fc0..2fc80320c42 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.test.tsx @@ -5,7 +5,6 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { UserDataHeading } from './UserDataHeading'; const queryMocks = vi.hoisted(() => ({ - useSearch: vi.fn(), useParams: vi.fn(), })); @@ -13,26 +12,20 @@ vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { ...actual, - useSearch: queryMocks.useSearch, useParams: queryMocks.useParams, }; }); describe('UserDataHeading', () => { beforeEach(() => { - queryMocks.useSearch.mockReturnValue({}); queryMocks.useParams.mockReturnValue({ linodeId: '123', }); }); it('should display a warning in the header for cloning', async () => { - queryMocks.useSearch.mockReturnValue({ - type: 'Clone Linode', - }); - const { getByText } = renderWithTheme(, { - initialRoute: '/linodes/create', + initialRoute: '/linodes/create/clone', }); expect( @@ -43,12 +36,8 @@ describe('UserDataHeading', () => { }); it('should display a warning in the header for creating from a Linode backup', async () => { - queryMocks.useSearch.mockReturnValue({ - type: 'Backups', - }); - const { getByText } = renderWithTheme(, { - initialRoute: '/linodes/create', + initialRoute: '/linodes/create/backups', }); expect( diff --git a/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.tsx b/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.tsx index 6b099af27cd..c902a8f54ac 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.tsx @@ -3,12 +3,12 @@ import React from 'react'; import { Link } from 'src/components/Link'; -import { useLinodeCreateQueryParams } from '../utilities'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import type { LinodeCreateType } from '@linode/utilities'; export const UserDataHeading = () => { - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const warningMessageMap: Record = { Backups: @@ -21,7 +21,7 @@ export const UserDataHeading = () => { StackScripts: null, }; - const warningMessage = params.type ? warningMessageMap[params.type] : null; + const warningMessage = createType ? warningMessageMap[createType] : null; return ( diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.tsx index 7bb7f2c8a95..6bb26a5a968 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.tsx @@ -14,14 +14,14 @@ import { VLANSelect } from 'src/components/VLANSelect'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { VLANAvailabilityNotice } from '../Networking/VLANAvailabilityNotice'; -import { useLinodeCreateQueryParams } from '../utilities'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import type { CreateLinodeRequest } from '@linode/api-v4'; export const VLAN = () => { const { control } = useFormContext(); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const { permissions } = usePermissions('account', ['create_linode']); @@ -32,7 +32,7 @@ export const VLAN = () => { const regionSupportsVLANs = selectedRegion?.capabilities.includes('Vlans') ?? false; - const isCreatingFromBackup = params.type === 'Backups'; + const isCreatingFromBackup = createType === 'Backups'; const disabled = !imageId || diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx index 2f9a094f50c..fb40be0ac3f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx @@ -27,7 +27,7 @@ import { VPCCreateDrawer } from 'src/features/VPCs/VPCCreateDrawer/VPCCreateDraw import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { VPCAvailabilityNotice } from '../Networking/VPCAvailabilityNotice'; -import { useLinodeCreateQueryParams } from '../utilities'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import { VPCRanges } from './VPCRanges'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -70,10 +70,10 @@ export const VPC = () => { ? 'Allow Linode to communicate in an isolated environment.' : 'Assign this Linode to an existing VPC.'; - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const vpcFormEventOptions: LinodeCreateFormEventOptions = { - createType: params.type ?? 'OS', + createType: createType ?? 'OS', headerName: 'VPC', interaction: 'click', label: 'VPC', diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx index a76a67edf75..4d402c7b206 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx @@ -29,7 +29,7 @@ describe('Linode Create', () => { it('Should not render VLANs when cloning', () => { const { queryByText } = renderWithTheme(, { - MemoryRouter: { initialEntries: ['/linodes/create?type=Clone+Linode'] }, + MemoryRouter: { initialEntries: ['/linodes/create/clone'] }, }); expect(queryByText('VLAN')).toBeNull(); @@ -37,7 +37,7 @@ describe('Linode Create', () => { it('Should not render access panel items when cloning', () => { const { queryByText } = renderWithTheme(, { - MemoryRouter: { initialEntries: ['/linodes/create?type=Clone+Linode'] }, + MemoryRouter: { initialEntries: ['/linodes/create/clone'] }, }); expect(queryByText('Root Password')).toBeNull(); @@ -46,7 +46,7 @@ describe('Linode Create', () => { it('Should not render the region select when creating from a backup', () => { const { queryByText } = renderWithTheme(, { - MemoryRouter: { initialEntries: ['/linodes/create?type=Backups'] }, + MemoryRouter: { initialEntries: ['/linodes/create/backups'] }, }); expect(queryByText('Region')).toBeNull(); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx index 2b3321601a2..0e114dae0f5 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx @@ -8,7 +8,12 @@ import { import { CircleProgress, Notice, Stack } from '@linode/ui'; import { scrollErrorIntoView } from '@linode/utilities'; import { useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; +import { + Outlet, + useLocation, + useNavigate, + useSearch, +} from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import React, { useEffect, useRef } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; @@ -16,18 +21,18 @@ import type { SubmitHandler } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; -import { Tab } from 'src/components/Tabs/Tab'; -import { TabList } from 'src/components/Tabs/TabList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { getRestrictedResourceText, useVMHostMaintenanceEnabled, } from 'src/features/Account/utils'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useGetLinodeCreateType } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType'; import { useFlags } from 'src/hooks/useFlags'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; +import { useTabs } from 'src/hooks/useTabs'; import { sendLinodeCreateFormInputEvent, sendLinodeCreateFormSubmitEvent, @@ -52,21 +57,12 @@ import { getLinodeCreateResolver } from './resolvers'; import { Security } from './Security'; import { SMTP } from './SMTP'; import { Summary } from './Summary/Summary'; -import { Backups } from './Tabs/Backups/Backups'; -import { Clone } from './Tabs/Clone/Clone'; -import { Images } from './Tabs/Images'; -import { Marketplace } from './Tabs/Marketplace/Marketplace'; -import { OperatingSystems } from './Tabs/OperatingSystems'; -import { StackScripts } from './Tabs/StackScripts/StackScripts'; import { UserData } from './UserData/UserData'; import { captureLinodeCreateAnalyticsEvent, defaultValues, getLinodeCreatePayload, - getTabIndex, - tabs, useHandleLinodeCreateAnalyticsFormError, - useLinodeCreateQueryParams, } from './utilities'; import { VLAN } from './VLAN/VLAN'; import { VPC } from './VPC/VPC'; @@ -77,12 +73,16 @@ import type { } from './utilities'; export const LinodeCreate = () => { - const { params, setParams } = useLinodeCreateQueryParams(); + const location = useLocation(); + const search = useSearch({ + from: '/linodes/create', + }); const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); const { data: profile } = useProfile(); const { isLinodeCloneFirewallEnabled } = useIsLinodeCloneFirewallEnabled(); const { isVMHostMaintenanceEnabled } = useVMHostMaintenanceEnabled(); + const linodeCreateType = useGetLinodeCreateType(); const { aclpBetaServices } = useFlags(); @@ -96,12 +96,12 @@ export const LinodeCreate = () => { const form = useForm({ context: { isLinodeInterfacesEnabled, profile, secureVMNoticesEnabled }, defaultValues: () => - defaultValues(params, queryClient, { + defaultValues(linodeCreateType, search, queryClient, { isLinodeInterfacesEnabled, isVMHostMaintenanceEnabled, }), mode: 'onBlur', - resolver: getLinodeCreateResolver(params.type, queryClient), + resolver: getLinodeCreateResolver(linodeCreateType, queryClient), shouldFocusError: false, // We handle this ourselves with `scrollErrorIntoView` }); @@ -111,23 +111,43 @@ export const LinodeCreate = () => { const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { handleLinodeCreateAnalyticsFormError } = - useHandleLinodeCreateAnalyticsFormError(params.type ?? 'OS'); - - const currentTabIndex = getTabIndex(params.type); + useHandleLinodeCreateAnalyticsFormError(linodeCreateType ?? 'OS'); const { permissions } = usePermissions('account', ['create_linode']); - const onTabChange = (index: number) => { - if (index !== currentTabIndex) { - const newTab = tabs[index]; + const { tabs, handleTabChange, tabIndex } = useTabs([ + { + title: 'OS', + to: '/linodes/create/os', + }, + { + title: 'Marketplace', + to: '/linodes/create/marketplace', + }, + { + title: 'StackScripts', + to: '/linodes/create/stackscripts', + }, + { + title: 'Images', + to: '/linodes/create/images', + }, + { + title: 'Backups', + to: '/linodes/create/backups', + }, + { + title: 'Clone Linode', + to: '/linodes/create/clone', + }, + ]); - const newParams = { type: newTab }; - - // Update tab "type" query param. (This changes the selected tab) - setParams(newParams); + const onTabChange = (index: number) => { + handleTabChange(index); + if (index !== tabIndex) { // Get the default values for the new tab and reset the form - defaultValues(newParams, queryClient, { + defaultValues(linodeCreateType, search, queryClient, { isLinodeInterfacesEnabled, isVMHostMaintenanceEnabled, }).then(form.reset); @@ -143,7 +163,7 @@ export const LinodeCreate = () => { try { const linode = - params.type === 'Clone Linode' + linodeCreateType === 'Clone Linode' ? await cloneLinode({ sourceLinodeId: values.linode?.id ?? -1, ...payload, @@ -163,12 +183,12 @@ export const LinodeCreate = () => { captureLinodeCreateAnalyticsEvent({ queryClient, secureVMNoticesEnabled, - type: params.type ?? 'OS', + type: linodeCreateType ?? 'OS', values, }); sendLinodeCreateFormSubmitEvent({ - createType: params.type ?? 'OS', + createType: linodeCreateType ?? 'OS', }); if (values.hasSignedEUAgreement) { @@ -208,15 +228,24 @@ export const LinodeCreate = () => { return ; } + if (location.pathname === '/linodes/create') { + navigate({ + to: '/linodes/create/os', + }); + } + return ( sendLinodeCreateFormInputEvent({ - createType: params.type ?? 'OS', + createType: linodeCreateType ?? 'OS', interaction: 'click', label: 'Getting Started', }) @@ -227,15 +256,8 @@ export const LinodeCreate = () => {
- - - OS - Marketplace - StackScripts - Images - Backups - Clone Linode - + + {!permissions.create_linode && ( { /> )} - - - - - - - - - - - - - - - - - - +
- {params.type !== 'Clone Linode' && } - {!isLinodeInterfacesEnabled && params.type !== 'Clone Linode' && ( - - )} + {linodeCreateType !== 'Clone Linode' && } {!isLinodeInterfacesEnabled && - (params.type !== 'Clone Linode' || + linodeCreateType !== 'Clone Linode' && } + {!isLinodeInterfacesEnabled && + (linodeCreateType !== 'Clone Linode' || isLinodeCloneFirewallEnabled) && } - {!isLinodeInterfacesEnabled && params.type !== 'Clone Linode' && ( - - )} + {!isLinodeInterfacesEnabled && + linodeCreateType !== 'Clone Linode' && } - {isLinodeInterfacesEnabled && params.type !== 'Clone Linode' && ( + {isLinodeInterfacesEnabled && linodeCreateType !== 'Clone Linode' && ( )} { const { enablePowerOff } = props; + const search = useSearch({ + from: '/linodes/create', + }); const matchesMdUp = useMediaQuery((theme: Theme) => theme.breakpoints.up('md') @@ -68,16 +73,18 @@ export const LinodeSelectTable = (props: Props) => { } ); - const { params } = useLinodeCreateQueryParams(); + const createType = useGetLinodeCreateType(); const [query, setQuery] = useState( - params.linodeID ? `id = ${params.linodeID}` : '' + search.linodeID ? `id = ${search.linodeID}` : '' ); const [linodeToPowerOff, setLinodeToPowerOff] = useState(); + const createPath = linodesCreateTypesMap.get(createType) ?? 'os'; + const pagination = usePaginationV2({ - currentRoute: '/linodes/create', + currentRoute: `/linodes/create/${createPath}`, initialPage: 1, preferenceKey: 'linode-clone-select-table', }); @@ -88,7 +95,7 @@ export const LinodeSelectTable = (props: Props) => { order: 'asc', orderBy: 'label', }, - from: '/linodes/create', + from: `/linodes/create/${createPath}`, }, preferenceKey: 'linode-clone-select-table', }); @@ -123,7 +130,7 @@ export const LinodeSelectTable = (props: Props) => { 'label', await getGeneratedLinodeLabel({ queryClient, - tab: params.type, + tab: createType, values: getValues(), }) ); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx index ca168f3c1f3..10b3f74db37 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx @@ -14,7 +14,6 @@ import { getIsValidLinodeLabelCharacter, getLinodeCreatePayload, getLinodeLabelFromLabelParts, - getTabIndex, } from './utilities'; import type { LinodeCreateFormValues } from './utilities'; @@ -37,19 +36,6 @@ packages: const base64UserData = 'I2Nsb3VkLWNvbmZpZwpwYWNrYWdlX3VwZGF0ZTogdHJ1ZQpwYWNrYWdlX3VwZ3JhZGU6IHRydWUKcGFja2FnZXM6Ci0gbmdpbngKLSBteXNxbC1zZXJ2ZXIK'; -describe('getTabIndex', () => { - it('should return 0 when there is no value specifying the tab', () => { - expect(getTabIndex(undefined)).toBe(0); - }); - it('should return 0 when the value is not a valid tab', () => { - // @ts-expect-error We are intentionally passing an invalid value. - expect(getTabIndex('fake tab')).toBe(0); - }); - it('should return the correct index when the value is a valid tab', () => { - expect(getTabIndex('Images')).toBe(3); - }); -}); - describe('getLinodeCreatePayload', () => { it('should return a basic payload', () => { const values = createLinodeRequestFactory.build() as LinodeCreateFormValues; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 1a882a7568c..1a8158ab8d7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -7,7 +7,6 @@ import { } from '@linode/queries'; import { omitProps } from '@linode/ui'; import { isNotNullOrUndefined, utoa } from '@linode/utilities'; -import { useNavigate, useSearch } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; import { useCallback } from 'react'; import type { FieldErrors } from 'react-hook-form'; @@ -24,7 +23,6 @@ import { import { getDefaultUDFData } from './Tabs/StackScripts/UserDefinedFields/utilities'; import type { LinodeCreateInterface } from './Networking/utilities'; -import type { StackScriptTabType } from './Tabs/StackScripts/utilities'; import type { AccountSettings, CreateLinodeRequest, @@ -51,85 +49,6 @@ interface LinodeCreatePayloadOptions { isShowingNewNetworkingUI: boolean; } -/** - * Hook that allows you to read and manage Linode Create flow query params. - * - * We have this because react-router-dom's query strings are not typesafe. - */ -export const useLinodeCreateQueryParams = () => { - const search = useSearch({ strict: false }); - const navigate = useNavigate(); - - /** - * Updates query params - */ - const updateParams = (params: Partial) => { - navigate({ - to: '/linodes/create', - search: (prev) => ({ - ...prev, - ...params, - }), - }); - }; - - /** - * Replaces query params with the provided values - */ - const setParams = (params: Partial) => { - navigate({ - to: '/linodes/create', - search: params, - }); - }; - - const params = getParsedLinodeCreateQueryParams(search); - - return { params, setParams, updateParams }; -}; - -const getParsedLinodeCreateQueryParams = ( - rawParams: LinodeCreateSearchParams -): LinodeCreateSearchParams => { - return { - appID: rawParams.appID ?? undefined, - backupID: rawParams.backupID ?? undefined, - imageID: rawParams.imageID ?? undefined, - linodeID: rawParams.linodeID ?? undefined, - stackScriptID: rawParams.stackScriptID ?? undefined, - subtype: rawParams.subtype as StackScriptTabType | undefined, - type: rawParams.type as LinodeCreateType | undefined, - }; -}; - -/** - * Given the Linode Create flow 'type' from query params, this function - * returns the tab's index. This allows us to control the tabs via the query string. - */ -export const getTabIndex = (tabType: LinodeCreateType | undefined) => { - if (!tabType) { - return 0; - } - - const currentTabIndex = tabs.indexOf(tabType); - - // Users might type an invalid tab name into query params. Fallback to the first tab. - if (currentTabIndex === -1) { - return 0; - } - - return currentTabIndex; -}; - -export const tabs: LinodeCreateType[] = [ - 'OS', - 'One-Click', - 'StackScripts', - 'Images', - 'Backups', - 'Clone Linode', -]; - /** * Performs some transformations to the Linode Create form data so that the data * is in the correct format for the API. Intended to be used in the "onSubmit" when creating a Linode. @@ -329,6 +248,7 @@ export interface LinodeCreateFormContext { * The default values are dependent on the query params present. */ export const defaultValues = async ( + createType: LinodeCreateType, params: LinodeCreateSearchParams, queryClient: QueryClient, flags: { @@ -378,7 +298,7 @@ export const defaultValues = async ( ); // Don't set the interface generation when cloning. The API can figure that out - if (flags.isLinodeInterfacesEnabled && params.type !== 'Clone Linode') { + if (flags.isLinodeInterfacesEnabled && createType !== 'Clone Linode') { interfaceGeneration = getDefaultInterfaceGenerationFromAccountSetting( accountSettings.interfaces_for_new_linodes ); @@ -417,7 +337,7 @@ export const defaultValues = async ( firewallSettings && firewallSettings.default_firewall_ids.linode ? firewallSettings.default_firewall_ids.linode : undefined, - image: getDefaultImageId(params), + image: getDefaultImageId(createType, params), interface_generation: interfaceGeneration, interfaces: defaultInterfaces, linode, @@ -435,7 +355,7 @@ export const defaultValues = async ( try { values.label = await getGeneratedLinodeLabel({ queryClient, - tab: params.type, + tab: createType, values, }); } catch (error) { @@ -445,20 +365,23 @@ export const defaultValues = async ( return values; }; -const getDefaultImageId = (params: LinodeCreateSearchParams) => { +const getDefaultImageId = ( + createType: LinodeCreateType, + params: LinodeCreateSearchParams +) => { // You can't have an Image selected when deploying from a backup. - if (params.type === 'Backups') { + if (createType === 'Backups') { return null; } // Always default debian for the OS tab. - if (!params.type || params.type === 'OS') { + if (!createType || createType === 'OS') { return DEFAULT_OS; } // If the user is deep linked to the Images tab with a preselected image, // default to it. - if (params.type === 'Images' && params.imageID) { + if (createType === 'Images' && params.imageID) { return params.imageID; } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx index 00bfb36bef0..142934fbb0d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx @@ -79,10 +79,9 @@ export const LinodeBackups = () => { const handleDeploy = (backup: LinodeBackup) => { navigate({ - to: '/linodes/create', + to: '/linodes/create/backups', search: (prev) => ({ ...prev, - type: 'Backups', backupID: backup.id, linodeID: linode?.id, typeID: linode?.type, diff --git a/packages/manager/src/features/Linodes/LinodesLanding/AppsSection.tsx b/packages/manager/src/features/Linodes/LinodesLanding/AppsSection.tsx index fa77f71b99c..e918ebbc964 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/AppsSection.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/AppsSection.tsx @@ -14,27 +14,27 @@ const linkGAEventTemplate = { const appsLinkData = [ { text: 'Wordpress', - to: '/linodes/create?type=One-Click&appID=401697&utm_source=marketplace&utm_medium=website&utm_campaign=WordPress', + to: '/linodes/create/marketplace?appID=401697&utm_source=marketplace&utm_medium=website&utm_campaign=WordPress', }, { text: 'Harbor', - to: '/linodes/create?type=One-Click&appID=912262&utm_source=marketplace&utm_medium=website&utm_campaign=Harbor', + to: '/linodes/create/marketplace?appID=912262&utm_source=marketplace&utm_medium=website&utm_campaign=Harbor', }, { text: 'cPanel', - to: '/linodes/create?type=One-Click&appID=595742&utm_source=marketplace&utm_medium=website&utm_campaign=cPanel', + to: '/linodes/create/marketplace?appID=595742&utm_source=marketplace&utm_medium=website&utm_campaign=cPanel', }, { text: 'Postgres Cluster', - to: '/linodes/create?type=One-Click&appID=1068726&utm_source=marketplace&utm_medium=website&utm_campaign=Postgres_Cluster', + to: '/linodes/create/marketplace?appID=1068726&utm_source=marketplace&utm_medium=website&utm_campaign=Postgres_Cluster', }, { text: 'Prometheus & Grafana', - to: '/linodes/create?type=One-Click&appID=985364&utm_source=marketplace&utm_medium=website&utm_campaign=Prometheus_Grafana', + to: '/linodes/create/marketplace?appID=985364&utm_source=marketplace&utm_medium=website&utm_campaign=Prometheus_Grafana', }, { text: 'Kali', - to: '/linodes/create?type=One-Click&appID=1017300&utm_source=marketplace&utm_medium=website&utm_campaign=Kali', + to: '/linodes/create/marketplace?appID=1017300&utm_source=marketplace&utm_medium=website&utm_campaign=Kali', }, ]; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx index 0d08e14c3d0..1c3b5c2b35c 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx @@ -167,7 +167,6 @@ describe('LinodeActionMenu', () => { [] ); expect(result).toMatchObject({ - type: 'Clone Linode', linodeID: 1, }); }); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx index d20fa1f8c65..2e84236ca8b 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx @@ -150,7 +150,7 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { onClick: () => { sendLinodeActionMenuItemEvent('Clone'); navigate({ - to: '/linodes/create', + to: '/linodes/create/clone', search: buildQueryStringForLinodeClone( linodeId, linodeRegion, diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils.ts b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils.ts index 149696a2135..c7c67c95f62 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils.ts +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils.ts @@ -13,7 +13,6 @@ export const buildQueryStringForLinodeClone = ( const params: Record = { linodeID: linodeId, regionID: linodeRegionId, - type: 'Clone Linode', }; // If the type of this Linode is a valid (current) type, use it in the QS diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLandingEmptyState.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLandingEmptyState.tsx index 17b4f50ea9c..9018eb8c2b8 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLandingEmptyState.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLandingEmptyState.tsx @@ -60,7 +60,7 @@ export const LinodesLandingEmptyState = () => { linkAnalyticsEvent, APPS_MORE_LINKS_TEXT )} - to="/linodes/create?type=One-Click" + to="/linodes/create/marketplace" {...props} > {APPS_MORE_LINKS_TEXT} diff --git a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptActionMenu.tsx b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptActionMenu.tsx index 60f137c6c23..3ec7dcd3356 100644 --- a/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptActionMenu.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptLanding/StackScriptActionMenu.tsx @@ -1,7 +1,6 @@ import { useMediaQuery } from '@mui/material'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; @@ -26,7 +25,7 @@ interface Props { export const StackScriptActionMenu = (props: Props) => { const { handlers, stackscript, type } = props; - const history = useHistory(); + const navigate = useNavigate(); const isLargeScreen = useMediaQuery((theme) => theme.breakpoints.up('md') @@ -53,7 +52,11 @@ export const StackScriptActionMenu = (props: Props) => { const actions: { action: Action; show: boolean }[] = [ { action: { - onClick: () => history.push(`/stackscripts/${stackscript.id}/edit`), + onClick: () => + navigate({ + to: '/stackscripts/$id/edit', + params: { id: stackscript.id }, + }), title: 'Edit', ...sharedActionOptions, }, @@ -63,11 +66,13 @@ export const StackScriptActionMenu = (props: Props) => { action: { disabled: isLinodeCreationRestricted, onClick: () => - history.push( - type === 'account' - ? `/linodes/create?type=StackScripts&subtype=Account&stackScriptID=${stackscript.id}` - : `/linodes/create?type=StackScripts&subtype=Community&stackScriptID=${stackscript.id}` - ), + navigate({ + to: '/linodes/create/stackscripts', + search: { + subtype: type === 'account' ? 'Account' : 'Community', + stackScriptID: stackscript.id, + }, + }), title: 'Deploy New Linode', tooltip: isLinodeCreationRestricted ? "You don't have permissions to add Linodes" diff --git a/packages/manager/src/features/StackScripts/stackScriptUtils.ts b/packages/manager/src/features/StackScripts/stackScriptUtils.ts index 637de3cf9b3..46bb64166a2 100644 --- a/packages/manager/src/features/StackScripts/stackScriptUtils.ts +++ b/packages/manager/src/features/StackScripts/stackScriptUtils.ts @@ -11,20 +11,20 @@ export const getStackScriptUrl = ( case currentUser: // My StackScripts // @todo: handle account stackscripts - type = 'StackScripts'; + type = 'stackscripts'; subtype = 'Account'; break; case 'linode': // This is a Marketplace App - type = 'One-Click'; + type = 'marketplace'; subtype = 'One-Click%20Apps'; break; default: // Community StackScripts - type = 'StackScripts'; + type = 'stackscripts'; subtype = 'Community'; } - return `/linodes/create?type=${type}&subtype=${subtype}&stackScriptID=${id}`; + return `/linodes/create/${type}?subtype=${subtype}&stackScriptID=${id}`; }; export const canUserModifyAccountStackScript = ( diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx index c20cd8d58c4..abc65e795a0 100644 --- a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx +++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx @@ -88,7 +88,7 @@ export const CreateMenu = () => { attr: { 'data-qa-one-click-add-new': true }, description: 'Deploy applications with ease', display: 'Marketplace', - href: '/linodes/create?type=One-Click', + href: '/linodes/create/marketplace', }, ], name: 'Compute', diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx index 5e580eacd0a..f821dedc7a2 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.tsx @@ -1,11 +1,9 @@ import { Notice } from '@linode/ui'; -import { getQueryParamsFromQueryString } from '@linode/utilities'; import * as React from 'react'; import { useFormContext } from 'react-hook-form'; -// eslint-disable-next-line no-restricted-imports -import { useLocation } from 'react-router-dom'; import { Link } from 'src/components/Link'; +import { useGetLinodeCreateType } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { VPC_CREATE_FORM_SUBNET_HELPER_TEXT } from '../../constants'; @@ -16,9 +14,6 @@ import { } from './VPCCreateForm.styles'; import type { CreateVPCPayload } from '@linode/api-v4'; -import type { LinodeCreateType } from '@linode/utilities'; -import type { LinodeCreateQueryParams } from 'src/features/Linodes/types'; - interface Props { disabled?: boolean; isDrawer?: boolean; @@ -27,11 +22,8 @@ interface Props { export const SubnetContent = (props: Props) => { const { disabled, isDrawer } = props; - const location = useLocation(); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); - const queryParams = getQueryParamsFromQueryString( - location.search - ); + const createType = useGetLinodeCreateType(); const { formState: { errors }, @@ -48,7 +40,7 @@ export const SubnetContent = (props: Props) => { onClick={() => isFromLinodeCreate && sendLinodeCreateFormInputEvent({ - createType: (queryParams.type as LinodeCreateType) ?? 'OS', + createType: createType ?? 'OS', headerName: 'Create VPC', interaction: 'click', label: 'Learn more', diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index 21fc0594029..7ab330ae87b 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -10,7 +10,6 @@ import { Typography, } from '@linode/ui'; import { Radio, RadioGroup } from '@linode/ui'; -import { getQueryParamsFromQueryString } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import * as React from 'react'; import { @@ -19,14 +18,13 @@ import { useFormContext, useWatch, } from 'react-hook-form'; -// eslint-disable-next-line no-restricted-imports -import { useLocation } from 'react-router-dom'; import { Code } from 'src/components/Code/Code'; import { FormLabel } from 'src/components/FormLabel'; import { Link } from 'src/components/Link'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; +import { useGetLinodeCreateType } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType'; import { useFlags } from 'src/hooks/useFlags'; import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; @@ -36,8 +34,6 @@ import { StyledBodyTypography } from './VPCCreateForm.styles'; import type { Region } from '@linode/api-v4'; import type { CreateVPCPayload } from '@linode/api-v4'; -import type { LinodeCreateType } from '@linode/utilities'; -import type { LinodeCreateQueryParams } from 'src/features/Linodes/types'; interface Props { disabled?: boolean; @@ -47,16 +43,13 @@ interface Props { export const VPCTopSectionContent = (props: Props) => { const { disabled, isDrawer, regions } = props; - const location = useLocation(); const flags = useFlags(); const { isGeckoLAEnabled } = useIsGeckoEnabled( flags.gecko2?.enabled, flags.gecko2?.la ); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); - const queryParams = getQueryParamsFromQueryString( - location.search - ); + const createType = useGetLinodeCreateType(); const { control, @@ -82,7 +75,7 @@ export const VPCTopSectionContent = (props: Props) => { onClick={() => isFromLinodeCreate && sendLinodeCreateFormInputEvent({ - createType: (queryParams.type as LinodeCreateType) ?? 'OS', + createType: createType ?? 'OS', headerName: 'Create VPC', interaction: 'click', label: 'Learn more', diff --git a/packages/manager/src/hooks/useCreateVPC.ts b/packages/manager/src/hooks/useCreateVPC.ts index 3c99e62ab15..3864cb9be16 100644 --- a/packages/manager/src/hooks/useCreateVPC.ts +++ b/packages/manager/src/hooks/useCreateVPC.ts @@ -6,20 +6,17 @@ import { useProfile, useRegionsQuery, } from '@linode/queries'; -import { - getQueryParamsFromQueryString, - scrollErrorIntoView, -} from '@linode/utilities'; +import { scrollErrorIntoView } from '@linode/utilities'; import { createVPCSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { useForm } from 'react-hook-form'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useGetLinodeCreateType } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { DEFAULT_SUBNET_IPV4_VALUE } from 'src/utilities/subnets'; import type { CreateVPCPayload, VPC } from '@linode/api-v4'; -import type { LinodeCreateType } from '@linode/utilities'; // Custom hook to consolidate shared logic between VPCCreate.tsx and VPCCreateDrawer.tsx export interface UseCreateVPCInputs { @@ -35,14 +32,13 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => { const previousSubmitCount = React.useRef(0); - const history = useHistory(); + const navigate = useNavigate(); const { data: profile } = useProfile(); const { data: grants } = useGrants(); const userCannotAddVPC = profile?.restricted && !grants?.global.add_vpcs; - const location = useLocation(); + const createType = useGetLinodeCreateType(); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); - const queryParams = getQueryParamsFromQueryString(location.search); const { data: regions } = useRegionsQuery(); const regionsData = regions ?? []; @@ -54,7 +50,7 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => { try { const vpc = await createVPC(values); if (pushToVPCPage) { - history.push(`/vpcs/${vpc.id}`); + navigate({ to: '/vpcs/$vpcId', params: { vpcId: vpc.id } }); } else { if (handleSelectVPC && onDrawerClose) { handleSelectVPC(vpc); @@ -66,7 +62,7 @@ export const useCreateVPC = (inputs: UseCreateVPCInputs) => { // Fire analytics form submit upon successful VPC creation from Linode Create flow. if (isFromLinodeCreate) { sendLinodeCreateFormStepEvent({ - createType: (queryParams.type as LinodeCreateType) ?? 'OS', + createType: createType ?? 'OS', headerName: 'Create VPC', interaction: 'click', label: 'Create VPC', diff --git a/packages/manager/src/routes/linodes/index.ts b/packages/manager/src/routes/linodes/index.ts index 72925ec41a3..73f62b7faf4 100644 --- a/packages/manager/src/routes/linodes/index.ts +++ b/packages/manager/src/routes/linodes/index.ts @@ -3,8 +3,8 @@ import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { LinodesRoute } from './LinodesRoute'; -import type { LinodeCreateType } from '@linode/utilities'; import type { StackScriptTabType } from 'src/features/Linodes/LinodeCreate/Tabs/StackScripts/utilities'; +import type { linodesCreateTypes } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType'; interface LinodeDetailSearchParams { delete?: boolean; @@ -23,7 +23,6 @@ export interface LinodeCreateSearchParams { linodeID?: number; stackScriptID?: number; subtype?: StackScriptTabType; - type?: LinodeCreateType; } export const linodesRoute = createRoute({ @@ -44,13 +43,124 @@ const linodesIndexRoute = createRoute({ const linodesCreateRoute = createRoute({ getParentRoute: () => linodesRoute, path: 'create', - validateSearch: (search: LinodeCreateSearchParams) => search, + validateSearch: ( + search: LinodeCreateSearchParams & { + type?: string; + } + ) => search, }).lazy(() => import('src/features/Linodes/LinodeCreate/linodeCreateLazyRoute').then( (m) => m.linodeCreateLazyRoute ) ); +// This route provides backwards compatibility for the old routes with "type" parameter +// It redirects to the correct tab based on the type parameter, removes the type parameter, and passes the rest of the search params to the new route +// ex: /linodes/create?type=StackScripts&order=asc&orderBy=label will redirect to /linodes/create/stackscripts?order=asc&orderBy=label +const linodesCreateRouteRedirect = createRoute({ + getParentRoute: () => linodesCreateRoute, + path: '/', + validateSearch: ( + search: LinodeCreateSearchParams & { + type?: (typeof linodesCreateTypes)[number]; + } + ) => search, + beforeLoad: ({ search }) => { + const { type, ...restOfSearch } = search; + + switch (type) { + case 'Backups': + throw redirect({ + to: '/linodes/create/backups', + search: restOfSearch, + }); + case 'Clone Linode': + throw redirect({ + to: '/linodes/create/clone', + search: restOfSearch, + }); + case 'Images': + throw redirect({ + to: '/linodes/create/images', + search: restOfSearch, + }); + case 'One-Click': + throw redirect({ + to: '/linodes/create/marketplace', + search: restOfSearch, + }); + case 'StackScripts': + throw redirect({ + to: '/linodes/create/stackscripts', + search: restOfSearch, + }); + default: + throw redirect({ + to: '/linodes/create/os', + search: restOfSearch, + }); + } + }, +}).lazy(() => + import('src/features/Linodes/LinodeCreate/linodeCreateLazyRoute').then( + (m) => m.linodeCreateLazyRoute + ) +); + +const linodesCreateOperatingSystemsRoute = createRoute({ + getParentRoute: () => linodesCreateRoute, + path: 'os', +}).lazy(() => + import( + 'src/features/Linodes/LinodeCreate/Tabs/operatingSystemsLazyRoute' + ).then((m) => m.operatingSystemsLazyRoute) +); + +const linodesCreateStackScriptsRoute = createRoute({ + getParentRoute: () => linodesCreateRoute, + path: 'stackscripts', +}).lazy(() => + import( + 'src/features/Linodes/LinodeCreate/Tabs/StackScripts/stackScriptsLazyRoute' + ).then((m) => m.stackScriptsLazyRoute) +); + +const linodesCreateMarketplaceRoute = createRoute({ + getParentRoute: () => linodesCreateRoute, + path: 'marketplace', +}).lazy(() => + import( + 'src/features/Linodes/LinodeCreate/Tabs/Marketplace/marketPlaceLazyRoute' + ).then((m) => m.marketPlaceLazyRoute) +); + +const linodesCreateImagesRoute = createRoute({ + getParentRoute: () => linodesCreateRoute, + path: 'images', +}).lazy(() => + import('src/features/Linodes/LinodeCreate/Tabs/imagesLazyRoutes').then( + (m) => m.imagesLazyRoute + ) +); + +const linodesCreateCloneRoute = createRoute({ + getParentRoute: () => linodesCreateRoute, + path: 'clone', +}).lazy(() => + import('src/features/Linodes/LinodeCreate/Tabs/Clone/cloneLazyRoute').then( + (m) => m.cloneLazyRoute + ) +); + +const linodesCreateBackupsRoute = createRoute({ + getParentRoute: () => linodesCreateRoute, + path: 'backups', +}).lazy(() => + import( + 'src/features/Linodes/LinodeCreate/Tabs/Backups/backupsLazyRoute' + ).then((m) => m.backupsLazyRoute) +); + const linodesDetailRoute = createRoute({ getParentRoute: () => linodesRoute, parseParams: (params) => ({ @@ -201,6 +311,13 @@ const linodesDetailUpgradeInterfacesRoute = createRoute({ export const linodesRouteTree = linodesRoute.addChildren([ linodesIndexRoute, linodesCreateRoute, + linodesCreateOperatingSystemsRoute, + linodesCreateStackScriptsRoute, + linodesCreateMarketplaceRoute, + linodesCreateImagesRoute, + linodesCreateCloneRoute, + linodesCreateBackupsRoute, + linodesCreateRouteRedirect, linodesDetailRoute.addChildren([ linodesDetailCloneRoute.addChildren([ linodesDetailCloneConfigsRoute, From 31574cca4683c4edf027aa0887793f3e17ac7589 Mon Sep 17 00:00:00 2001 From: mduda-akamai Date: Wed, 30 Jul 2025 12:18:26 +0200 Subject: [PATCH 20/67] [DPS-34204] DataStream - cleanup docs and costs (#12572) --- ...r-12572-upcoming-features-1753360891663.md | 5 ++ .../DestinationsLandingEmptyStateData.ts | 19 ++------ .../StreamCreateSubmitBar.test.tsx | 38 +++++++++++++++ .../CheckoutBar/StreamCreateSubmitBar.tsx | 48 +++++++++++++++++++ .../Streams/StreamCreate/StreamCreate.tsx | 4 +- .../StreamCreate/StreamCreateClusters.tsx | 10 +--- .../StreamCreate/StreamCreateDelivery.tsx | 12 +---- .../Streams/StreamsLandingEmptyStateData.ts | 19 ++------ 8 files changed, 104 insertions(+), 51 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12572-upcoming-features-1753360891663.md create mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx diff --git a/packages/api-v4/.changeset/pr-12572-upcoming-features-1753360891663.md b/packages/api-v4/.changeset/pr-12572-upcoming-features-1753360891663.md new file mode 100644 index 00000000000..49fb66c80ed --- /dev/null +++ b/packages/api-v4/.changeset/pr-12572-upcoming-features-1753360891663.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Remove the docs and costs from streams and destinations landing pages and stream create form ([#12572](https://github.com/linode/manager/pull/12572)) diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyStateData.ts b/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyStateData.ts index 95389029e42..d42329e9063 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyStateData.ts +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyStateData.ts @@ -1,8 +1,3 @@ -import { - docsLink, - guidesMoreLinkText, -} from 'src/utilities/emptyStateLandingUtils'; - import type { ResourcesHeaders, ResourcesLinks, @@ -21,16 +16,10 @@ export const linkAnalyticsEvent: ResourcesLinks['linkAnalyticsEvent'] = { }; export const gettingStartedGuides: ResourcesLinkSection = { - links: [ - { - // TODO: Change the link and text when proper documentation is ready - text: 'Getting started guide', - to: 'https://techdocs.akamai.com/cloud-computing/docs', - }, - ], + links: [], moreInfo: { - text: guidesMoreLinkText, - to: docsLink, + text: '', + to: '', }, - title: 'Getting Started Guides', + title: '', }; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx new file mode 100644 index 00000000000..a315f9fc9b7 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx @@ -0,0 +1,38 @@ +import { destinationType } from '@linode/api-v4'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +describe('StreamCreateSubmitBar', () => { + const createStream = () => {}; + + const renderComponent = () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + destination_type: destinationType.LinodeObjectStorage, + }, + }, + }); + }; + + it('should render checkout bar with enabled checkout button', async () => { + renderComponent(); + const submitButton = screen.getByText('Create Stream'); + + expect(submitButton).toBeEnabled(); + }); + + it('should render Delivery summary with destination type', () => { + renderComponent(); + const deliveryTitle = screen.getByText('Delivery'); + const deliveryType = screen.getByText('Linode Object Storage'); + + expect(deliveryTitle).toBeInTheDocument(); + expect(deliveryType).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx new file mode 100644 index 00000000000..79ec15734aa --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx @@ -0,0 +1,48 @@ +import { Box, Button, Divider, Paper, Stack, Typography } from '@linode/ui'; +import * as React from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; + +import type { CreateStreamForm } from 'src/features/DataStream/Streams/StreamCreate/types'; + +type StreamCreateSidebarProps = { + createStream: () => void; +}; + +export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => { + const { createStream } = props; + + const { control } = useFormContext(); + const destinationType = useWatch({ control, name: 'destination_type' }); + + return ( + + + Stream Summary + + + Delivery + + {getDestinationTypeOption(destinationType)?.label ?? ''} + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx index f9473c82ba3..d84debac77d 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx @@ -8,9 +8,9 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; +import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { StreamCreateCheckoutBar } from './CheckoutBar/StreamCreateCheckoutBar'; import { StreamCreateClusters } from './StreamCreateClusters'; import { StreamCreateDelivery } from './StreamCreateDelivery'; import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; @@ -94,7 +94,7 @@ export const StreamCreate = () => { - + diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx index 70bec844f32..2c37c02804e 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx @@ -6,7 +6,6 @@ import { useWatch } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; @@ -202,14 +201,7 @@ export const StreamCreateClusters = () => { return ( - - Clusters - - + Clusters Disabling this option allows you to manually define which clusters will be included in the stream. Stream will not be updated automatically with diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDelivery.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDelivery.tsx index 3369ef5aa72..68515cc02e6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDelivery.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateDelivery.tsx @@ -1,11 +1,10 @@ import { destinationType } from '@linode/api-v4'; -import { Autocomplete, Box, Paper, Typography } from '@linode/ui'; +import { Autocomplete, Paper, Typography } from '@linode/ui'; import { createFilterOptions } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; -import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; @@ -45,14 +44,7 @@ export const StreamCreateDelivery = () => { return ( - - Delivery - - + Delivery Define a destination where you want this stream to send logs. diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts b/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts index f41b778cb95..420a3671c26 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamsLandingEmptyStateData.ts @@ -1,8 +1,3 @@ -import { - docsLink, - guidesMoreLinkText, -} from 'src/utilities/emptyStateLandingUtils'; - import type { ResourcesHeaders, ResourcesLinks, @@ -21,16 +16,10 @@ export const linkAnalyticsEvent: ResourcesLinks['linkAnalyticsEvent'] = { }; export const gettingStartedGuides: ResourcesLinkSection = { - links: [ - { - // TODO: Change the link and text when proper documentation is ready - text: 'Getting started guide', - to: 'https://techdocs.akamai.com/cloud-computing/docs', - }, - ], + links: [], moreInfo: { - text: guidesMoreLinkText, - to: docsLink, + text: '', + to: '', }, - title: 'Getting Started Guides', + title: '', }; From eba58077bf978a5247de0dcfff99473972fff107 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:33:57 -0400 Subject: [PATCH 21/67] fix: `lke-create.spec.ts` failures due to LKE version 1.31 being deprecated (#12597) * mock version * Added changeset: Mock LKE versions in `lke-create.spec.ts` to fix test failure due to LKE version 1.31 being deprecated --------- Co-authored-by: Banks Nussman --- packages/manager/.changeset/pr-12597-tests-1753819485057.md | 5 +++++ .../manager/cypress/e2e/core/kubernetes/lke-create.spec.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12597-tests-1753819485057.md diff --git a/packages/manager/.changeset/pr-12597-tests-1753819485057.md b/packages/manager/.changeset/pr-12597-tests-1753819485057.md new file mode 100644 index 00000000000..f63c711aa20 --- /dev/null +++ b/packages/manager/.changeset/pr-12597-tests-1753819485057.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Mock LKE versions in `lke-create.spec.ts` to fix test failure due to LKE version 1.31 being deprecated ([#12597](https://github.com/linode/manager/pull/12597)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index a860eb97e74..f6717cbbf68 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -697,6 +697,7 @@ describe('LKE Cluster Creation with ACL', () => { describe('with LKE IPACL account capability', () => { beforeEach(() => { + mockGetKubernetesVersions([clusterVersion]).as('getLKEVersions'); mockGetRegions([mockRegion]).as('getRegions'); mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); mockGetRegionAvailability(mockRegion.id, []).as('getRegionAvailability'); @@ -731,7 +732,7 @@ describe('LKE Cluster Creation with ACL', () => { .click(); cy.url().should('endWith', '/kubernetes/create'); - cy.wait(['@getRegions', '@getLinodeTypes']); + cy.wait(['@getRegions', '@getLinodeTypes', '@getLKEVersions']); // Fill out LKE creation form label, region, and Kubernetes version fields. cy.findByLabelText('Cluster Label').should('be.visible').click(); From 62b4fe09837e02c663842294b3ec564c57b0900d Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:05:13 -0500 Subject: [PATCH 22/67] fix: [M3-10351] - Align Encrypt Volume checkbox in Volume Create with other fields (#12592) * Adjust checkbox alignment in Volume create * Added changeset: Align Encrypt Volume checkbox in Volume Create with other fields * Added changeset: Added new prop `sxCheckbox` to overried default styles for Checkbox * PR feedback - @bnussman-akamai @coliu-akamai * Delete pr-12592-changed-1753738347547.md * Update packages/manager/.changeset/pr-12592-fixed-1753738232161.md Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * PR feedback - @coliu-akamai - rename sx prop to sxCheckbox --------- Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --- packages/manager/.changeset/pr-12592-fixed-1753738232161.md | 5 +++++ packages/manager/src/components/Encryption/Encryption.tsx | 6 ++++++ .../manager/src/features/Linodes/LinodeCreate/Security.tsx | 1 + .../Linodes/LinodesDetail/LinodeRebuild/DiskEncryption.tsx | 1 + .../Volumes/Drawers/VolumeDrawer/LinodeVolumeCreateForm.tsx | 1 + packages/manager/src/features/Volumes/VolumeCreate.tsx | 1 + 6 files changed, 15 insertions(+) create mode 100644 packages/manager/.changeset/pr-12592-fixed-1753738232161.md diff --git a/packages/manager/.changeset/pr-12592-fixed-1753738232161.md b/packages/manager/.changeset/pr-12592-fixed-1753738232161.md new file mode 100644 index 00000000000..6be88fd1bc6 --- /dev/null +++ b/packages/manager/.changeset/pr-12592-fixed-1753738232161.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Left alignment of Encryption checkbox in Linode Create, Linode Rebuild, and Volume Create forms ([#12592](https://github.com/linode/manager/pull/12592)) diff --git a/packages/manager/src/components/Encryption/Encryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx index 9290ff8f3f2..d0ee10a87e9 100644 --- a/packages/manager/src/components/Encryption/Encryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -5,6 +5,8 @@ import type { JSX } from 'react'; import { checkboxTestId, descriptionTestId, headerTestId } from './constants'; +import type { SxProps, Theme } from '@mui/material/styles'; + export interface EncryptionProps { descriptionCopy: JSX.Element | string; disabled?: boolean; @@ -14,6 +16,7 @@ export interface EncryptionProps { isEncryptEntityChecked: boolean; notices?: string[]; onChange: (checked: boolean) => void; + sxCheckbox?: SxProps; } export const Encryption = (props: EncryptionProps) => { @@ -26,6 +29,7 @@ export const Encryption = (props: EncryptionProps) => { isEncryptEntityChecked, notices, onChange, + sxCheckbox, } = props; return ( @@ -57,6 +61,8 @@ export const Encryption = (props: EncryptionProps) => { data-testid={checkboxTestId} disabled={disabled} onChange={(e, checked) => onChange(checked)} + sx={sxCheckbox} + sxFormLabel={{ marginLeft: '0px' }} text={`Encrypt ${entityType ?? 'Disk'}`} toolTipText={disabled ? disabledReason : ''} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx index bada708aef6..e2e598e3e9a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx @@ -117,6 +117,7 @@ export const Security = () => { onChange={(checked) => field.onChange(checked ? 'enabled' : 'disabled') } + sxCheckbox={{ paddingLeft: '0px' }} /> )} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/DiskEncryption.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/DiskEncryption.tsx index a5480a8f32e..4402c799df3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/DiskEncryption.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/DiskEncryption.tsx @@ -63,6 +63,7 @@ export const DiskEncryption = (props: Props) => { onChange={(checked) => field.onChange(checked ? 'enabled' : 'disabled') } + sxCheckbox={{ paddingLeft: '0px' }} /> )} /> diff --git a/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeCreateForm.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeCreateForm.tsx index 2142a7505a3..68dba39dfd0 100644 --- a/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeCreateForm.tsx +++ b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeCreateForm.tsx @@ -286,6 +286,7 @@ export const LinodeVolumeCreateForm = (props: Props) => { : [] } onChange={() => toggleVolumeEncryptionEnabled(values.encryption)} + sxCheckbox={{ paddingLeft: '0px' }} /> )} diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index 117a35972ec..ae1641de646 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -550,6 +550,7 @@ export const VolumeCreate = () => { onChange={() => toggleVolumeEncryptionEnabled(values.encryption) } + sxCheckbox={{ paddingLeft: '0px' }} /> )} From 5075e69f1ced4ba018670e79e0c0574dedd807ef Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:06:22 -0500 Subject: [PATCH 23/67] change: [M3-8151] - Allow Linode Select options to be disabled on a per-option basis (#12585) * Allow Linode Select options to be disabled on a per-option basis * Added changeset: Allow Linode Select options to be disabled on a per-option basis * Update LinodeSelect.tsx * PR feedback - @HanaXu --- .../pr-12585-changed-1753475926906.md | 5 +++ .../LinodeSelect/LinodeSelect.test.tsx | 27 +++++++++++++++ .../components/LinodeSelect/LinodeSelect.tsx | 33 +++++++++++++++++-- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 packages/shared/.changeset/pr-12585-changed-1753475926906.md diff --git a/packages/shared/.changeset/pr-12585-changed-1753475926906.md b/packages/shared/.changeset/pr-12585-changed-1753475926906.md new file mode 100644 index 00000000000..efda63ad40a --- /dev/null +++ b/packages/shared/.changeset/pr-12585-changed-1753475926906.md @@ -0,0 +1,5 @@ +--- +"@linode/shared": Changed +--- + +Allow Linode Select options to be disabled on a per-option basis ([#12585](https://github.com/linode/manager/pull/12585)) diff --git a/packages/shared/src/components/LinodeSelect/LinodeSelect.test.tsx b/packages/shared/src/components/LinodeSelect/LinodeSelect.test.tsx index f7c70a169e4..7cc4a4e17db 100644 --- a/packages/shared/src/components/LinodeSelect/LinodeSelect.test.tsx +++ b/packages/shared/src/components/LinodeSelect/LinodeSelect.test.tsx @@ -147,4 +147,31 @@ describe('LinodeSelect', () => { ).not.toBeInTheDocument(); }); }); + + test('should display disabled reason when a Linode is disabled', async () => { + const linode = linodeFactory.build({ id: 123, label: 'My Linode' }); + const disabledReason = 'You do not have access to this Linode'; + + const screen = renderWithWrappers( + , + [QueryClientWrapper(), ThemeWrapper()], + ); + + const input = screen.getByTestId(TEXTFIELD_ID); + await userEvent.click(input); + + await waitFor(() => { + // Ensure the label is in the dropdown + expect(screen.getByText('My Linode')).toBeInTheDocument(); + + // Ensure the disabled reason is displayed + expect(screen.getByText(disabledReason)).toBeInTheDocument(); + }); + }); }); diff --git a/packages/shared/src/components/LinodeSelect/LinodeSelect.tsx b/packages/shared/src/components/LinodeSelect/LinodeSelect.tsx index c8de7f8e0ac..16f07fc8fb2 100644 --- a/packages/shared/src/components/LinodeSelect/LinodeSelect.tsx +++ b/packages/shared/src/components/LinodeSelect/LinodeSelect.tsx @@ -1,5 +1,11 @@ import { useAllLinodesQuery } from '@linode/queries'; -import { Autocomplete, CloseIcon, CustomPopper } from '@linode/ui'; +import { + Autocomplete, + CloseIcon, + CustomPopper, + ListItemOption, + Typography, +} from '@linode/ui'; import { mapIdsToDevices } from '@linode/utilities'; import React from 'react'; @@ -16,6 +22,8 @@ interface LinodeSelectProps { clearable?: boolean; /** Disable editing the input value. */ disabled?: boolean; + /** Map of Linode IDs to be disabled with a reason. */ + disabledLinodes?: Record; /** Hint displayed with error styling. */ errorText?: string; /** Filter sent to the API when retrieving account Linodes. */ @@ -76,6 +84,7 @@ export const LinodeSelect = ( checkIsOptionEqualToValue, clearable = true, disabled, + disabledLinodes, errorText, filter, getOptionDisabled, @@ -121,7 +130,9 @@ export const LinodeSelect = ( disabled={disabled} disablePortal={true} errorText={error?.[0].reason ?? errorText} - getOptionDisabled={getOptionDisabled} + getOptionDisabled={(linode) => + !!disabledLinodes?.[linode.id] || getOptionDisabled?.(linode) || false + } helperText={helperText} id={id} inputValue={inputValue} @@ -153,6 +164,24 @@ export const LinodeSelect = ( : 'Select a Linode' } PopperComponent={CustomPopper} + renderOption={(props, linode, { selected }) => { + const { key, ...restProps } = props; // Avoids passing `key` via props, which triggers React console warnings. + return ( + + {linode.label} + + ); + }} slotProps={{ chip: { deleteIcon: } }} sx={sx} value={ From c771de5e9aef4de8460eddda992133737ddd7abe Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Wed, 30 Jul 2025 19:58:55 +0200 Subject: [PATCH 24/67] feat: [UIE-8845, UIE-8832, UIE-8834, UIE-8833] - IAM RBAC: add a permission check in Profile and Account (#12561) * feat: [UIE-8845] - IAM RBAC: add a permission check in Profile and IAM * feat: [UIE-8834] - IAM RBAC: add a permission check in Accunt/billing * Added changeset: IAM RBAC: add a permission check in Profile and Account/Billing * unit tests fix * e2e tests fix --------- Co-authored-by: mpolotsk Co-authored-by: Conal Ryan <136115382+corya-akamai@users.noreply.github.com> --- ...r-12561-upcoming-features-1753283335594.md | 5 ++ .../PaymentMethodRow.test.tsx | 78 ++++++++++++++++++- .../PaymentMethodRow/PaymentMethodRow.tsx | 20 +++-- .../features/Account/AccountLanding.test.tsx | 39 ++++++++++ .../src/features/Account/AccountLanding.tsx | 9 +-- .../BillingSummary/BillingSummary.test.tsx | 46 +++++++++++ .../BillingSummary/BillingSummary.tsx | 13 +--- .../ContactInformation.test.tsx | 56 ++++++++++--- .../ContactInfoPanel/ContactInformation.tsx | 10 +-- .../PaymentInformation.test.tsx | 24 +++--- .../PaymentInfoPanel/PaymentInformation.tsx | 11 +-- .../PaymentInfoPanel/PaymentMethods.tsx | 3 - .../Users/UserDetails/UsernamePanel.test.tsx | 59 ++++++++++++++ .../IAM/Users/UserDetails/UsernamePanel.tsx | 14 +++- .../DisplaySettings/UsernameForm.test.tsx | 36 ++++++--- .../Profile/DisplaySettings/UsernameForm.tsx | 5 +- 16 files changed, 356 insertions(+), 72 deletions(-) create mode 100644 packages/manager/.changeset/pr-12561-upcoming-features-1753283335594.md create mode 100644 packages/manager/src/features/Account/AccountLanding.test.tsx diff --git a/packages/manager/.changeset/pr-12561-upcoming-features-1753283335594.md b/packages/manager/.changeset/pr-12561-upcoming-features-1753283335594.md new file mode 100644 index 00000000000..0a54f361bf4 --- /dev/null +++ b/packages/manager/.changeset/pr-12561-upcoming-features-1753283335594.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +IAM RBAC: add a permission check in Profile and Account/Billing ([#12561](https://github.com/linode/manager/pull/12561)) diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx index 35548d688c0..a392bf0ef76 100644 --- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx +++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx @@ -11,6 +11,19 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { PaymentMethodRow } from './PaymentMethodRow'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + permissions: { + make_billing_payment: false, + update_account: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + vi.mock('@linode/api-v4/lib/account', async () => { const actual = await vi.importActual('@linode/api-v4/lib/account'); return { @@ -132,7 +145,12 @@ describe('Payment Method Row', () => { it('Calls `onDelete` callback when "Delete" action is clicked', async () => { const mockFunction = vi.fn(); - + queryMocks.userPermissions.mockReturnValue({ + permissions: { + make_billing_payment: false, + update_account: true, + }, + }); const { getByLabelText, getByText } = renderWithTheme( { }); it('Makes payment method default when "Make Default" action is clicked', async () => { + queryMocks.userPermissions.mockReturnValue({ + permissions: { + make_billing_payment: true, + update_account: true, + }, + }); const paymentMethod = paymentMethodFactory.build({ data: { card_type: 'Visa', @@ -177,6 +201,58 @@ describe('Payment Method Row', () => { expect(makeDefaultPaymentMethod).toBeCalledTimes(1); }); + it('should disable "Make a Payment" button if the user does not have make_billing_payment permissions', async () => { + queryMocks.userPermissions.mockReturnValue({ + permissions: { + make_billing_payment: false, + update_account: false, + }, + }); + const { getByLabelText, getByText } = renderWithTheme( + + + + ); + + const actionMenu = getByLabelText('Action menu for card ending in 1881'); + await userEvent.click(actionMenu); + + const makePaymentButton = getByText('Make a Payment'); + expect(makePaymentButton).toBeVisible(); + expect( + makePaymentButton.closest('li')?.getAttribute('aria-disabled') + ).toEqual('true'); + }); + + it('should enable "Make a Payment" button if the user has make_billing_payment permissions', async () => { + queryMocks.userPermissions.mockReturnValue({ + permissions: { + make_billing_payment: true, + update_account: false, + }, + }); + const { getByLabelText, getByText } = renderWithTheme( + + + + ); + + const actionMenu = getByLabelText('Action menu for card ending in 1881'); + await userEvent.click(actionMenu); + + const makePaymentButton = getByText('Make a Payment'); + expect(makePaymentButton).toBeVisible(); + expect( + makePaymentButton.closest('li')?.getAttribute('aria-disabled') + ).not.toEqual('true'); + }); + it('Opens "Make a Payment" drawer with the payment method preselected if "Make a Payment" action is clicked', async () => { const paymentMethods = [ paymentMethodFactory.build({ diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx index e323c598317..d3970e4c1a1 100644 --- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx +++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx @@ -7,6 +7,7 @@ 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 { ThirdPartyPayment } from './ThirdPartyPayment'; @@ -18,10 +19,6 @@ interface Props { * Whether the user is a child user. */ isChildUser?: boolean | undefined; - /** - * Whether the user is a restricted user. - */ - isRestrictedUser?: boolean | undefined; /** * Function called when the delete button in the Action Menu is pressed. */ @@ -38,7 +35,7 @@ interface Props { */ export const PaymentMethodRow = (props: Props) => { const theme = useTheme(); - const { isRestrictedUser, onDelete, paymentMethod } = props; + const { onDelete, paymentMethod, isChildUser } = props; const { is_default, type } = paymentMethod; const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); @@ -46,6 +43,11 @@ export const PaymentMethodRow = (props: Props) => { const { mutateAsync: makePaymentMethodDefault } = useMakeDefaultPaymentMethodMutation(props.paymentMethod.id); + const { permissions } = usePermissions('account', [ + 'make_billing_payment', + 'update_account', + ]); + const makeDefault = () => { makePaymentMethodDefault().catch((errors) => enqueueSnackbar( @@ -57,7 +59,7 @@ export const PaymentMethodRow = (props: Props) => { const actions: Action[] = [ { - disabled: isRestrictedUser, + disabled: isChildUser || !permissions.make_billing_payment, onClick: () => { navigate({ to: '/account/billing', @@ -71,7 +73,8 @@ export const PaymentMethodRow = (props: Props) => { title: 'Make a Payment', }, { - disabled: isRestrictedUser || paymentMethod.is_default, + disabled: + isChildUser || !permissions.update_account || paymentMethod.is_default, onClick: makeDefault, title: 'Make Default', tooltip: paymentMethod.is_default @@ -79,7 +82,8 @@ export const PaymentMethodRow = (props: Props) => { : undefined, }, { - disabled: isRestrictedUser || paymentMethod.is_default, + disabled: + isChildUser || !permissions.update_account || paymentMethod.is_default, onClick: onDelete, title: 'Delete', tooltip: paymentMethod.is_default diff --git a/packages/manager/src/features/Account/AccountLanding.test.tsx b/packages/manager/src/features/Account/AccountLanding.test.tsx new file mode 100644 index 00000000000..a4ace258d90 --- /dev/null +++ b/packages/manager/src/features/Account/AccountLanding.test.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AccountLanding } from './AccountLanding'; + +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + permissions: { make_billing_payment: false }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + +describe('AccountLanding', () => { + it('should disable "Make a Payment" button if the user does not have make_billing_payment permission', async () => { + const { getByRole } = renderWithTheme(); + + const addTagBtn = getByRole('button', { + name: 'Make a Payment', + }); + expect(addTagBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable "Make a Payment" button if the user has make_billing_payment permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + permissions: { make_billing_payment: true }, + }); + + const { getByRole } = renderWithTheme(); + + const addTagBtn = getByRole('button', { + name: 'Make a Payment', + }); + expect(addTagBtn).not.toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index f31bf88f84c..2e79df5616c 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -23,6 +23,7 @@ import { useTabs } from 'src/hooks/useTabs'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import { PlatformMaintenanceBanner } from '../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { usePermissions } from '../IAM/hooks/usePermissions'; import { SwitchAccountButton } from './SwitchAccountButton'; import { SwitchAccountDrawer } from './SwitchAccountDrawer'; @@ -38,6 +39,8 @@ export const AccountLanding = () => { const { data: profile } = useProfile(); const { limitsEvolution } = useFlags(); + const { permissions } = usePermissions('account', ['make_billing_payment']); + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const sessionContext = React.useContext(switchAccountSessionContext); @@ -55,11 +58,7 @@ export const AccountLanding = () => { const showQuotasTab = limitsEvolution?.enabled ?? false; - const isReadOnly = - useRestrictedGlobalGrantCheck({ - globalGrantType: 'account_access', - permittedGrantLevel: 'read_write', - }) || isChildUser; + const isReadOnly = !permissions.make_billing_payment || isChildUser; const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'child_account_access', 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 8a7aa604767..f8ca3a44101 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx @@ -12,6 +12,18 @@ import BillingSummary from './BillingSummary'; const accountBalanceText = 'account-balance-text'; const accountBalanceValue = 'account-balance-value'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + permissions: { + create_promo_code: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + vi.mock('@linode/api-v4/lib/account', async () => { const actual = await vi.importActual('@linode/api-v4/lib/account'); return { @@ -165,4 +177,38 @@ describe('BillingSummary', () => { expect(getByTestId('drawer')).toBeVisible(); expect(getByTestId('drawer-title').textContent).toEqual('Make a Payment'); }); + + it('does not display the "Add a promo code" button if user does not have create_promo_code permission', async () => { + const { queryByText } = renderWithTheme( + + + , + { + initialRoute: '/account/billing', + } + ); + expect(queryByText('Add a promo code')).not.toBeInTheDocument(); + }); + + it('displays the "Add a promo code" button if user has create_promo_code permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + permissions: { + create_promo_code: true, + }, + }); + const { queryByText } = renderWithTheme( + + + , + { + initialRoute: '/account/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 00d5c08302b..70961ff5050 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx @@ -1,9 +1,4 @@ -import { - useAccount, - useGrants, - useNotificationsQuery, - useProfile, -} from '@linode/queries'; +import { useAccount, useGrants, useNotificationsQuery } from '@linode/queries'; import { Box, Button, Divider, TooltipIcon, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; @@ -11,6 +6,7 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; import * as React from 'react'; import { Currency } from 'src/components/Currency'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { isWithinDays } from 'src/utilities/date'; import { BillingPaper } from '../../BillingDetail'; @@ -33,9 +29,8 @@ export const BillingSummary = (props: BillingSummaryProps) => { const { data: notifications } = useNotificationsQuery(); const { data: account } = useAccount(); - const { data: profile } = useProfile(); - const isRestrictedUser = profile?.restricted; + const { permissions } = usePermissions('account', ['create_promo_code']); const [isPromoDialogOpen, setIsPromoDialogOpen] = React.useState(false); @@ -154,7 +149,7 @@ export const BillingSummary = (props: BillingSummaryProps) => { const showAddPromoLink = balance <= 0 && - !isRestrictedUser && + permissions.create_promo_code && isWithinDays(90, account?.active_since) && promotions?.length === 0; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx index b467b4b1203..6ab8eb34068 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx @@ -1,4 +1,4 @@ -import { grantsFactory, profileFactory } from '@linode/utilities'; +import { profileFactory } from '@linode/utilities'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -8,8 +8,10 @@ import { ContactInformation } from './ContactInformation'; const EDIT_BUTTON_ID = 'edit-contact-info'; const queryMocks = vi.hoisted(() => ({ - useGrants: vi.fn().mockReturnValue({}), useProfile: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + permissions: { update_account: false }, + })), })); const props = { @@ -28,11 +30,14 @@ const props = { zip: '19106', }; +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { ...actual, - useGrants: queryMocks.useGrants, useProfile: queryMocks.useProfile, }; }); @@ -55,7 +60,7 @@ describe('Edit Contact Information', () => { ); }); - it('should be disabled for restricted users', async () => { + it('should be disabled if user does not have update_account permission', async () => { queryMocks.useProfile.mockReturnValue({ data: profileFactory.build({ restricted: true, @@ -63,16 +68,49 @@ describe('Edit Contact Information', () => { }), }); - queryMocks.useGrants.mockReturnValue({ - data: grantsFactory.build({ - global: { - account_access: 'read_only', - }, + const { getByTestId } = renderWithTheme(); + + expect(getByTestId(EDIT_BUTTON_ID)).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('should be enabled if user has update_account permission', async () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ + restricted: true, + user_type: 'default', }), }); + queryMocks.userPermissions.mockReturnValue({ + permissions: { update_account: true }, + }); + const { getByTestId } = renderWithTheme(); + expect(getByTestId(EDIT_BUTTON_ID)).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('should be disabled for all child users and if user has update_account permission', async () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ + user_type: 'child', + }), + }); + + queryMocks.userPermissions.mockReturnValue({ + permissions: { update_account: true }, + }); + + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId(EDIT_BUTTON_ID)).toHaveAttribute( 'aria-disabled', 'true' diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx index 56faa4973fb..971083369d9 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx @@ -9,8 +9,8 @@ import { useState } from 'react'; import { MaskableTextAreaCopy } from 'src/components/MaskableText/MaskableTextArea'; 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 { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { BillingActionButton, @@ -76,11 +76,9 @@ export const ContactInformation = React.memo((props: Props) => { return notification.type === 'tax_id_verifying'; }); - const isReadOnly = - useRestrictedGlobalGrantCheck({ - globalGrantType: 'account_access', - permittedGrantLevel: 'read_write', - }) || isChildUser; + const { permissions } = usePermissions('account', ['update_account']); + + const isReadOnly = !permissions.update_account || isChildUser; const handleEditDrawerOpen = () => { navigate({ 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 c409c4bd245..5e5d0650a06 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx @@ -1,4 +1,4 @@ -import { grantsFactory, profileFactory } from '@linode/utilities'; +import { profileFactory } from '@linode/utilities'; import { PayPalScriptProvider } from '@paypal/react-paypal-js'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -20,15 +20,20 @@ vi.mock('@linode/api-v4/lib/account', async () => { }); const queryMocks = vi.hoisted(() => ({ - useGrants: vi.fn().mockReturnValue({}), useProfile: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + permissions: { update_account: false, make_billing_payment: false }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { ...actual, - useGrants: queryMocks.useGrants, useProfile: queryMocks.useProfile, }; }); @@ -92,6 +97,9 @@ describe('Payment Info Panel', () => { }); it('Opens "Add Payment Method" drawer when "Add Payment Method" is clicked', async () => { + queryMocks.userPermissions.mockReturnValue({ + permissions: { update_account: true, make_billing_payment: true }, + }); const { getByTestId, findByTestId } = renderWithTheme( @@ -170,7 +178,7 @@ describe('Payment Info Panel', () => { ); }); - it('should be disabled for restricted users', async () => { + it('should be disabled if user does not have update_account permission', async () => { queryMocks.useProfile.mockReturnValue({ data: profileFactory.build({ restricted: true, @@ -178,12 +186,8 @@ describe('Payment Info Panel', () => { }), }); - queryMocks.useGrants.mockReturnValue({ - data: grantsFactory.build({ - global: { - account_access: 'read_only', - }, - }), + queryMocks.userPermissions.mockReturnValue({ + permissions: { update_account: false, make_billing_payment: false }, }); const { getByTestId } = renderWithTheme( diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx index 80b8e8bea26..7a664342583 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx @@ -10,7 +10,7 @@ import { DeletePaymentMethodDialog } from 'src/components/PaymentMethodRow/Delet 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 { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { @@ -48,11 +48,9 @@ const PaymentInformation = (props: Props) => { const isChildUser = profile?.user_type === 'child'; - const isReadOnly = - useRestrictedGlobalGrantCheck({ - globalGrantType: 'account_access', - permittedGrantLevel: 'read_write', - }) || isChildUser; + const { permissions } = usePermissions('account', ['update_account']); + + const isReadOnly = !permissions.update_account || isChildUser; const doDelete = () => { setDeleteLoading(true); @@ -135,7 +133,6 @@ const PaymentInformation = (props: Props) => { void; paymentMethods: PaymentMethod[] | undefined; @@ -20,7 +19,6 @@ interface Props { const PaymentMethods = ({ error, isChildUser, - isRestrictedUser, loading, openDeleteDialog, paymentMethods, @@ -64,7 +62,6 @@ const PaymentMethods = ({ {paymentMethods.map((paymentMethod: PaymentMethod) => ( openDeleteDialog(paymentMethod)} paymentMethod={paymentMethod} diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx index db683423a19..1a4d881a816 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx @@ -1,3 +1,4 @@ +import userEvent from '@testing-library/user-event'; import React from 'react'; import { accountUserFactory } from 'src/factories'; @@ -5,6 +6,18 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { UsernamePanel } from './UsernamePanel'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + permissions: { + update_user: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + describe('UsernamePanel', () => { it("initializes the form with the user's username", async () => { const user = accountUserFactory.build(); @@ -16,7 +29,27 @@ describe('UsernamePanel', () => { expect(usernameTextField).toHaveDisplayValue(user.username); }); + it('disables the input if the user doesn not have update_user permission', async () => { + const user = accountUserFactory.build(); + + const { getByLabelText } = renderWithTheme(); + + expect(getByLabelText('Username')).toBeDisabled(); + + expect( + getByLabelText( + 'Restricted users cannot update their username. Please contact an account administrator.' + ) + ).toBeVisible(); + }); + it("does not allow the user to update a proxy user's username", async () => { + queryMocks.userPermissions.mockReturnValue({ + permissions: { + update_user: true, + }, + }); + const user = accountUserFactory.build({ user_type: 'proxy', username: 'proxy-user-1', @@ -38,4 +71,30 @@ describe('UsernamePanel', () => { // Verify save button is disabled expect(getByText('Save').closest('button')).toBeDisabled(); }); + + it('enables the save button when the user makes a change to the username and has update_user permission', async () => { + const user = accountUserFactory.build({ + username: 'my-linode-username', + }); + + queryMocks.userPermissions.mockReturnValue({ + permissions: { + update_user: true, + }, + }); + + const { getByLabelText, getByRole, findByDisplayValue } = renderWithTheme( + + ); + + await findByDisplayValue(user.username); + + const saveButton = getByRole('button', { name: 'Save' }); + + expect(saveButton).toBeDisabled(); + + await userEvent.type(getByLabelText('Username'), 'my-linode-username-1'); + + expect(saveButton).toBeEnabled(); + }); }); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx index a1538b8aa09..c52e10bd302 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx @@ -7,6 +7,8 @@ import { Controller, useForm } from 'react-hook-form'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; +import { usePermissions } from '../../hooks/usePermissions'; + import type { User } from '@linode/api-v4'; interface Props { @@ -21,6 +23,8 @@ export const UsernamePanel = ({ user }: Props) => { const { mutateAsync } = useUpdateUserMutation(user.username); + const { permissions } = usePermissions('account', ['update_user']); + const { control, formState: { isDirty, isSubmitting }, @@ -47,9 +51,11 @@ export const UsernamePanel = ({ user }: Props) => { } }; - const tooltipForDisabledUsernameField = isProxyUserProfile - ? RESTRICTED_FIELD_TOOLTIP - : undefined; + const tooltipForDisabledUsernameField = !permissions.update_user + ? 'Restricted users cannot update their username. Please contact an account administrator.' + : isProxyUserProfile + ? RESTRICTED_FIELD_TOOLTIP + : undefined; return ( @@ -59,7 +65,7 @@ export const UsernamePanel = ({ user }: Props) => { name="username" render={({ field, fieldState }) => ( ({ + userPermissions: vi.fn(() => ({ + permissions: { + update_user: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + describe('UsernameForm', () => { it('renders a label and input', () => { const { getByLabelText, getByText } = renderWithTheme(); @@ -26,16 +38,10 @@ describe('UsernameForm', () => { await findByDisplayValue(profile.username); }); - it('disables the input if the user is restricted', async () => { - const profile = profileFactory.build({ restricted: true }); - - server.use(http.get('*/v4/profile', () => HttpResponse.json(profile))); - + it('disables the input if the user doesn not have update_user permission', async () => { const { getByLabelText } = renderWithTheme(); - await waitFor(() => { - expect(getByLabelText('Username')).toBeDisabled(); - }); + expect(getByLabelText('Username')).toBeDisabled(); expect( getByLabelText( @@ -45,6 +51,12 @@ describe('UsernameForm', () => { }); it('disables the input if the user is a proxy user', async () => { + queryMocks.userPermissions.mockReturnValue({ + permissions: { + update_user: true, + }, + }); + const profile = profileFactory.build({ restricted: false, user_type: 'proxy', @@ -61,11 +73,17 @@ describe('UsernameForm', () => { expect(getByLabelText('This field can’t be modified.')).toBeVisible(); }); - it('enables the save button when the user makes a change to the username', async () => { + it('enables the save button when the user makes a change to the username and has update_user permission', async () => { const profile = profileFactory.build({ username: 'my-linode-username' }); server.use(http.get('*/v4/profile', () => HttpResponse.json(profile))); + queryMocks.userPermissions.mockReturnValue({ + permissions: { + update_user: true, + }, + }); + const { findByDisplayValue, getByLabelText, getByText } = renderWithTheme( ); diff --git a/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.tsx b/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.tsx index b5067a9e7df..1af0b383139 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { SingleTextFieldFormContainer } from './TimezoneForm'; @@ -21,6 +22,8 @@ export const UsernameForm = () => { const values = { username: profile?.username ?? '' }; + const { permissions } = usePermissions('account', ['update_user']); + const { control, formState: { isDirty, isSubmitting }, @@ -31,7 +34,7 @@ export const UsernameForm = () => { values, }); - const tooltipForDisabledUsernameField = profile?.restricted + const tooltipForDisabledUsernameField = !permissions.update_user ? 'Restricted users cannot update their username. Please contact an account administrator.' : profile?.user_type === 'proxy' ? RESTRICTED_FIELD_TOOLTIP From 9983af167fa17eda32a610562e3012ff8da6ebc8 Mon Sep 17 00:00:00 2001 From: Conal Ryan <136115382+corya-akamai@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:59:43 -0400 Subject: [PATCH 25/67] feat: [UIE-8999] - IAM RBAC Adjust the parameters to useQueryWithPermissions and return errors (#12560) * feat: [UIE-8999] - IAM RBAC add parameters to the userQueryWithPermission hook, return errors and loading indicators * Changeset * Better API * Fix test --------- Co-authored-by: Conal Ryan --- ...r-12560-upcoming-features-1753279716756.md | 5 ++ .../FirewallLanding/CustomFirewallFields.tsx | 2 +- .../src/features/IAM/hooks/usePermissions.ts | 48 ++++++++++++------- .../LinodeBackup/LinodeBackups.test.tsx | 6 +++ .../LinodeBackup/RestoreToLinodeDrawer.tsx | 17 ++++--- 5 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 packages/manager/.changeset/pr-12560-upcoming-features-1753279716756.md diff --git a/packages/manager/.changeset/pr-12560-upcoming-features-1753279716756.md b/packages/manager/.changeset/pr-12560-upcoming-features-1753279716756.md new file mode 100644 index 00000000000..901d4d18c68 --- /dev/null +++ b/packages/manager/.changeset/pr-12560-upcoming-features-1753279716756.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Modified the query parameter to allow varying use cases. Return any errors from the API along with isLoading, isError values. ([#12560](https://github.com/linode/manager/pull/12560)) diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx index afa54e9907f..c0c218fa07a 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx @@ -66,7 +66,7 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { const { data: profile } = useProfile(); const { data: permissableLinodes, hasFiltered: hasFilteredLinodes } = - useQueryWithPermissions(useAllLinodesQuery, 'linode', [ + useQueryWithPermissions(useAllLinodesQuery(), 'linode', [ 'apply_linode_firewalls', ]); diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index 58a85dc10ee..bb9a5df58d2 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -23,9 +23,7 @@ import type { AccountEntity, APIError, EntityType, - Filter, GrantType, - Params, Profile, } from '@linode/api-v4'; import type { UseQueryResult } from '@linode/queries'; @@ -92,31 +90,41 @@ export const useEntitiesPermissions = ( })), }); - const isLoading = queries.some((query) => query.isLoading); - const isError = queries.some((query) => query.isError); const data = queries.map((query) => query.data); + const error = queries.map((query) => query.error); + const isError = queries.some((query) => query.isError); + const isLoading = queries.some((query) => query.isLoading); - return { data, isLoading, isError }; + return { data, error, isError, isLoading }; }; +export type QueryWithPermissionsResult = { + data: T[]; + error: APIError[] | null; + hasFiltered: boolean; + isError: boolean; + isLoading: boolean; +} & Omit< + UseQueryResult, + 'data' | 'error' | 'isError' | 'isLoading' +>; + export const useQueryWithPermissions = ( - query: ( - params?: Params, - filter?: Filter, - enabled?: boolean - ) => UseQueryResult, + useQueryResult: UseQueryResult, entityType: EntityType, permissionsToCheck: PermissionType[] -): { data: T[]; hasFiltered: boolean } => { - const { data: allEntities } = query(); +): QueryWithPermissionsResult => { + const { + data: allEntities, + error: allEntitiesError, + isLoading: areEntitiesLoading, + isError: isEntitiesError, + ...restQueryResult + } = useQueryResult; const { data: profile } = useProfile(); const { isIAMEnabled } = useIsIAMEnabled(); - const { data: entityPermissions } = useEntitiesPermissions( - allEntities, - entityType, - profile, - isIAMEnabled - ); + const { data: entityPermissions, isLoading: areEntityPermissionsLoading } = + useEntitiesPermissions(allEntities, entityType, profile, isIAMEnabled); const { data: grants } = useGrants(!isIAMEnabled); const entityPermissionsMap = isIAMEnabled @@ -138,6 +146,10 @@ export const useQueryWithPermissions = ( return { data: entities || [], + error: allEntitiesError, hasFiltered: allEntities?.length !== entities?.length, + isError: isEntitiesError, + isLoading: areEntitiesLoading || areEntityPermissionsLoading, + ...restQueryResult, } as const; }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.test.tsx index 6bab5e42a3b..ee1beb3c871 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.test.tsx @@ -19,6 +19,11 @@ const queryMocks = vi.hoisted(() => ({ restore_linode_backup: true, }, })), + useQueryWithPermissions: vi.fn().mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }), })); vi.mock('@tanstack/react-router', async () => { @@ -31,6 +36,7 @@ vi.mock('@tanstack/react-router', async () => { vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.userPermissions, + useQueryWithPermissions: queryMocks.useQueryWithPermissions, })); describe('LinodeBackups', () => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx index db75bdc9502..bcc7194f753 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx @@ -17,10 +17,12 @@ import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { useQueryWithPermissions } from 'src/features/IAM/hooks/usePermissions'; import { useEventsPollingActions } from 'src/queries/events/events'; import { getErrorMap } from 'src/utilities/errorUtils'; -import type { LinodeBackup } from '@linode/api-v4/lib/linodes'; +import type { Linode, LinodeBackup } from '@linode/api-v4/lib/linodes'; + interface Props { backup: LinodeBackup | undefined; linodeId: number; @@ -35,12 +37,7 @@ export const RestoreToLinodeDrawer = (props: Props) => { const { checkForNewEvents } = useEventsPollingActions(); - // TODO - UIE-8999 - this query must be included with the useQueryWithPermissions hook - const { - data: linodes, - error: linodeError, - isLoading: linodesLoading, - } = useAllLinodesQuery( + const query = useAllLinodesQuery( {}, { region: linode?.region, @@ -48,6 +45,12 @@ export const RestoreToLinodeDrawer = (props: Props) => { open && linode !== undefined ); + const { + data: linodes, + error: linodeError, + isLoading: linodesLoading, + } = useQueryWithPermissions(query, 'linode', ['update_linode']); + const { error, isPending, From 3ec0cccdf77f8bfdca54207ff34d115581821887 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:18:25 -0400 Subject: [PATCH 26/67] fix: [M3-10394] - Use human readable date/time for maintenance window (#12605) * fix: [M3-10394] - Use human readable date/time for maintenance window * Add changeset * Remove humanizeCutOff --------- Co-authored-by: Jaalah Ramos --- .../manager/.changeset/pr-12605-fixed-1753890447726.md | 5 +++++ .../src/features/Linodes/LinodeMaintenanceText.tsx | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12605-fixed-1753890447726.md diff --git a/packages/manager/.changeset/pr-12605-fixed-1753890447726.md b/packages/manager/.changeset/pr-12605-fixed-1753890447726.md new file mode 100644 index 00000000000..ac285c642e5 --- /dev/null +++ b/packages/manager/.changeset/pr-12605-fixed-1753890447726.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Use human readable date/time for Linode maintenance window ([#12605](https://github.com/linode/manager/pull/12605)) diff --git a/packages/manager/src/features/Linodes/LinodeMaintenanceText.tsx b/packages/manager/src/features/Linodes/LinodeMaintenanceText.tsx index 16588439b94..251f4751c6e 100644 --- a/packages/manager/src/features/Linodes/LinodeMaintenanceText.tsx +++ b/packages/manager/src/features/Linodes/LinodeMaintenanceText.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay/DateTimeDisplay'; + interface LinodeMaintenanceTextProps { isOpened?: boolean; maintenanceStartTime: string; @@ -12,7 +14,12 @@ export const LinodeMaintenanceText = ({ return ( <> This Linode’s maintenance window {isOpened ? 'opened' : 'opens'} at{' '} - {maintenanceStartTime} + ({ + color: theme.tokens.alias.Content.Text.Secondary.Default, + })} + value={maintenanceStartTime} + /> {!isOpened && <>. For more information, see your open support tickets}. ); From 0f8577bd145c5a4f211813e5c8eaba34e113fe99 Mon Sep 17 00:00:00 2001 From: bill-akamai Date: Wed, 30 Jul 2025 15:29:00 -0500 Subject: [PATCH 27/67] chore:[M3-10397] - Update PR Template to Encourage Video Previews (#12608) * Update PR template * Added changeset: Revise PR template to recommend video previews --- docs/PULL_REQUEST_TEMPLATE.md | 4 ++-- .../.changeset/pr-12608-tech-stories-1753905809598.md | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-12608-tech-stories-1753905809598.md diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md index 6b94028a818..08272c541ca 100644 --- a/docs/PULL_REQUEST_TEMPLATE.md +++ b/docs/PULL_REQUEST_TEMPLATE.md @@ -15,11 +15,11 @@ Please specify a release date (and environment, if applicable) to guarantee time ## Preview 📷 -**Include a screenshot or screen recording of the change.** +**Include a screenshot `` or video `
---- + \ No newline at end of file diff --git a/packages/manager/.changeset/pr-12609-tech-stories-1753907236327.md b/packages/manager/.changeset/pr-12609-tech-stories-1753907236327.md new file mode 100644 index 00000000000..9729745b124 --- /dev/null +++ b/packages/manager/.changeset/pr-12609-tech-stories-1753907236327.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update PR template to hide a section; add a Scope subsection for confirmation of customer-facing changes ([#12609](https://github.com/linode/manager/pull/12609)) From ec90ac88d31bc5078d24a00a2292a2f538712a45 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Thu, 31 Jul 2025 13:36:22 +0530 Subject: [PATCH 29/67] change: [M3-9911] - Add generic description to Premium tab to support both legacy Premium and MTC plans (#12601) * Add generic description copy to Premium CPU tab info content * Add changeset --- .../manager/.changeset/pr-12601-changed-1753862177012.md | 5 +++++ packages/manager/src/features/components/PlansPanel/utils.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12601-changed-1753862177012.md diff --git a/packages/manager/.changeset/pr-12601-changed-1753862177012.md b/packages/manager/.changeset/pr-12601-changed-1753862177012.md new file mode 100644 index 00000000000..bc0c2459be3 --- /dev/null +++ b/packages/manager/.changeset/pr-12601-changed-1753862177012.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Changed +--- + +Replace the existing Premium tab description with generic description copy that works for both legacy Premium and MTC plans ([#12601](https://github.com/linode/manager/pull/12601)) diff --git a/packages/manager/src/features/components/PlansPanel/utils.ts b/packages/manager/src/features/components/PlansPanel/utils.ts index 8644bb0dc96..8763d6ff3e9 100644 --- a/packages/manager/src/features/components/PlansPanel/utils.ts +++ b/packages/manager/src/features/components/PlansPanel/utils.ts @@ -263,7 +263,7 @@ export const planTabInfoContent = { key: 'premium', title: 'Premium CPU', typography: - 'Premium CPU instances guarantee a minimum processor generation of AMD EPYC\u2122 Milan or newer to ensure consistent high performance for more demanding workloads.', + 'Run high-performance, latency-sensitive workloads on dedicated AMD EPYC\u2122 CPUs.', }, prodedicated: { dataId: 'data-qa-prodedi', From 2253937424dc0d8fccb42a66e505ebc9d0f7aa58 Mon Sep 17 00:00:00 2001 From: rodonnel-akamai Date: Thu, 31 Jul 2025 08:28:21 -0400 Subject: [PATCH 30/67] feat: [UIE-8955] - IAM RBAC: Fix removing entity can cause empty page (#12586) * feat: [UIE-8955] - IAM RBAC: Fix removing entity can cause empty page * Added changeset: UIE-8955] - IAM RBAC: Fix removing entity can cause empty page --------- Co-authored-by: Conal Ryan <136115382+corya-akamai@users.noreply.github.com> --- .../.changeset/pr-12586-fixed-1753562679377.md | 5 +++++ .../IAM/Users/UserEntities/AssignedEntitiesTable.tsx | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12586-fixed-1753562679377.md diff --git a/packages/manager/.changeset/pr-12586-fixed-1753562679377.md b/packages/manager/.changeset/pr-12586-fixed-1753562679377.md new file mode 100644 index 00000000000..767b6ecc5ba --- /dev/null +++ b/packages/manager/.changeset/pr-12586-fixed-1753562679377.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +UIE-8955] - IAM RBAC: Fix removing entity can cause empty page ([#12586](https://github.com/linode/manager/pull/12586)) diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx index 718e82cbaa2..3dda690a153 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx @@ -125,6 +125,16 @@ export const AssignedEntitiesTable = () => { setSelectedRole(role); }; + const handleRemoveAssignmentDialogClose = () => { + setIsRemoveAssignmentDialogOpen(false); + // If we just deleted the last one on a page, reset to the first page. + const removedLastOnPage = + filteredAndSortedRoles.length % pagination.pageSize === 1; + if (removedLastOnPage) { + pagination.handlePageChange(1); + } + }; + const filteredRoles = getFilteredRoles({ entityType: entityType?.value as 'all' | EntityType, getSearchableFields, @@ -303,7 +313,7 @@ export const AssignedEntitiesTable = () => { role={selectedRole} /> setIsRemoveAssignmentDialogOpen(false)} + onClose={() => handleRemoveAssignmentDialogClose()} open={isRemoveAssignmentDialogOpen} role={selectedRole} /> From 7662ca026b9ec4a465a44b6346cc85abf56ecbbc Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:15:11 -0400 Subject: [PATCH 31/67] fix: [M3-10393] - Add border to countries with white backgrounds (#12604) * fix: [M3-10393] - Add border to countries with white backgrounds * fix: [M3-10393] - Add border to countries with white backgrounds * Added changeset: Add borders to countries with white backgrounds --------- Co-authored-by: Jaalah Ramos --- .../.changeset/pr-12604-fixed-1753883735502.md | 5 +++++ packages/manager/src/components/Flag.tsx | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-12604-fixed-1753883735502.md diff --git a/packages/manager/.changeset/pr-12604-fixed-1753883735502.md b/packages/manager/.changeset/pr-12604-fixed-1753883735502.md new file mode 100644 index 00000000000..14e058ca781 --- /dev/null +++ b/packages/manager/.changeset/pr-12604-fixed-1753883735502.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Add borders to countries with white backgrounds ([#12604](https://github.com/linode/manager/pull/12604)) diff --git a/packages/manager/src/components/Flag.tsx b/packages/manager/src/components/Flag.tsx index 5653b3d2fa8..2cbf7b8a23c 100644 --- a/packages/manager/src/components/Flag.tsx +++ b/packages/manager/src/components/Flag.tsx @@ -1,6 +1,6 @@ import { Box } from '@linode/ui'; -import { styled } from '@mui/material/styles'; import 'flag-icons/css/flag-icons.min.css'; +import { styled } from '@mui/material/styles'; import React from 'react'; import type { Country } from '@linode/api-v4'; @@ -10,6 +10,13 @@ const COUNTRY_FLAG_OVERRIDES = { uk: 'gb', }; +// Countries that need a css border in the Flag component (countries that have DCs) +const COUNTRIES_WITH_BORDERS = [ + 'id', // indonesia + 'jp', // japan + 'sg', // singapore +]; + interface Props extends BoxProps { country: Country; } @@ -23,6 +30,7 @@ export const Flag = (props: Props) => { return ( ); @@ -37,10 +45,15 @@ const getFlagClass = (country: Country | string) => { return country; }; -const StyledFlag = styled(Box, { label: 'StyledFlag' })(({ theme }) => ({ +const StyledFlag = styled(Box, { label: 'StyledFlag' })<{ + hasBorder: boolean; +}>(({ theme, hasBorder }) => ({ boxShadow: theme.palette.mode === 'light' ? `0px 0px 0px 1px #00000010` : undefined, fontSize: '1.5rem', verticalAlign: 'top', width: '1.41rem', + ...(hasBorder && { + border: `1px solid ${theme.tokens.alias.Border.Normal}`, + }), })); From 579c54153aa0a6bb30bf3578d1cc4dd648b6ff15 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:20:08 -0400 Subject: [PATCH 32/67] tech-story: [M3-10347] - MSW CRUD: prevent deletion of vpcs/subnets with attached resources and Linode Configuration Profile support (#12574) * clean up subnet interfaces when deleting subnet * jk prevent deletion instead * configuration profile update * more handlers * Added changeset: Add MSW Crud support for Linode Config profiles and prevent deletion of vpcs/subnets with resources * some fixes * remove stray consoles * update linode create to account for config interfaces * all the update complexities argh * fix bug * Added changeset: IPAM address from `linodeConfigInterfaceFactoryWithVPC` --- .../pr-12574-tech-stories-1753455956281.md | 5 + .../presets/crud/handlers/linodes/configs.ts | 236 +++++++++++++++++- .../presets/crud/handlers/linodes/linodes.ts | 39 ++- .../src/mocks/presets/crud/handlers/vpcs.ts | 19 ++ .../manager/src/mocks/presets/crud/linodes.ts | 6 + .../pr-12574-removed-1753885111694.md | 5 + .../src/factories/linodeConfigInterface.ts | 2 +- 7 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-12574-tech-stories-1753455956281.md create mode 100644 packages/utilities/.changeset/pr-12574-removed-1753885111694.md diff --git a/packages/manager/.changeset/pr-12574-tech-stories-1753455956281.md b/packages/manager/.changeset/pr-12574-tech-stories-1753455956281.md new file mode 100644 index 00000000000..2379e713a40 --- /dev/null +++ b/packages/manager/.changeset/pr-12574-tech-stories-1753455956281.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add MSW Crud support for Linode Config profiles and prevent deletion of vpcs/subnets with resources ([#12574](https://github.com/linode/manager/pull/12574)) diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes/configs.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes/configs.ts index cdfcc933792..190a3cd194b 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/linodes/configs.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes/configs.ts @@ -1,4 +1,9 @@ -import { linodeConfigInterfaceFactory } from '@linode/utilities'; +import { + configFactory, + linodeConfigInterfaceFactory, + linodeConfigInterfaceFactoryWithVPC, +} from '@linode/utilities'; +import { DateTime } from 'luxon'; import { http } from 'msw'; import { @@ -77,6 +82,235 @@ export const getConfigs = () => [ ), ]; +export const createConfig = (mockState: MockState) => [ + http.post( + '*/v4*/instances/:id/configs', + async ({ + params, + request, + }): Promise> => { + const linodeId = Number(params.id); + const linode = await mswDB.get('linodes', linodeId); + + if (!linode) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + + const configPayload = configFactory.build({ + ...payload, + created: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }); + + const config = await mswDB.add( + 'linodeConfigs', + [linodeId, configPayload], + mockState + ); + + const addInterfacePromises = []; + + // add interfaces as needed + for (const ifacePayload of payload.interfaces ?? []) { + const iface = + ifacePayload.purpose === 'vpc' + ? linodeConfigInterfaceFactoryWithVPC.build({ + ...ifacePayload, + }) + : linodeConfigInterfaceFactory.build({ + purpose: ifacePayload.purpose, + label: ifacePayload.purpose === 'public' ? null : 'interface', + ipam_address: + ifacePayload.purpose === 'public' ? null : '10.0.0.1/24', + }); + addInterfacePromises.push( + mswDB.add('configInterfaces', [config[1].id, iface], mockState) + ); + } + + const addedIfaces = await Promise.all(addInterfacePromises); + for (const iface of addedIfaces) { + if (iface[1].purpose === 'vpc') { + await addInterfaceToSubnet({ + mockState, + interfaceId: iface[1].id, + isLinodeInterface: false, + linodeId: linode.id, + configId: config[1].id, + vpcId: iface[1].vpc_id, + subnetId: iface[1].subnet_id, + }); + } + } + + return makeResponse({ + ...configPayload, + interfaces: addedIfaces.map((iface) => iface[1]), + }); + } + ), +]; + +export const deleteConfig = (mockState: MockState) => [ + http.delete( + '*/v4*/instances/:id/configs/:configId', + async ({ params }): Promise> => { + const id = Number(params.id); + const linode = await mswDB.get('linodes', id); + const configId = Number(params.configId); + const configFromDB = await mswDB.get('linodeConfigs', configId); + const configInterfaces = await mswDB.getAll('configInterfaces'); + + if (!linode || !configFromDB || !configInterfaces) { + return makeNotFoundResponse(); + } + + const ifacesBelongingToConfig = configInterfaces.filter( + (interfaceTuple) => interfaceTuple[0] === configId + ); + + const deleteInterfacePromises = []; + for (const iface of ifacesBelongingToConfig) { + deleteInterfacePromises.push( + mswDB.delete('configInterfaces', iface[1].id, mockState) + ); + if (iface[1].purpose === 'vpc') { + deleteInterfacePromises.push( + removeInterfaceFromSubnet({ + mockState, + interfaceId: iface[1].id, + isLinodeInterface: false, + configId, + vpcId: iface[1].vpc_id, + subnetId: iface[1].subnet_id ?? -1, + }) + ); + } + } + + await Promise.all(deleteInterfacePromises); + await mswDB.delete('linodeConfigs', configId, mockState); + + return makeResponse({}); + } + ), +]; + +export const updateConfig = (mockState: MockState) => [ + http.put( + '*/v4*/linode/instances/:id/configs/:configId', + async ({ + params, + request, + }): Promise> => { + const linodeId = Number(params.id); + const linode = await mswDB.get('linodes', linodeId); + const configId = Number(params.configId); + const configFromDB = await mswDB.get('linodeConfigs', configId); + const configInterfaces = await mswDB.getAll('configInterfaces'); + + if (!linode || !configFromDB || !configInterfaces) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + const config = configFromDB[1]; + const updatedConfig: Config = { + ...config, + ...payload, + updated: DateTime.now().toISO(), + }; + + const updateInterfacePromises = []; + const addInterfacePromises = []; + const _updatedIfaceIds: number[] = []; + + // update / add interfaces as needed + for (const ifacePayload of updatedConfig.interfaces ?? []) { + if (ifacePayload.id) { + _updatedIfaceIds.push(ifacePayload.id); + updateInterfacePromises.push( + mswDB.update( + 'configInterfaces', + ifacePayload.id, + [configId, ifacePayload], + mockState + ) + ); + } else { + const iface = + ifacePayload.purpose === 'vpc' + ? linodeConfigInterfaceFactoryWithVPC.build({ + ...ifacePayload, + }) + : linodeConfigInterfaceFactory.build({ + purpose: ifacePayload.purpose, + }); + addInterfacePromises.push( + mswDB.add('configInterfaces', [configId, iface], mockState) + ); + } + } + + await Promise.all(updateInterfacePromises); + const addedInterfaces = await Promise.all(addInterfacePromises); + for (const addedInterface of addedInterfaces) { + if (addedInterface[1].purpose === 'vpc') { + await addInterfaceToSubnet({ + mockState, + interfaceId: addedInterface[1].id, + isLinodeInterface: false, + linodeId: linode.id, + configId, + vpcId: addedInterface[1].vpc_id, + subnetId: addedInterface[1].subnet_id, + }); + } + } + + const deleteInterfacePromises = []; + const updatedIfaceIds = [ + ..._updatedIfaceIds, + ...addedInterfaces.map((iface) => iface[1].id), + ]; + const oldInterfaces = configInterfaces.filter( + (interfaceTuple) => interfaceTuple[0] === configId + ); + + for (const _iface of oldInterfaces) { + if (!updatedIfaceIds.some((id) => id === _iface[1].id)) { + deleteInterfacePromises.push( + mswDB.delete('configInterfaces', _iface[1].id, mockState) + ); + if (_iface[1].purpose === 'vpc') + deleteInterfacePromises.push( + await removeInterfaceFromSubnet({ + mockState, + configId, + interfaceId: _iface[1].id, + isLinodeInterface: false, + subnetId: _iface[1].subnet_id ?? -1, + vpcId: _iface[1].vpc_id, + }) + ); + } + } + + await Promise.all(deleteInterfacePromises); + await mswDB.update( + 'linodeConfigs', + configId, + [linodeId, updatedConfig], + mockState + ); + + return makeResponse(updatedConfig); + } + ), +]; + export const appendConfigInterface = (mockState: MockState) => [ http.post( '*/v4*/linode/instances/:id/configs/:configId/interfaces', diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts index d11e5ddec81..1477af9a3c8 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts @@ -1,6 +1,8 @@ import { configFactory, linodeBackupFactory, + linodeConfigInterfaceFactory, + linodeConfigInterfaceFactoryWithVPC, linodeFactory, linodeInterfaceFactoryPublic, linodeInterfaceFactoryVlan, @@ -269,11 +271,44 @@ export const createLinode = (mockState: MockState) => [ ); } } else { - // TODO UPDATE THIS const linodeConfig = configFactory.build({ created: DateTime.now().toISO(), }); - await mswDB.add('linodeConfigs', [linode.id, linodeConfig], mockState); + const config = await mswDB.add( + 'linodeConfigs', + [linode.id, linodeConfig], + mockState + ); + const addInterfacePromises = []; + for (const ifacePayload of payload.interfaces) { + const iface = + ifacePayload.purpose === 'vpc' + ? linodeConfigInterfaceFactoryWithVPC.build({ + ...ifacePayload, + }) + : linodeConfigInterfaceFactory.build({ + purpose: ifacePayload.purpose, + label: ifacePayload.purpose === 'public' ? null : 'interface', + ipam_address: + ifacePayload.purpose === 'public' ? null : '10.0.0.1/24', + }); + addInterfacePromises.push( + mswDB.add('configInterfaces', [config[1].id, iface], mockState) + ); + } + const responses = await Promise.all(addInterfacePromises); + const vpcIface = responses.find((iface) => iface[1].purpose === 'vpc'); + if (vpcIface) { + await addInterfaceToSubnet({ + mockState, + interfaceId: vpcIface[1].id, + isLinodeInterface: false, + linodeId: linode.id, + configId: config[1].id, + vpcId: vpcIface[1].vpc_id, + subnetId: vpcIface[1].subnet_id, + }); + } } queueEvents({ diff --git a/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts b/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts index d7ab3188de0..eb6e80bfe69 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/vpcs.ts @@ -4,6 +4,7 @@ import { http } from 'msw'; import { subnetFactory, vpcFactory, vpcIPFactory } from 'src/factories'; import { queueEvents } from 'src/mocks/utilities/events'; import { + makeErrorResponse, makeNotFoundResponse, makePaginatedResponse, makeResponse, @@ -237,6 +238,15 @@ export const deleteVPC = (mockState: MockState) => [ return makeNotFoundResponse(); } + if ( + vpc.subnets.some( + (subnet) => + subnet.linodes.length > 0 || subnet.nodebalancers.length > 0 + ) + ) { + return makeErrorResponse('Cannot delete a VPC with resources attached'); + } + const vpcsIPs = await mswDB.getAll('vpcsIps'); const deleteVPCsIPsPromises = []; @@ -409,6 +419,15 @@ export const deleteSubnet = (mockState: MockState) => [ return makeNotFoundResponse(); } + if ( + subnetFromDB[1].linodes.length > 0 || + subnetFromDB[1].nodebalancers.length > 0 + ) { + return makeErrorResponse( + 'Cannot delete a subnet with resources associated with it' + ); + } + const updatedVPC = { ...vpc, subnets: vpc.subnets.filter( diff --git a/packages/manager/src/mocks/presets/crud/linodes.ts b/packages/manager/src/mocks/presets/crud/linodes.ts index fd9bfeb31d2..f8c89c800d9 100644 --- a/packages/manager/src/mocks/presets/crud/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/linodes.ts @@ -23,8 +23,11 @@ import { import { appendConfigInterface, + createConfig, + deleteConfig, deleteConfigInterface, getConfigs, + updateConfig, } from './handlers/linodes/configs'; import type { MockPresetCrud } from 'src/mocks/types'; @@ -53,6 +56,9 @@ export const linodeCrudPreset: MockPresetCrud = { getLinodeInterfaceFirewalls, appendConfigInterface, deleteConfigInterface, + updateConfig, + createConfig, + deleteConfig, ], id: 'linodes:crud', label: 'Linode CRUD', diff --git a/packages/utilities/.changeset/pr-12574-removed-1753885111694.md b/packages/utilities/.changeset/pr-12574-removed-1753885111694.md new file mode 100644 index 00000000000..4512b2579b1 --- /dev/null +++ b/packages/utilities/.changeset/pr-12574-removed-1753885111694.md @@ -0,0 +1,5 @@ +--- +"@linode/utilities": Removed +--- + +IPAM address from `linodeConfigInterfaceFactoryWithVPC` ([#12574](https://github.com/linode/manager/pull/12574)) diff --git a/packages/utilities/src/factories/linodeConfigInterface.ts b/packages/utilities/src/factories/linodeConfigInterface.ts index 3daeb50a58b..302a2173197 100644 --- a/packages/utilities/src/factories/linodeConfigInterface.ts +++ b/packages/utilities/src/factories/linodeConfigInterface.ts @@ -17,7 +17,7 @@ export const linodeConfigInterfaceFactoryWithVPC = active: false, id: Factory.each((i) => i), ip_ranges: ['192.0.2.0/24', '192.0.3.0/24'], - ipam_address: '10.0.0.1/24', + ipam_address: null, ipv4: { nat_1_1: 'some nat', vpc: '10.0.0.0', From 8954c1860a30dc91586fe55b21ed16077f6964b0 Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Thu, 31 Jul 2025 16:24:32 +0200 Subject: [PATCH 33/67] [DPS-34044] - Validate streams and destinations (#12557) --- ...r-12557-upcoming-features-1753340103984.md | 5 + packages/api-v4/src/datastream/streams.ts | 4 +- packages/api-v4/src/datastream/types.ts | 2 +- ...r-12557-upcoming-features-1753340182313.md | 5 + .../DestinationCreate.test.tsx | 16 +- .../DestinationCreate/DestinationCreate.tsx | 28 ++- ...tinationLinodeObjectStorageDetailsForm.tsx | 60 +++++-- .../src/features/DataStream/Shared/types.ts | 9 +- .../StreamCreateCheckoutBar.test.tsx | 14 +- .../CheckoutBar/StreamCreateCheckoutBar.tsx | 14 +- .../StreamCreateSubmitBar.test.tsx | 2 +- .../CheckoutBar/StreamCreateSubmitBar.tsx | 6 +- .../Streams/StreamCreate/StreamCreate.tsx | 41 +++-- .../StreamCreateClusters.test.tsx | 11 +- .../StreamCreate/StreamCreateClusters.tsx | 27 ++- .../StreamCreateDelivery.test.tsx | 14 +- .../StreamCreate/StreamCreateDelivery.tsx | 36 ++-- .../StreamCreateGeneralInfo.test.tsx | 4 +- .../StreamCreate/StreamCreateGeneralInfo.tsx | 26 ++- .../DataStream/Streams/StreamCreate/types.ts | 10 +- ...r-12557-upcoming-features-1753339964453.md | 5 + packages/validation/src/datastream.schema.ts | 165 ++++++++++++++++++ packages/validation/src/index.ts | 1 + 23 files changed, 406 insertions(+), 99 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12557-upcoming-features-1753340103984.md create mode 100644 packages/manager/.changeset/pr-12557-upcoming-features-1753340182313.md create mode 100644 packages/validation/.changeset/pr-12557-upcoming-features-1753339964453.md create mode 100644 packages/validation/src/datastream.schema.ts diff --git a/packages/api-v4/.changeset/pr-12557-upcoming-features-1753340103984.md b/packages/api-v4/.changeset/pr-12557-upcoming-features-1753340103984.md new file mode 100644 index 00000000000..4bf7b200314 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12557-upcoming-features-1753340103984.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add validation to Create Stream POST request ([#12557](https://github.com/linode/manager/pull/12557)) diff --git a/packages/api-v4/src/datastream/streams.ts b/packages/api-v4/src/datastream/streams.ts index 70cba790c94..2e9ef2229cd 100644 --- a/packages/api-v4/src/datastream/streams.ts +++ b/packages/api-v4/src/datastream/streams.ts @@ -1,3 +1,5 @@ +import { createStreamSchema } from '@linode/validation'; + import { BETA_API_ROOT } from '../constants'; import Request, { setData, @@ -41,7 +43,7 @@ export const getStreams = (params?: Params, filter?: Filter) => */ export const createStream = (data: CreateStreamPayload) => Request( - setData(data), // @TODO (DPS-34044) add validation schema + setData(data, createStreamSchema), setURL(`${BETA_API_ROOT}/monitor/streams`), setMethod('POST'), ); diff --git a/packages/api-v4/src/datastream/types.ts b/packages/api-v4/src/datastream/types.ts index 340b38517e5..59ca5736517 100644 --- a/packages/api-v4/src/datastream/types.ts +++ b/packages/api-v4/src/datastream/types.ts @@ -86,7 +86,7 @@ interface ClientCertificateDetails { type AuthenticationType = 'basic' | 'none'; interface Authentication { - details: AuthenticationDetails; + details?: AuthenticationDetails; type: AuthenticationType; } diff --git a/packages/manager/.changeset/pr-12557-upcoming-features-1753340182313.md b/packages/manager/.changeset/pr-12557-upcoming-features-1753340182313.md new file mode 100644 index 00000000000..f8387f3e932 --- /dev/null +++ b/packages/manager/.changeset/pr-12557-upcoming-features-1753340182313.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Datastream form validation on create stream and destination ([#12557](https://github.com/linode/manager/pull/12557)) diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx index d0862710d15..60943a5a9ef 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx @@ -13,7 +13,7 @@ describe('DestinationCreate', () => { component: , useFormOptions: { defaultValues: { - destination_type: destinationType.LinodeObjectStorage, + destination: { type: destinationType.LinodeObjectStorage }, }, }, }); @@ -30,7 +30,7 @@ describe('DestinationCreate', () => { component: , useFormOptions: { defaultValues: { - destination_label: '', + destination: { label: '' }, }, }, }); @@ -47,7 +47,7 @@ describe('DestinationCreate', () => { component: , useFormOptions: { defaultValues: { - destination_type: destinationType.LinodeObjectStorage, + destination: { type: destinationType.LinodeObjectStorage }, }, }, }); @@ -64,7 +64,7 @@ describe('DestinationCreate', () => { component: , useFormOptions: { defaultValues: { - destination_type: destinationType.LinodeObjectStorage, + destination: { type: destinationType.LinodeObjectStorage }, }, }, }); @@ -81,7 +81,7 @@ describe('DestinationCreate', () => { component: , useFormOptions: { defaultValues: { - destination_type: destinationType.LinodeObjectStorage, + destination: { type: destinationType.LinodeObjectStorage }, }, }, }); @@ -104,7 +104,7 @@ describe('DestinationCreate', () => { component: , useFormOptions: { defaultValues: { - destination_type: destinationType.LinodeObjectStorage, + destination: { type: destinationType.LinodeObjectStorage }, }, }, }); @@ -121,7 +121,7 @@ describe('DestinationCreate', () => { component: , useFormOptions: { defaultValues: { - destination_type: destinationType.LinodeObjectStorage, + destination: { type: destinationType.LinodeObjectStorage }, }, }, }); @@ -138,7 +138,7 @@ describe('DestinationCreate', () => { component: , useFormOptions: { defaultValues: { - destination_type: destinationType.LinodeObjectStorage, + destination: { type: destinationType.LinodeObjectStorage }, }, }, }); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx index 1a5a06d4272..3d318f52d59 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx @@ -1,5 +1,7 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { destinationType } from '@linode/api-v4'; import { Autocomplete, Box, Button, Paper, TextField } from '@linode/ui'; +import { createDestinationSchema } from '@linode/validation'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; @@ -10,7 +12,7 @@ import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtil import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; -import type { CreateStreamForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { CreateDestinationForm } from 'src/features/DataStream/Shared/types'; export const DestinationCreate = () => { const theme = useTheme(); @@ -30,17 +32,21 @@ export const DestinationCreate = () => { title: 'Create Destination', }; - const form = useForm({ + const form = useForm({ defaultValues: { - destination_type: destinationType.LinodeObjectStorage, - region: '', + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, }, + mode: 'onBlur', + resolver: yupResolver(createDestinationSchema), }); const { control, handleSubmit } = form; const selectedDestinationType = useWatch({ control, - name: 'destination_type', + name: 'type', }); const onSubmit = handleSubmit(async () => {}); @@ -51,15 +57,16 @@ export const DestinationCreate = () => { -
+ ( { field.onChange(value); }} @@ -71,11 +78,13 @@ export const DestinationCreate = () => { /> ( + name="label" + render={({ field, fieldState }) => ( { field.onChange(value); }} @@ -100,6 +109,7 @@ export const DestinationCreate = () => { > diff --git a/packages/manager/src/features/Account/DefaultFirewalls.test.tsx b/packages/manager/src/features/Account/DefaultFirewalls.test.tsx index fafad2e3d56..e71a5ea92eb 100644 --- a/packages/manager/src/features/Account/DefaultFirewalls.test.tsx +++ b/packages/manager/src/features/Account/DefaultFirewalls.test.tsx @@ -11,6 +11,17 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { DefaultFirewalls } from './DefaultFirewalls'; +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + data: { update_account_settings: true }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + describe('NetworkInterfaces', () => { it('renders the NetworkInterfaces section', async () => { const account = accountFactory.build({ @@ -50,4 +61,50 @@ describe('NetworkInterfaces', () => { expect(getByText('NodeBalancers Firewall')).toBeVisible(); expect(getByText('Save')).toBeVisible(); }); + + it('should disable Save button and all select boxes if the user does not have "update_account_settings" permissions', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { update_account_settings: false }, + }); + const account = accountFactory.build({ + capabilities: ['Linode Interfaces'], + }); + + server.use( + http.get('*/v4/account', () => HttpResponse.json(account)), + http.get('*/v4beta/networking/firewalls/settings', () => + HttpResponse.json(firewallSettingsFactory.build()) + ), + http.get('*/v4beta/networking/firewalls', () => + HttpResponse.json(makeResourcePage(firewallFactory.buildList(1))) + ) + ); + + const { getByLabelText, getByText } = renderWithTheme( + , + { + flags: { linodeInterfaces: { enabled: true } }, + } + ); + + const configurationSelect = getByLabelText( + 'Configuration Profile Interfaces Firewall' + ); + expect(configurationSelect).toHaveAttribute('disabled'); + + const linodePublicSelect = getByLabelText( + 'Linode Interfaces - Public Interface Firewall' + ); + expect(linodePublicSelect).toHaveAttribute('disabled'); + + const linodeVPCSelect = getByLabelText( + 'Linode Interfaces - VPC Interface Firewall' + ); + expect(linodeVPCSelect).toHaveAttribute('disabled'); + + const nodeBalancerSelect = getByLabelText('NodeBalancers Firewall'); + expect(nodeBalancerSelect).toHaveAttribute('disabled'); + + expect(getByText('Save')).toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/packages/manager/src/features/Account/DefaultFirewalls.tsx b/packages/manager/src/features/Account/DefaultFirewalls.tsx index c80292a0ee1..84af676e298 100644 --- a/packages/manager/src/features/Account/DefaultFirewalls.tsx +++ b/packages/manager/src/features/Account/DefaultFirewalls.tsx @@ -22,6 +22,7 @@ import { Controller, useForm } from 'react-hook-form'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { FirewallSelect } from '../Firewalls/components/FirewallSelect'; +import { usePermissions } from '../IAM/hooks/usePermissions'; import type { UpdateFirewallSettings } from '@linode/api-v4'; @@ -38,7 +39,9 @@ export const DefaultFirewalls = () => { } = useFirewallSettingsQuery({ enabled: isLinodeInterfacesEnabled }); const { mutateAsync: updateFirewallSettings } = useMutateFirewallSettings(); - + const { data: permissions } = usePermissions('account', [ + 'update_account_settings', + ]); const values = { default_firewall_ids: { ...firewallSettings?.default_firewall_ids }, }; @@ -117,6 +120,7 @@ export const DefaultFirewalls = () => { render={({ field, fieldState }) => ( { render={({ field, fieldState }) => ( { render={({ field, fieldState }) => ( { render={({ field, fieldState }) => ( { ({ marginTop: theme.spacingFunction(16) })}> @@ -65,6 +72,7 @@ export const EnableManaged = (props: Props) => { const [error, setError] = React.useState(); const [isLoading, setLoading] = React.useState(false); + const { data: permissions } = usePermissions('account', ['enable_managed']); const linodeCount = linodes?.results ?? 0; const handleClose = () => { @@ -94,6 +102,7 @@ export const EnableManaged = (props: Props) => { 'data-testid': 'submit-managed-enrollment', label: 'Add Linode Managed', loading: isLoading, + disabled: !permissions.enable_managed, onClick: handleSubmit, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Account/MaintenancePolicy.test.tsx b/packages/manager/src/features/Account/MaintenancePolicy.test.tsx index 80838dd9761..0c38410b25b 100644 --- a/packages/manager/src/features/Account/MaintenancePolicy.test.tsx +++ b/packages/manager/src/features/Account/MaintenancePolicy.test.tsx @@ -9,6 +9,16 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { MaintenancePolicy } from './MaintenancePolicy'; +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + data: { update_account_settings: true }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); describe('MaintenancePolicy', () => { it('renders the MaintenancePolicy section', () => { const { getByText } = renderWithTheme(); @@ -50,4 +60,19 @@ describe('MaintenancePolicy', () => { ); }); }); + + it('should disable "Save Maintenance Policy" button and the selectbox if the user does not have "update_account_settings" permission', () => { + queryMocks.userPermissions.mockReturnValue({ + data: { update_account_settings: false }, + }); + const { getByText, getByLabelText } = renderWithTheme( + + ); + + expect(getByLabelText('Maintenance Policy')).toHaveAttribute('disabled'); + expect(getByText('Save Maintenance Policy')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); }); diff --git a/packages/manager/src/features/Account/MaintenancePolicy.tsx b/packages/manager/src/features/Account/MaintenancePolicy.tsx index 91769002535..9d5a2d664d9 100644 --- a/packages/manager/src/features/Account/MaintenancePolicy.tsx +++ b/packages/manager/src/features/Account/MaintenancePolicy.tsx @@ -22,6 +22,8 @@ import { MaintenancePolicySelect } from 'src/components/MaintenancePolicySelect/ import { useFlags } from 'src/hooks/useFlags'; import { useUpcomingMaintenanceNotice } from 'src/hooks/useUpcomingMaintenanceNotice'; +import { usePermissions } from '../IAM/hooks/usePermissions'; + import type { MaintenancePolicyValues } from 'src/hooks/useUpcomingMaintenanceNotice.ts'; export const MaintenancePolicy = () => { @@ -31,7 +33,9 @@ export const MaintenancePolicy = () => { const { mutateAsync: updateAccountSettings } = useMutateAccountSettings(); const flags = useFlags(); - + const { data: permissions } = usePermissions('account', [ + 'update_account_settings', + ]); const { control, formState: { isDirty, isSubmitting }, @@ -90,6 +94,7 @@ export const MaintenancePolicy = () => { name="maintenance_policy" render={({ field, fieldState }) => ( field.onChange(policy.slug)} @@ -100,7 +105,7 @@ export const MaintenancePolicy = () => {