From 8840224fc2f33bf8916f045ec11546db712e505f Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Tue, 31 Mar 2026 22:32:55 +0530 Subject: [PATCH 1/6] upcoming: [UIE-10430] - Reserved IP: Implement Landing Screen. --- packages/manager/src/factories/networking.ts | 52 ++++++ .../ReservedIpsActionMenu.test.tsx | 62 +++++++ .../ReservedIpsActionMenu.tsx | 36 ++++ .../ReservedIpsLanding.test.tsx | 98 ++++++++++ .../ReservedIpsLanding/ReservedIpsLanding.tsx | 103 ++++++++++- .../ReservedIpsLandingRow.test.tsx | 170 ++++++++++++++++++ .../ReservedIpsLandingRow.tsx | 126 +++++++++++++ .../ReservedIpsLandingTable.tsx | 139 ++++++++++++++ packages/manager/src/mocks/mockState.ts | 1 + .../src/mocks/presets/baseline/crud.ts | 7 +- .../mocks/presets/crud/handlers/networking.ts | 20 +++ .../src/mocks/presets/crud/networking.ts | 10 +- .../src/mocks/presets/crud/seeds/index.ts | 3 +- .../mocks/presets/crud/seeds/networking.ts | 33 +++- .../src/mocks/presets/crud/seeds/utils.ts | 3 + packages/manager/src/mocks/types.ts | 3 + 16 files changed, 852 insertions(+), 14 deletions(-) create mode 100644 packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.test.tsx create mode 100644 packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.tsx create mode 100644 packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx create mode 100644 packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx create mode 100644 packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx create mode 100644 packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx diff --git a/packages/manager/src/factories/networking.ts b/packages/manager/src/factories/networking.ts index ec1ad0fee40..85122e7e340 100644 --- a/packages/manager/src/factories/networking.ts +++ b/packages/manager/src/factories/networking.ts @@ -17,3 +17,55 @@ export const ipAddressFactory = Factory.Sync.makeFactory({ reserved: false, tags: [], }); + +const REGIONS = ['pl-labkrk-2', 'us-labedgeeat-2', 'us-labedgeeat-3']; +const SAMPLE_TAGS = [ + ['web', 'production', 'db', 'staging', 'lb', 'api', 'internal'], + ['db', 'staging'], + ['lb'], + ['api', 'internal'], + [], +]; +const SAMPLE_ENTITIES: Array = [ + { + id: 1, + label: 'web-server-01', + type: 'linode', + url: '/v4/linode/instances/1', + }, + { + id: 2, + label: 'ubuntu-pl-labkrk-2', + type: 'linode', + url: '/v4/linode/instances/2', + }, + null, + { + id: 5, + label: 'my-nodebalancer', + type: 'nodebalancer', + url: '/v4/nodebalancers/5', + }, + null, +]; + +export const reservedIPsFactory = Factory.Sync.makeFactory({ + address: Factory.each((id) => `203.0.113.${id}`), + assigned_entity: Factory.each( + (id) => SAMPLE_ENTITIES[id % SAMPLE_ENTITIES.length] + ), + gateway: '203.0.113.1', + interface_id: null, + linode_id: Factory.each((id) => { + const entity = SAMPLE_ENTITIES[id % SAMPLE_ENTITIES.length]; + return entity?.type === 'linode' ? entity.id : null; + }), + prefix: 24, + public: true, + rdns: '172-24-226-80.ip.linodeusercontent.com', + region: Factory.each((id) => REGIONS[id % REGIONS.length]), + reserved: true, + subnet_mask: '255.255.255.0', + tags: Factory.each((id) => SAMPLE_TAGS[id % SAMPLE_TAGS.length]), + type: 'ipv4', +}); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.test.tsx new file mode 100644 index 00000000000..ed126684a21 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.test.tsx @@ -0,0 +1,62 @@ +import { userEvent } from '@testing-library/user-event'; +import * as React from 'react'; + +import { reservedIPsFactory } from 'src/factories/networking'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ReservedIpsActionMenu } from './ReservedIpsActionMenu'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; + +describe('ReservedIpsActionMenu', () => { + const mockHandlers: ReservedIpsActionHandlers = { + onEdit: vi.fn(), + onUnreserve: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the action menu with the correct aria-label', () => { + const ip = reservedIPsFactory.build({ address: '203.0.113.5' }); + + const { getByLabelText } = renderWithTheme( + + ); + + expect( + getByLabelText('Action menu for Reserved IP 203.0.113.5') + ).toBeVisible(); + }); + + it('calls onEdit when Edit is clicked', async () => { + const ip = reservedIPsFactory.build(); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await userEvent.click( + getByLabelText(`Action menu for Reserved IP ${ip.address}`) + ); + await userEvent.click(getByText('Edit')); + + expect(mockHandlers.onEdit).toHaveBeenCalledWith(ip); + }); + + it('calls onUnreserve when Unreserve is clicked', async () => { + const ip = reservedIPsFactory.build(); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await userEvent.click( + getByLabelText(`Action menu for Reserved IP ${ip.address}`) + ); + await userEvent.click(getByText('Unreserve')); + + expect(mockHandlers.onUnreserve).toHaveBeenCalledWith(ip); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.tsx new file mode 100644 index 00000000000..e631032b233 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsActionMenu.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { IPAddress } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +export interface ReservedIpsActionHandlers { + onEdit: (ip: IPAddress) => void; + onUnreserve: (ip: IPAddress) => void; +} + +interface Props { + handlers: ReservedIpsActionHandlers; + ip: IPAddress; +} + +export const ReservedIpsActionMenu = ({ handlers, ip }: Props) => { + const actions: Action[] = [ + { + onClick: () => handlers.onEdit(ip), + title: 'Edit', + }, + { + onClick: () => handlers.onUnreserve(ip), + title: 'Unreserve', + }, + ]; + + return ( + + ); +}; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx new file mode 100644 index 00000000000..963aceda72d --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; + +import { routeTree } from 'src/routes'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ReservedIpsLanding } from './ReservedIpsLanding'; + +const mockQueryReturn = vi.hoisted(() => + vi.fn().mockReturnValue({ + data: undefined, + error: null, + isLoading: true, + }) +); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useReservedIPsQuery: mockQueryReturn, + }; +}); + +describe('ReservedIpsLanding', () => { + it('renders a loading state while data is fetching', () => { + const { getByTestId } = renderWithTheme(, { + initialRoute: '/reserved-ips', + routeTree, + }); + + expect(getByTestId('circle-progress')).toBeInTheDocument(); + }); + + it('renders an error state when the query fails', () => { + mockQueryReturn.mockReturnValue({ + data: undefined, + error: [{ reason: 'Something went wrong.' }], + isLoading: false, + }); + + const { getByText } = renderWithTheme(, { + initialRoute: '/reserved-ips', + routeTree, + }); + + expect(getByText('Something went wrong.')).toBeVisible(); + }); + + it('renders the empty state when there are no reserved IPs', () => { + mockQueryReturn.mockReturnValue({ + data: { data: [], results: 0 }, + error: null, + isLoading: false, + }); + + const { getByText } = renderWithTheme(, { + initialRoute: '/reserved-ips', + routeTree, + }); + + expect(getByText('Reserve an IP Address')).toBeVisible(); + }); + + it('renders the table when reserved IPs are returned', () => { + mockQueryReturn.mockReturnValue({ + data: { + data: [ + { + address: '203.0.113.1', + assigned_entity: null, + gateway: '203.0.113.0', + interface_id: null, + linode_id: null, + prefix: 24, + public: true, + rdns: null, + region: 'us-east', + reserved: true, + subnet_mask: '255.255.255.0', + tags: ['web'], + type: 'ipv4', + }, + ], + results: 1, + }, + error: null, + isLoading: false, + }); + + const { getByText } = renderWithTheme(, { + initialRoute: '/reserved-ips', + routeTree, + }); + + expect(getByText('Reserved IP Addresses')).toBeVisible(); + expect(getByText('203.0.113.1')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx index a51b489f1a3..da66e6d3ed8 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx @@ -1,28 +1,113 @@ -import { Notice } from '@linode/ui'; +import { useReservedIPsQuery } from '@linode/queries'; +import { CircleProgress, ErrorState } from '@linode/ui'; import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { ReservedIpsLandingEmptyState } from './ReservedIpsLandingEmptyState'; +import { ReservedIpsLandingTable } from './ReservedIpsLandingTable'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; + +const preferenceKey = 'reserved-ips'; export const ReservedIpsLanding = () => { - // TODO: Replace with actual data check once API queries are implemented - const showEmptyState = true; + // TODO: These will be used by the Edit drawer and Unreserve dialog component + // const [_selectedIP, setSelectedIP] = React.useState(); + // const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + + // const [_isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); + + // const [_isUnreserveDialogOpen, setIsUnreserveDialogOpen] = React.useState(false); + + const handlers: ReservedIpsActionHandlers = { + onEdit: (ip) => { + // setSelectedIP(ip); + // setIsEditDrawerOpen(true); + }, + onUnreserve: (ip) => { + // setSelectedIP(ip); + // setIsUnreserveDialogOpen(true); + }, + }; - if (showEmptyState) { + const pagination = usePaginationV2({ + currentRoute: '/reserved-ips', + preferenceKey, + }); + + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'address', + }, + from: '/reserved-ips', + }, + preferenceKey: `${preferenceKey}-order`, + }); + + const filter = { + ['+order']: order, + ['+order_by']: orderBy, + }; + + const { + data: reservedIps, + error, + isLoading, + } = useReservedIPsQuery( + { + page: pagination.page, + page_size: pagination.pageSize, + }, + filter + ); + + if (error) { + return ( + + ); + } + + if (isLoading) { + return ; + } + + if (!reservedIps?.data.length) { return ; } + return ( <> { + /*To be updated + setIsDrawerOpen(true) */ }} spacingBottom={16} - title="Reserved IPs" + title="Reserved IP Addresses" + /> + - Reserved IPs is coming soon... ); }; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx new file mode 100644 index 00000000000..a03db6a06e2 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx @@ -0,0 +1,170 @@ +import * as React from 'react'; + +import { reservedIPsFactory } from 'src/factories/networking'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ReservedIpsLandingRow } from './ReservedIpsLandingRow'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; + +const mockHandlers: ReservedIpsActionHandlers = { + onEdit: vi.fn(), + onUnreserve: vi.fn(), +}; + +describe('ReservedIpsLandingRow', () => { + it('renders the IP address', () => { + const ip = reservedIPsFactory.build({ address: '203.0.113.10' }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('203.0.113.10')).toBeVisible(); + }); + + it('renders the assigned entity label as a link for a linode', () => { + const ip = reservedIPsFactory.build({ + assigned_entity: { + id: 123, + label: 'my-linode', + type: 'linode', + url: '/v4/linode/instances/123', + }, + }); + + const { getByRole } = renderWithTheme( + + ); + + const link = getByRole('link', { name: /my-linode/i }); + expect(link).toBeVisible(); + expect(link).toHaveAttribute('href', '/linodes/123'); + }); + + it('renders the assigned entity label as a link for a nodebalancer', () => { + const ip = reservedIPsFactory.build({ + assigned_entity: { + id: 456, + label: 'my-nodebalancer', + type: 'nodebalancer', + url: '/v4/nodebalancers/456', + }, + }); + + const { getByRole } = renderWithTheme( + + ); + + const link = getByRole('link', { name: /my-nodebalancer/i }); + expect(link).toBeVisible(); + expect(link).toHaveAttribute('href', '/nodebalancers/456'); + }); + + it('renders "Unassigned" when there is no assigned entity', () => { + const ip = reservedIPsFactory.build({ assigned_entity: null }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Unassigned')).toBeVisible(); + }); + + it('renders the region label', () => { + const ip = reservedIPsFactory.build(); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Krakow, PL')).toBeVisible(); + }); + + it('renders visible tags as chips', () => { + const ip = reservedIPsFactory.build({ + tags: ['web', 'production', 'staging'], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('web')).toBeVisible(); + expect(getByText('production')).toBeVisible(); + expect(getByText('staging')).toBeVisible(); + }); + + it('renders a ShowMore chip when there are more than 3 tags', () => { + const ip = reservedIPsFactory.build({ + tags: ['web', 'production', 'staging', 'db', 'internal'], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('web')).toBeVisible(); + expect(getByText('production')).toBeVisible(); + expect(getByText('staging')).toBeVisible(); + expect(getByText('+2')).toBeVisible(); + }); + + it('renders no tags when the tags array is empty', () => { + const ip = reservedIPsFactory.build({ tags: [] }); + + const { queryByTestId } = renderWithTheme( + + ); + + expect(queryByTestId('show-more')).not.toBeInTheDocument(); + }); + + it('renders the action menu', () => { + const ip = reservedIPsFactory.build(); + + const { getByLabelText } = renderWithTheme( + + ); + + expect( + getByLabelText(`Action menu for Reserved IP ${ip.address}`) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx new file mode 100644 index 00000000000..25db1853027 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx @@ -0,0 +1,126 @@ +import { Chip, Stack, styled } from '@linode/ui'; +import { splitAt } from '@linode/utilities'; +import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { ShowMore } from 'src/components/ShowMore/ShowMore'; + +import { ReservedIpsActionMenu } from './ReservedIpsActionMenu'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; +import type { IPAddress } from '@linode/api-v4'; + +interface Props { + handlers: ReservedIpsActionHandlers; + ip: IPAddress; + regionLabel: string; +} + +/** + * Derives the Cloud Manager route from the assigned entity. + * API URLs (e.g. `/v4/linode/instances/123`) are not valid app routes. + */ +const getEntityRoute = ( + entity: IPAddress['assigned_entity'] +): null | string => { + if (!entity) { + return null; + } + + switch (entity.type) { + case 'linode': + return `/linodes/${entity.id}`; + case 'nodebalancer': + return `/nodebalancers/${entity.id}`; + default: + return null; + } +}; + +export const ReservedIpsLandingRow = ({ handlers, ip, regionLabel }: Props) => { + const { address, assigned_entity, tags } = ip; + const entityRoute = getEntityRoute(assigned_entity); + + return ( + + {address} + + {assigned_entity && entityRoute ? ( + + {assigned_entity.label} + + ) : ( + 'Unassigned' + )} + + {regionLabel} + {tags?.length > 0 ? : ''} + + + + + ); +}; + +/** + * Displays up to 3 non-clickable tag chips, with a "+N" ShowMore popover + * for any overflow tags. + */ +const MAX_VISIBLE_TAGS = 3; + +const TagsList = ({ tags }: { tags: string[] }) => { + const [visible, overflow] = splitAt(MAX_VISIBLE_TAGS, tags); + + return ( + <> + {visible.map((tag) => ( + + ))} + {overflow.length > 0 && ( + ( + + {items.map((tag) => ( + {tag} + ))} + + )} + /> + )} + + ); +}; + +const StyledTagChip = styled(Chip, { + label: 'StyledTagChip', +})(({ theme }) => ({ + '& .MuiChip-label': { + color: theme.tokens.component.Badge.Informative.Subtle.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + }, +})); + +const StyledActionMenuCell = styled(TableCell, { + label: 'StyledActionMenuCell', +})(({ theme }) => ({ + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-end', + maxWidth: 40, + '& button': { + backgroundColor: 'transparent', + color: theme.tokens.alias.Content.Icon.Primary.Default, + padding: 0, + }, + '& button:hover': { + backgroundColor: 'transparent', + color: theme.tokens.alias.Content.Icon.Primary.Hover, + }, +})); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx new file mode 100644 index 00000000000..4c7c7ea61f9 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx @@ -0,0 +1,139 @@ +import { useRegionsQuery } from '@linode/queries'; +import { useTheme } from '@mui/material/styles'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { + Table, + TableBody, + TableHead, + TableHeaderCell, + TableRow, +} from 'akamai-cds-react-components/Table'; +import * as React from 'react'; + +import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; + +import { ReservedIpsLandingRow } from './ReservedIpsLandingRow'; + +import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; +import type { IPAddress } from '@linode/api-v4'; +import type { Order } from '@linode/utilities'; +import type { PaginationPropsV2 } from 'src/hooks/usePaginationV2'; + +const DEFAULT_PAGE_SIZES = [25, 50, 100]; + +interface Props { + data: IPAddress[] | undefined; + handleOrderChange: (newOrderBy: string, newOrder: Order) => void; + handlers: ReservedIpsActionHandlers; + order: 'asc' | 'desc'; + orderBy: string; + pagination: PaginationPropsV2; + results: number | undefined; +} + +export const ReservedIpsLandingTable = ({ + data, + handleOrderChange, + handlers, + order, + orderBy, + pagination, + results, +}: Props) => { + const theme = useTheme(); + + const { data: regions } = useRegionsQuery(); + + const regionLabelMap = React.useMemo(() => { + const map = new Map(); + regions?.forEach((r) => map.set(r.id, r.label)); + return map; + }, [regions]); + + return ( + <> +
+ + + + + handleOrderChange('address', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'address' ? order : undefined} + > + IP Address + + Assigned Resource + + handleOrderChange('region', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'region' ? order : undefined} + > + Region + + Tags + + + + + {data?.length === 0 ? ( + + ) : ( + data?.map((ip: IPAddress) => ( + + )) + )} + +
+
+ {(results || 0) > MIN_PAGE_SIZE && ( + ) => + pagination.handlePageChange(Number(e.detail)) + } + onPageSizeChange={( + e: CustomEvent<{ page: number; pageSize: number }> + ) => pagination.handlePageSizeChange(Number(e.detail.pageSize))} + page={pagination.page} + pageSize={pagination.pageSize} + pageSizes={DEFAULT_PAGE_SIZES} + style={{ + borderLeft: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderTop: 0, + }} + /> + )} + + ); +}; diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts index 66be91b6f58..3e77bd344be 100644 --- a/packages/manager/src/mocks/mockState.ts +++ b/packages/manager/src/mocks/mockState.ts @@ -55,6 +55,7 @@ export const emptyStore: MockState = { placementGroups: [], regionAvailability: [], regions: [], + reservedIPs: [], streams: [], subnets: [], supportReplies: [], diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts index 35235c49976..9ec424b0040 100644 --- a/packages/manager/src/mocks/presets/baseline/crud.ts +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -14,7 +14,10 @@ import { firewallCrudPreset } from '../crud/firewalls'; import { imagesCrudPreset } from '../crud/images'; import { kubernetesCrudPreset } from '../crud/kubernetes'; import { locksCrudPreset } from '../crud/locks'; -import { networkingCrudPreset } from '../crud/networking'; +import { + networkingCrudPreset, + reservedIPsCrudPreset, +} from '../crud/networking'; import { nodeBalancerCrudPreset } from '../crud/nodebalancers'; import { permissionsCrudPreset } from '../crud/permissions'; import { placementGroupsCrudPreset } from '../crud/placementGroups'; @@ -49,6 +52,8 @@ export const baselineCrudPreset: MockPresetBaseline = { ...vpcCrudPreset.handlers, ...networkingCrudPreset.handlers, ...nodeBalancerCrudPreset.handlers, + ...networkingCrudPreset.handlers, + ...reservedIPsCrudPreset.handlers, // Events. getEvents, diff --git a/packages/manager/src/mocks/presets/crud/handlers/networking.ts b/packages/manager/src/mocks/presets/crud/handlers/networking.ts index c9997d084e7..eac88266008 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/networking.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/networking.ts @@ -118,3 +118,23 @@ export const getReservedIPsTypes = () => [ ]; // @TODO Linode Interfaces - add mocks for sharing/assigning IPs + +// Reserved IP handlers + +export const getReservedIPs = (mockState: MockState) => [ + http.get( + '*/v4beta/networking/reserved/ips', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const reservedIPs = await mswDB.getAll('reservedIPs'); + + return makePaginatedResponse({ + data: reservedIPs || [], + request, + }); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/networking.ts b/packages/manager/src/mocks/presets/crud/networking.ts index 115cb13e65e..9112f38b272 100644 --- a/packages/manager/src/mocks/presets/crud/networking.ts +++ b/packages/manager/src/mocks/presets/crud/networking.ts @@ -3,13 +3,21 @@ import { getIPAddresses, getReservedIPsTypes, reserveIP, + getReservedIPs, } from 'src/mocks/presets/crud/handlers/networking'; import type { MockPresetCrud } from 'src/mocks/types'; export const networkingCrudPreset: MockPresetCrud = { group: { id: 'IP Addresses' }, - handlers: [getIPAddresses, allocateIP, getReservedIPsTypes, reserveIP], + handlers: [getIPAddresses], id: 'ip-addresses:crud', label: 'IP Addresses CRUD', }; + +export const reservedIPsCrudPreset: MockPresetCrud = { + group: { id: 'Reserved IPs' }, + handlers: [getReservedIPs, allocateIP, getReservedIPsTypes, reserveIP], + id: 'reserved-ips:crud', + label: 'Reserved IPs CRUD', +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts index 7fb0f995d18..c40eb2a011c 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/index.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts @@ -4,7 +4,7 @@ import { firewallSeeder } from './firewalls'; import { kubernetesSeeder } from './kubernetes'; import { linodesSeeder } from './linodes'; import { locksSeeder } from './locks'; -import { ipAddressSeeder } from './networking'; +import { ipAddressSeeder, reservedIPSeeder } from './networking'; import { nodeBalancerSeeder } from './nodebalancers'; import { placementGroupSeeder } from './placementGroups'; import { supportTicketsSeeder } from './supportTickets'; @@ -22,6 +22,7 @@ export const dbSeeders = [ locksSeeder, nodeBalancerSeeder, placementGroupSeeder, + reservedIPSeeder, supportTicketsSeeder, defaultUsersSeeder, parentUsersSeeder, diff --git a/packages/manager/src/mocks/presets/crud/seeds/networking.ts b/packages/manager/src/mocks/presets/crud/seeds/networking.ts index 97cb025092b..e9a5381e243 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/networking.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/networking.ts @@ -1,6 +1,6 @@ import { getSeedsCountMap } from 'src/dev-tools/utils'; -import { ipAddressFactory } from 'src/factories'; -import { mswDB } from 'src/mocks/indexedDB'; +import { ipAddressFactory, reservedIPsFactory } from 'src/factories'; +import { addToEntities, mswDB } from 'src/mocks/indexedDB'; import { seedWithUniqueIds } from 'src/mocks/presets/crud/seeds/utils'; import type { MockSeeder, MockState } from 'src/mocks/types'; @@ -30,3 +30,32 @@ export const ipAddressSeeder: MockSeeder = { return updatedMockState; }, }; + +export const reservedIPSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Reserved IP Address Seeds', + group: { id: 'Reserved IPs' }, + id: 'reserved-ips:crud', + label: 'Reserved IPs', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[reservedIPSeeder.id] ?? 0; + const reservedIPSeeds = reservedIPsFactory.buildList(count); + + const uniqueReservedIPSeeds = seedWithUniqueIds<'reservedIPs'>({ + dbEntities: await mswDB.getAll('reservedIPs'), + seedEntities: reservedIPSeeds, + }); + + addToEntities(mockState, 'reservedIPs', uniqueReservedIPSeeds); + + const updatedMockState = { + ...mockState, + reservedIPs: mockState.reservedIPs.concat(uniqueReservedIPSeeds), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/utils.ts b/packages/manager/src/mocks/presets/crud/seeds/utils.ts index 591d3e6b555..0574b9456cb 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/utils.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/utils.ts @@ -42,6 +42,9 @@ export const removeSeeds = async (seederId: MockSeeder['id']) => { case 'placement-groups:crud': await mswDB.deleteAll('placementGroups', mockState, 'seedState'); break; + case 'reserved-ips:crud': + await mswDB.deleteAll('reservedIPs', mockState, 'seedState'); + break; case 'support-tickets:crud': await mswDB.deleteAll('supportTickets', mockState, 'seedState'); break; diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 2be09d2d891..e2f268f3030 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -148,6 +148,7 @@ export type MockPresetCrudGroup = { | 'Permissions' | 'Placement Groups' | 'Quotas' + | 'Reserved IPs' | 'Support Tickets' | 'Users' | 'Volumes' @@ -171,6 +172,7 @@ export type MockPresetCrudId = | 'permissions:crud' | 'placement-groups:crud' | 'quotas:crud' + | 'reserved-ips:crud' | 'support-tickets:crud' | 'users(default):crud' | 'users(parent):crud' @@ -241,6 +243,7 @@ export interface MockState { placementGroups: PlacementGroup[]; regionAvailability: RegionAvailability[]; regions: Region[]; + reservedIPs: IPAddress[]; streams: Stream[]; subnets: [number, Subnet][]; // number is VPC ID supportReplies: SupportReply[]; From 79482e05823fe5756e30b0bf150200ad7161d8b6 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Tue, 31 Mar 2026 22:34:56 +0530 Subject: [PATCH 2/6] Added changeset: Implemented Reserved IPs Landing Page --- .../.changeset/pr-13549-upcoming-features-1774976696073.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13549-upcoming-features-1774976696073.md diff --git a/packages/manager/.changeset/pr-13549-upcoming-features-1774976696073.md b/packages/manager/.changeset/pr-13549-upcoming-features-1774976696073.md new file mode 100644 index 00000000000..0b2ddd23261 --- /dev/null +++ b/packages/manager/.changeset/pr-13549-upcoming-features-1774976696073.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implemented Reserved IPs Landing Page ([#13549](https://github.com/linode/manager/pull/13549)) From b94e25839644fbcdfef79442de800840cb3ba945 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Tue, 7 Apr 2026 13:10:52 +0530 Subject: [PATCH 3/6] Addressed review comments. --- .../ReservedIpsLanding.test.tsx | 235 +++++++++++++----- .../ReservedIpsLandingRow.test.tsx | 40 +-- .../ReservedIpsLandingRow.tsx | 14 +- .../ReservedIpsLandingTable.tsx | 29 ++- 4 files changed, 223 insertions(+), 95 deletions(-) diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx index 963aceda72d..6d489d636a0 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx @@ -1,98 +1,207 @@ +import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; -import { routeTree } from 'src/routes'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { reservedIPsFactory } from 'src/factories/networking'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import { ReservedIpsLanding } from './ReservedIpsLanding'; +import { headers } from './ReservedIpsLandingEmptyStateData'; -const mockQueryReturn = vi.hoisted(() => - vi.fn().mockReturnValue({ - data: undefined, - error: null, - isLoading: true, - }) -); +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({ data: { restricted: false } }), +})); vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { ...actual, - useReservedIPsQuery: mockQueryReturn, + useProfile: queryMocks.useProfile, }; }); -describe('ReservedIpsLanding', () => { - it('renders a loading state while data is fetching', () => { - const { getByTestId } = renderWithTheme(, { - initialRoute: '/reserved-ips', - routeTree, +beforeAll(() => mockMatchMedia()); + +const loadingTestId = 'circle-progress'; +const reservedIPsEndpoint = '*/networking/reserved/ips'; + +describe('Reserved IPs Landing', () => { + it('renders loading state initially', async () => { + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId } = renderWithTheme(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 3000, }); + }); + + it('renders the empty state when there are no reserved IPs', async () => { + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); - expect(getByTestId('circle-progress')).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText(headers.description)).toBeInTheDocument(); }); - it('renders an error state when the query fails', () => { - mockQueryReturn.mockReturnValue({ - data: undefined, - error: [{ reason: 'Something went wrong.' }], - isLoading: false, + it('renders the table with reserved IPs', async () => { + const reservedIPs = reservedIPsFactory.buildList(3, { + region: 'us-east', + reserved: true, }); - const { getByText } = renderWithTheme(, { - initialRoute: '/reserved-ips', - routeTree, + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getAllByText, getByTestId, queryAllByText } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 3000, }); - expect(getByText('Something went wrong.')).toBeVisible(); + // Table column headers + getAllByText('IP Address'); + getAllByText('Assigned Resource'); + getAllByText('Region'); + getAllByText('Tags'); + + // Check mocked IP addresses rendered in the table + queryAllByText(reservedIPs[0].address); + }); + + it('renders the "Reserve an IP Address" button', async () => { + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { container, getByTestId } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const reserveIPButton = container.querySelector('button'); + + expect(reserveIPButton).toBeInTheDocument(); + expect(reserveIPButton).toHaveTextContent('Reserve an IP Address'); }); - it('renders the empty state when there are no reserved IPs', () => { - mockQueryReturn.mockReturnValue({ - data: { data: [], results: 0 }, - error: null, - isLoading: false, + it('renders a row with action menu for each reserved IP', async () => { + const reservedIPs = reservedIPsFactory.buildList(3, { + assigned_entity: null, + reserved: true, }); - const { getByText } = renderWithTheme(, { - initialRoute: '/reserved-ips', - routeTree, + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 3000, }); - expect(getByText('Reserve an IP Address')).toBeVisible(); + const actionMenu = getByLabelText( + `Action menu for Reserved IP ${reservedIPs[0].address}` + ); + expect(actionMenu).toBeInTheDocument(); }); - it('renders the table when reserved IPs are returned', () => { - mockQueryReturn.mockReturnValue({ - data: { - data: [ - { - address: '203.0.113.1', - assigned_entity: null, - gateway: '203.0.113.0', - interface_id: null, - linode_id: null, - prefix: 24, - public: true, - rdns: null, - region: 'us-east', - reserved: true, - subnet_mask: '255.255.255.0', - tags: ['web'], - type: 'ipv4', - }, - ], - results: 1, - }, - error: null, - isLoading: false, + it('opens the action menu with correct options', async () => { + const reservedIPs = reservedIPsFactory.buildList(1, { + assigned_entity: null, + reserved: true, }); - const { getByText } = renderWithTheme(, { - initialRoute: '/reserved-ips', - routeTree, + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 3000, }); - expect(getByText('Reserved IP Addresses')).toBeVisible(); - expect(getByText('203.0.113.1')).toBeVisible(); + const actionMenu = getByLabelText( + `Action menu for Reserved IP ${reservedIPs[0].address}` + ); + + await fireEvent.click(actionMenu); + + getByText('Edit'); + getByText('Unreserve'); + }); + + describe('Restricted users', () => { + it('should have the "Reserve an IP Address" button disabled for restricted users', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: true } }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { container, getByTestId } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const reserveIPButton = container.querySelector('button'); + + expect(reserveIPButton).toBeInTheDocument(); + expect(reserveIPButton).toHaveTextContent('Reserve an IP Address'); + }); + + it('should have the "Reserve an IP Address" button enabled for users with full access', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: false } }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { container, getByTestId } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const reserveIPButton = container.querySelector('button'); + + expect(reserveIPButton).toBeInTheDocument(); + expect(reserveIPButton).toHaveTextContent('Reserve an IP Address'); + expect(reserveIPButton).toBeEnabled(); + }); }); }); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx index a03db6a06e2..041e32a4a0a 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx @@ -7,6 +7,14 @@ import { ReservedIpsLandingRow } from './ReservedIpsLandingRow'; import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; +vi.mock('@mui/material', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMediaQuery: vi.fn().mockReturnValue(true), + }; +}); + const mockHandlers: ReservedIpsActionHandlers = { onEdit: vi.fn(), onUnreserve: vi.fn(), @@ -20,7 +28,7 @@ describe('ReservedIpsLandingRow', () => { ); @@ -41,7 +49,7 @@ describe('ReservedIpsLandingRow', () => { ); @@ -64,7 +72,7 @@ describe('ReservedIpsLandingRow', () => { ); @@ -80,7 +88,7 @@ describe('ReservedIpsLandingRow', () => { ); @@ -94,32 +102,31 @@ describe('ReservedIpsLandingRow', () => { ); - expect(getByText('Krakow, PL')).toBeVisible(); + expect(getByText('PL, Krakow')).toBeVisible(); }); it('renders visible tags as chips', () => { const ip = reservedIPsFactory.build({ - tags: ['web', 'production', 'staging'], + tags: ['web', 'production'], }); const { getByText } = renderWithTheme( ); - expect(getByText('web')).toBeVisible(); - expect(getByText('production')).toBeVisible(); - expect(getByText('staging')).toBeVisible(); + expect(getByText('web')).toBeInTheDocument(); + expect(getByText('production')).toBeInTheDocument(); }); - it('renders a ShowMore chip when there are more than 3 tags', () => { + it('renders a ShowMore chip when there are more than 2 tags', () => { const ip = reservedIPsFactory.build({ tags: ['web', 'production', 'staging', 'db', 'internal'], }); @@ -128,14 +135,13 @@ describe('ReservedIpsLandingRow', () => { ); expect(getByText('web')).toBeVisible(); expect(getByText('production')).toBeVisible(); - expect(getByText('staging')).toBeVisible(); - expect(getByText('+2')).toBeVisible(); + expect(getByText('+3')).toBeVisible(); }); it('renders no tags when the tags array is empty', () => { @@ -145,7 +151,7 @@ describe('ReservedIpsLandingRow', () => { ); @@ -159,7 +165,7 @@ describe('ReservedIpsLandingRow', () => { ); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx index 25db1853027..49a9309ae85 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx @@ -1,4 +1,4 @@ -import { Chip, Stack, styled } from '@linode/ui'; +import { Chip, Hidden, Stack, styled } from '@linode/ui'; import { splitAt } from '@linode/utilities'; import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import * as React from 'react'; @@ -57,8 +57,14 @@ export const ReservedIpsLandingRow = ({ handlers, ip, regionLabel }: Props) => { 'Unassigned' )} - {regionLabel} - {tags?.length > 0 ? : ''} + + {regionLabel} + + + {tags?.length > 0 ? : ''} + + + @@ -70,7 +76,7 @@ export const ReservedIpsLandingRow = ({ handlers, ip, regionLabel }: Props) => { * Displays up to 3 non-clickable tag chips, with a "+N" ShowMore popover * for any overflow tags. */ -const MAX_VISIBLE_TAGS = 3; +const MAX_VISIBLE_TAGS = 2; const TagsList = ({ tags }: { tags: string[] }) => { const [visible, overflow] = splitAt(MAX_VISIBLE_TAGS, tags); diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx index 4c7c7ea61f9..d466f7447b2 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx @@ -1,4 +1,5 @@ import { useRegionsQuery } from '@linode/queries'; +import { Hidden } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import { Pagination } from 'akamai-cds-react-components/Pagination'; import { @@ -60,7 +61,6 @@ export const ReservedIpsLandingTable = ({ { border: `1px solid ${theme.tokens.alias.Border.Normal}`, marginTop: '10px', - minWidth: '600px', '--token-component-table-header-outlined-border': theme.tokens.component.Table.Row.Border, } as React.CSSProperties @@ -83,16 +83,23 @@ export const ReservedIpsLandingTable = ({ IP Address Assigned Resource - - handleOrderChange('region', order === 'asc' ? 'desc' : 'asc') - } - sortable - sorted={orderBy === 'region' ? order : undefined} - > - Region - - Tags + + + handleOrderChange( + 'region', + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={orderBy === 'region' ? order : undefined} + > + Region + + + Tags + + From da3735ae6f76fbdc9b90ac307e3ee1c6b5cbe484 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Tue, 7 Apr 2026 13:24:04 +0530 Subject: [PATCH 4/6] Use Badge we component instead of Chip for displaying Tags. --- .../ReservedIpsLanding/ReservedIpsLandingRow.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx index 49a9309ae85..65ddb644578 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx @@ -1,5 +1,6 @@ -import { Chip, Hidden, Stack, styled } from '@linode/ui'; +import { Hidden, Stack, styled } from '@linode/ui'; import { splitAt } from '@linode/utilities'; +import { Badge } from 'akamai-cds-react-components/Badge'; import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import * as React from 'react'; @@ -84,7 +85,7 @@ const TagsList = ({ tags }: { tags: string[] }) => { return ( <> {visible.map((tag) => ( - + {tag} ))} {overflow.length > 0 && ( { ); }; -const StyledTagChip = styled(Chip, { - label: 'StyledTagChip', -})(({ theme }) => ({ - '& .MuiChip-label': { - color: theme.tokens.component.Badge.Informative.Subtle.Text, - font: theme.font.bold, - fontSize: theme.tokens.font.FontSize.Xxxs, - }, -})); - const StyledActionMenuCell = styled(TableCell, { label: 'StyledActionMenuCell', })(({ theme }) => ({ From a4a4bd27c6a67e94a20659965547789466ad1209 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Tue, 7 Apr 2026 19:46:51 +0530 Subject: [PATCH 5/6] Integrate Reserve IP drawer in Create and Edit modes with Landing page. --- .../ReservedIpsLanding/ReservedIpsLanding.tsx | 81 ++++++++++++------- .../ReservedIpsLandingEmptyState.tsx | 16 ++-- .../mocks/presets/crud/handlers/networking.ts | 41 +++++----- .../src/mocks/presets/crud/networking.ts | 2 +- .../mocks/presets/crud/seeds/networking.ts | 1 + 5 files changed, 83 insertions(+), 58 deletions(-) diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx index da66e6d3ed8..3425a11db45 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx @@ -7,32 +7,24 @@ import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { ReserveIPDrawer } from '../ReserveIPDrawer'; import { ReservedIpsLandingEmptyState } from './ReservedIpsLandingEmptyState'; import { ReservedIpsLandingTable } from './ReservedIpsLandingTable'; +import type { ReserveIPDrawerMode } from '../ReserveIPDrawer'; import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu'; +import type { IPAddress } from '@linode/api-v4'; const preferenceKey = 'reserved-ips'; export const ReservedIpsLanding = () => { - // TODO: These will be used by the Edit drawer and Unreserve dialog component - // const [_selectedIP, setSelectedIP] = React.useState(); - // const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + const [drawerMode, setDrawerMode] = + React.useState('create'); + const [selectedIP, setSelectedIP] = React.useState(); - // const [_isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); - - // const [_isUnreserveDialogOpen, setIsUnreserveDialogOpen] = React.useState(false); - - const handlers: ReservedIpsActionHandlers = { - onEdit: (ip) => { - // setSelectedIP(ip); - // setIsEditDrawerOpen(true); - }, - onUnreserve: (ip) => { - // setSelectedIP(ip); - // setIsUnreserveDialogOpen(true); - }, - }; + // TODO: Integrate Unreserve dialog + // const [isUnreserveDialogOpen, setIsUnreserveDialogOpen] = React.useState(false); const pagination = usePaginationV2({ currentRoute: '/reserved-ips', @@ -67,6 +59,30 @@ export const ReservedIpsLanding = () => { filter ); + const openDrawer = (mode: ReserveIPDrawerMode, ip?: IPAddress) => { + setSelectedIP(ip); + setDrawerMode(mode); + setIsDrawerOpen(true); + }; + + const closeDrawer = () => { + setIsDrawerOpen(false); + setSelectedIP(undefined); + }; + + const handlers: ReservedIpsActionHandlers = { + onEdit: (ip) => openDrawer('edit', ip), + onUnreserve: (_ip) => { + // TODO: Integrate Unreserve dialog + // setSelectedIP(ip); + // setIsUnreserveDialogOpen(true); + }, + }; + + if (isLoading) { + return ; + } + if (error) { return ( { ); } - if (isLoading) { - return ; - } - - if (!reservedIps?.data.length) { - return ; + if (reservedIps?.results === 0) { + return ( + <> + openDrawer('create')} + /> + + + ); } return ( <> { - /*To be updated - setIsDrawerOpen(true) */ - }} + onButtonClick={() => openDrawer('create')} spacingBottom={16} title="Reserved IP Addresses" /> @@ -108,6 +129,12 @@ export const ReservedIpsLanding = () => { pagination={pagination} results={reservedIps?.results} /> + ); }; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx index 62f12de0ad9..00eb698c836 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx @@ -4,16 +4,19 @@ import NetworkingIcon from 'src/assets/icons/entityIcons/networking.svg'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; -import { ReserveIPDrawer } from '../ReserveIPDrawer'; import { gettingStartedGuides, headers, linkAnalyticsEvent, } from './ReservedIpsLandingEmptyStateData'; -export const ReservedIpsLandingEmptyState = () => { - const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); +interface Props { + openReserveIPDrawer: () => void; +} +export const ReservedIpsLandingEmptyState = ({ + openReserveIPDrawer, +}: Props) => { return ( @@ -21,7 +24,7 @@ export const ReservedIpsLandingEmptyState = () => { buttonProps={[ { children: 'Reserve an IP Address', - onClick: () => setIsDrawerOpen(true), + onClick: openReserveIPDrawer, }, ]} descriptionMaxWidth={500} @@ -31,11 +34,6 @@ export const ReservedIpsLandingEmptyState = () => { linkAnalyticsEvent={linkAnalyticsEvent} wide={true} /> - setIsDrawerOpen(false)} - open={isDrawerOpen} - /> ); }; diff --git a/packages/manager/src/mocks/presets/crud/handlers/networking.ts b/packages/manager/src/mocks/presets/crud/handlers/networking.ts index eac88266008..6de93054c30 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/networking.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/networking.ts @@ -80,6 +80,25 @@ export const allocateIP = (mockState: MockState) => [ ), ]; +// Reserved IP handlers + +export const getReservedIPs = () => [ + http.get( + '*/v4beta/networking/reserved/ips', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const reservedIPs = await mswDB.getAll('reservedIPs'); + return makePaginatedResponse({ + data: reservedIPs ?? [], + request, + }); + } + ), +]; + export const reserveIP = (mockState: MockState) => [ http.post( '*/v4beta/networking/reserved/ips', @@ -95,7 +114,7 @@ export const reserveIP = (mockState: MockState) => [ type: 'ipv4', }); - await mswDB.add('ipAddresses', ipAddress, mockState); + await mswDB.add('reservedIPs', ipAddress, mockState); return makeResponse(ipAddress); } @@ -118,23 +137,3 @@ export const getReservedIPsTypes = () => [ ]; // @TODO Linode Interfaces - add mocks for sharing/assigning IPs - -// Reserved IP handlers - -export const getReservedIPs = (mockState: MockState) => [ - http.get( - '*/v4beta/networking/reserved/ips', - async ({ - request, - }): Promise< - StrictResponse> - > => { - const reservedIPs = await mswDB.getAll('reservedIPs'); - - return makePaginatedResponse({ - data: reservedIPs || [], - request, - }); - } - ), -]; diff --git a/packages/manager/src/mocks/presets/crud/networking.ts b/packages/manager/src/mocks/presets/crud/networking.ts index 9112f38b272..9c5b59a319f 100644 --- a/packages/manager/src/mocks/presets/crud/networking.ts +++ b/packages/manager/src/mocks/presets/crud/networking.ts @@ -1,9 +1,9 @@ import { allocateIP, getIPAddresses, + getReservedIPs, getReservedIPsTypes, reserveIP, - getReservedIPs, } from 'src/mocks/presets/crud/handlers/networking'; import type { MockPresetCrud } from 'src/mocks/types'; diff --git a/packages/manager/src/mocks/presets/crud/seeds/networking.ts b/packages/manager/src/mocks/presets/crud/seeds/networking.ts index e9a5381e243..dccb3502449 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/networking.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/networking.ts @@ -56,6 +56,7 @@ export const reservedIPSeeder: MockSeeder = { }; await mswDB.saveStore(updatedMockState, 'seedState'); + return updatedMockState; }, }; From 647d13a50c746654cc61147b92f36bd96da565ba Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Wed, 8 Apr 2026 17:48:40 +0530 Subject: [PATCH 6/6] Add docs link. --- .../ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx index 3425a11db45..d7836fdeeb6 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx @@ -7,6 +7,7 @@ import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { RESERVED_IPS_DOCS_LINK } from '../constants'; import { ReserveIPDrawer } from '../ReserveIPDrawer'; import { ReservedIpsLandingEmptyState } from './ReservedIpsLandingEmptyState'; import { ReservedIpsLandingTable } from './ReservedIpsLandingTable'; @@ -116,6 +117,7 @@ export const ReservedIpsLanding = () => { <> openDrawer('create')} spacingBottom={16} title="Reserved IP Addresses"