Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Implemented Reserved IPs Landing Page ([#13549](https://github.com/linode/manager/pull/13549))
52 changes: 52 additions & 0 deletions packages/manager/src/factories/networking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,55 @@ export const ipAddressFactory = Factory.Sync.makeFactory<IPAddress>({
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<IPAddress['assigned_entity']> = [
{
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<IPAddress>({
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',
});
Original file line number Diff line number Diff line change
@@ -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(
<ReservedIpsActionMenu handlers={mockHandlers} ip={ip} />
);

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(
<ReservedIpsActionMenu handlers={mockHandlers} ip={ip} />
);

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(
<ReservedIpsActionMenu handlers={mockHandlers} ip={ip} />
);

await userEvent.click(
getByLabelText(`Action menu for Reserved IP ${ip.address}`)
);
await userEvent.click(getByText('Unreserve'));

expect(mockHandlers.onUnreserve).toHaveBeenCalledWith(ip);
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<ActionMenu
actionsList={actions}
ariaLabel={`Action menu for Reserved IP ${ip.address}`}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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,

Check warning on line 10 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Use null instead. Raw Output: {"ruleId":"sonarjs/no-undefined-assignment","severity":1,"message":"Use null instead.","line":10,"column":11,"nodeType":"Identifier","messageId":"useNull","endLine":10,"endColumn":20}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null

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(<ReservedIpsLanding />, {
initialRoute: '/reserved-ips',

Check warning on line 27 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Define a constant instead of duplicating this literal 4 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 4 times.","line":27,"column":21,"nodeType":"Literal","endLine":27,"endColumn":36}
routeTree,
});

expect(getByTestId('circle-progress')).toBeInTheDocument();

Check warning on line 31 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Don't wrap `getBy*` query with `expect` & presence matchers like `toBeInTheDocument` or `not.toBeNull` as `getBy*` queries fail implicitly when element is not found Raw Output: {"ruleId":"testing-library/prefer-implicit-assert","severity":1,"message":"Don't wrap `getBy*` query with `expect` & presence matchers like `toBeInTheDocument` or `not.toBeNull` as `getBy*` queries fail implicitly when element is not found","line":31,"column":12,"nodeType":"Identifier","messageId":"preferImplicitAssert","endLine":31,"endColumn":23}
});

it('renders an error state when the query fails', () => {
mockQueryReturn.mockReturnValue({
data: undefined,

Check warning on line 36 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Use null instead. Raw Output: {"ruleId":"sonarjs/no-undefined-assignment","severity":1,"message":"Use null instead.","line":36,"column":13,"nodeType":"Identifier","messageId":"useNull","endLine":36,"endColumn":22}
error: [{ reason: 'Something went wrong.' }],
isLoading: false,
});

const { getByText } = renderWithTheme(<ReservedIpsLanding />, {
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(<ReservedIpsLanding />, {
initialRoute: '/reserved-ips',
routeTree,
});

expect(getByText('Reserve an IP Address')).toBeVisible();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This button is shown even when there are IPs to show in the table. So let's look for text within empty state.

});

it('renders the table when reserved IPs are returned', () => {
mockQueryReturn.mockReturnValue({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could use reservedIPsFactory to generate list instead of hardcoding it here.

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(<ReservedIpsLanding />, {
initialRoute: '/reserved-ips',
routeTree,
});

expect(getByText('Reserved IP Addresses')).toBeVisible();
expect(getByText('203.0.113.1')).toBeVisible();
});
});
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 18 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Complete the task associated to this "TODO" comment. Raw Output: {"ruleId":"sonarjs/todo-tag","severity":1,"message":"Complete the task associated to this \"TODO\" comment.","line":18,"column":6,"nodeType":null,"messageId":"completeTODO","endLine":18,"endColumn":10}
// const [_selectedIP, setSelectedIP] = React.useState<IPAddress>();
// const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);

// const [_isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false);

Check warning on line 22 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Remove this commented out code. Raw Output: {"ruleId":"sonarjs/no-commented-code","severity":1,"message":"Remove this commented out code.","line":22,"column":3,"nodeType":null,"messageId":"commentedCode","endLine":22,"endColumn":77,"suggestions":[{"messageId":"commentedCodeFix","fix":{"range":[912,986],"text":""},"desc":"Remove this commented out code"}]}

// const [_isUnreserveDialogOpen, setIsUnreserveDialogOpen] = React.useState(false);

Check warning on line 24 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Remove this commented out code. Raw Output: {"ruleId":"sonarjs/no-commented-code","severity":1,"message":"Remove this commented out code.","line":24,"column":3,"nodeType":null,"messageId":"commentedCode","endLine":24,"endColumn":87,"suggestions":[{"messageId":"commentedCodeFix","fix":{"range":[990,1074],"text":""},"desc":"Remove this commented out code"}]}

const handlers: ReservedIpsActionHandlers = {
onEdit: (ip) => {

Check warning on line 27 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 'ip' is defined but never used. Allowed unused args must match /^_/u. Raw Output: {"ruleId":"no-unused-vars","severity":1,"message":"'ip' is defined but never used. Allowed unused args must match /^_/u.","line":27,"column":14,"nodeType":"Identifier","messageId":"unusedVar","endLine":27,"endColumn":16,"suggestions":[{"messageId":"removeVar","data":{"varName":"ip"},"fix":{"range":[1137,1139],"text":""},"desc":"Remove unused variable 'ip'."}]}
// setSelectedIP(ip);

Check warning on line 28 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Remove this commented out code. Raw Output: {"ruleId":"sonarjs/no-commented-code","severity":1,"message":"Remove this commented out code.","line":28,"column":7,"nodeType":null,"messageId":"commentedCode","endLine":29,"endColumn":36,"suggestions":[{"messageId":"commentedCodeFix","fix":{"range":[1152,1209],"text":""},"desc":"Remove this commented out code"}]}
// setIsEditDrawerOpen(true);
},
onUnreserve: (ip) => {

Check warning on line 31 in packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 'ip' is defined but never used. Allowed unused args must match /^_/u. Raw Output: {"ruleId":"no-unused-vars","severity":1,"message":"'ip' is defined but never used. Allowed unused args must match /^_/u.","line":31,"column":19,"nodeType":"Identifier","messageId":"unusedVar","endLine":31,"endColumn":21,"suggestions":[{"messageId":"removeVar","data":{"varName":"ip"},"fix":{"range":[1235,1237],"text":""},"desc":"Remove unused variable '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 (
<ErrorState
errorText={
getAPIErrorOrDefault(
error,
'Error loading your Reserved IP addresses.'
)[0].reason
}
/>
);
}

if (isLoading) {
return <CircleProgress />;
}

if (!reservedIps?.data.length) {
return <ReservedIpsLandingEmptyState />;
}

return (
<>
<LandingHeader
Copy link
Copy Markdown
Contributor

@grevanak-akamai grevanak-akamai Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to show docs link before the create button. Placeholder URL would be "https://techdocs.akamai.com/cloud-computing/update/docs/reserved-ips"

breadcrumbProps={{
pathname: 'Reserved IPs',
removeCrumbX: 1,
createButtonText="Reserve an IP Address"
onButtonClick={() => {
/*To be updated
setIsDrawerOpen(true) */
}}
spacingBottom={16}
title="Reserved IPs"
title="Reserved IP Addresses"
/>
<ReservedIpsLandingTable
data={reservedIps?.data}
handleOrderChange={handleOrderChange}
handlers={handlers}
order={order}
orderBy={orderBy}
pagination={pagination}
results={reservedIps?.results}
/>
<Notice variant="info">Reserved IPs is coming soon...</Notice>
</>
);
};
Loading
Loading