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)) 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..6d489d636a0 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx @@ -0,0 +1,207 @@ +import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; + +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 queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({ data: { restricted: false } }), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useProfile: queryMocks.useProfile, + }; +}); + +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(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText(headers.description)).toBeInTheDocument(); + }); + + it('renders the table with reserved IPs', async () => { + const reservedIPs = reservedIPsFactory.buildList(3, { + region: 'us-east', + reserved: true, + }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getAllByText, getByTestId, queryAllByText } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 3000, + }); + + // 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 a row with action menu for each reserved IP', async () => { + const reservedIPs = reservedIPsFactory.buildList(3, { + assigned_entity: null, + reserved: true, + }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 3000, + }); + + const actionMenu = getByLabelText( + `Action menu for Reserved IP ${reservedIPs[0].address}` + ); + expect(actionMenu).toBeInTheDocument(); + }); + + it('opens the action menu with correct options', async () => { + const reservedIPs = reservedIPsFactory.buildList(1, { + assigned_entity: null, + reserved: true, + }); + + server.use( + http.get(reservedIPsEndpoint, () => { + return HttpResponse.json(makeResourcePage(reservedIPs)); + }) + ); + + const { getByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 3000, + }); + + 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/ReservedIpsLanding.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx index a51b489f1a3..d7836fdeeb6 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx @@ -1,28 +1,142 @@ -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 { RESERVED_IPS_DOCS_LINK } from '../constants'; +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: Replace with actual data check once API queries are implemented - const showEmptyState = true; + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + const [drawerMode, setDrawerMode] = + React.useState('create'); + const [selectedIP, setSelectedIP] = React.useState(); + + // TODO: Integrate Unreserve dialog + // const [isUnreserveDialogOpen, setIsUnreserveDialogOpen] = React.useState(false); + + 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 + ); + + const openDrawer = (mode: ReserveIPDrawerMode, ip?: IPAddress) => { + setSelectedIP(ip); + setDrawerMode(mode); + setIsDrawerOpen(true); + }; + + const closeDrawer = () => { + setIsDrawerOpen(false); + setSelectedIP(undefined); + }; - if (showEmptyState) { - return ; + 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 (reservedIps?.results === 0) { + return ( + <> + openDrawer('create')} + /> + + + ); + } + return ( <> openDrawer('create')} spacingBottom={16} - title="Reserved IPs" + title="Reserved IP Addresses" + /> + + - Reserved IPs is coming soon... ); }; 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/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx new file mode 100644 index 00000000000..041e32a4a0a --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.test.tsx @@ -0,0 +1,176 @@ +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'; + +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(), +}; + +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('PL, Krakow')).toBeVisible(); + }); + + it('renders visible tags as chips', () => { + const ip = reservedIPsFactory.build({ + tags: ['web', 'production'], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('web')).toBeInTheDocument(); + expect(getByText('production')).toBeInTheDocument(); + }); + + it('renders a ShowMore chip when there are more than 2 tags', () => { + const ip = reservedIPsFactory.build({ + tags: ['web', 'production', 'staging', 'db', 'internal'], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('web')).toBeVisible(); + expect(getByText('production')).toBeVisible(); + expect(getByText('+3')).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..65ddb644578 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingRow.tsx @@ -0,0 +1,123 @@ +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'; + +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 = 2; + +const TagsList = ({ tags }: { tags: string[] }) => { + const [visible, overflow] = splitAt(MAX_VISIBLE_TAGS, tags); + + return ( + <> + {visible.map((tag) => ( + {tag} + ))} + {overflow.length > 0 && ( + ( + + {items.map((tag) => ( + {tag} + ))} + + )} + /> + )} + + ); +}; + +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..d466f7447b2 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingTable.tsx @@ -0,0 +1,146 @@ +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 { + 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..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); } diff --git a/packages/manager/src/mocks/presets/crud/networking.ts b/packages/manager/src/mocks/presets/crud/networking.ts index 115cb13e65e..9c5b59a319f 100644 --- a/packages/manager/src/mocks/presets/crud/networking.ts +++ b/packages/manager/src/mocks/presets/crud/networking.ts @@ -1,6 +1,7 @@ import { allocateIP, getIPAddresses, + getReservedIPs, getReservedIPsTypes, reserveIP, } from 'src/mocks/presets/crud/handlers/networking'; @@ -9,7 +10,14 @@ 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..dccb3502449 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,33 @@ 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[];