Skip to content

Commit 22f8513

Browse files
upcoming: [UIE-10430] - Reserved IP: Implement Landing Screen. (#13549)
1 parent a996133 commit 22f8513

18 files changed

Lines changed: 1015 additions & 26 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
Implemented Reserved IPs Landing Page ([#13549](https://github.com/linode/manager/pull/13549))

packages/manager/src/factories/networking.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,55 @@ export const ipAddressFactory = Factory.Sync.makeFactory<IPAddress>({
1717
reserved: false,
1818
tags: [],
1919
});
20+
21+
const REGIONS = ['pl-labkrk-2', 'us-labedgeeat-2', 'us-labedgeeat-3'];
22+
const SAMPLE_TAGS = [
23+
['web', 'production', 'db', 'staging', 'lb', 'api', 'internal'],
24+
['db', 'staging'],
25+
['lb'],
26+
['api', 'internal'],
27+
[],
28+
];
29+
const SAMPLE_ENTITIES: Array<IPAddress['assigned_entity']> = [
30+
{
31+
id: 1,
32+
label: 'web-server-01',
33+
type: 'linode',
34+
url: '/v4/linode/instances/1',
35+
},
36+
{
37+
id: 2,
38+
label: 'ubuntu-pl-labkrk-2',
39+
type: 'linode',
40+
url: '/v4/linode/instances/2',
41+
},
42+
null,
43+
{
44+
id: 5,
45+
label: 'my-nodebalancer',
46+
type: 'nodebalancer',
47+
url: '/v4/nodebalancers/5',
48+
},
49+
null,
50+
];
51+
52+
export const reservedIPsFactory = Factory.Sync.makeFactory<IPAddress>({
53+
address: Factory.each((id) => `203.0.113.${id}`),
54+
assigned_entity: Factory.each(
55+
(id) => SAMPLE_ENTITIES[id % SAMPLE_ENTITIES.length]
56+
),
57+
gateway: '203.0.113.1',
58+
interface_id: null,
59+
linode_id: Factory.each((id) => {
60+
const entity = SAMPLE_ENTITIES[id % SAMPLE_ENTITIES.length];
61+
return entity?.type === 'linode' ? entity.id : null;
62+
}),
63+
prefix: 24,
64+
public: true,
65+
rdns: '172-24-226-80.ip.linodeusercontent.com',
66+
region: Factory.each((id) => REGIONS[id % REGIONS.length]),
67+
reserved: true,
68+
subnet_mask: '255.255.255.0',
69+
tags: Factory.each((id) => SAMPLE_TAGS[id % SAMPLE_TAGS.length]),
70+
type: 'ipv4',
71+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { userEvent } from '@testing-library/user-event';
2+
import * as React from 'react';
3+
4+
import { reservedIPsFactory } from 'src/factories/networking';
5+
import { renderWithTheme } from 'src/utilities/testHelpers';
6+
7+
import { ReservedIpsActionMenu } from './ReservedIpsActionMenu';
8+
9+
import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu';
10+
11+
describe('ReservedIpsActionMenu', () => {
12+
const mockHandlers: ReservedIpsActionHandlers = {
13+
onEdit: vi.fn(),
14+
onUnreserve: vi.fn(),
15+
};
16+
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
});
20+
21+
it('renders the action menu with the correct aria-label', () => {
22+
const ip = reservedIPsFactory.build({ address: '203.0.113.5' });
23+
24+
const { getByLabelText } = renderWithTheme(
25+
<ReservedIpsActionMenu handlers={mockHandlers} ip={ip} />
26+
);
27+
28+
expect(
29+
getByLabelText('Action menu for Reserved IP 203.0.113.5')
30+
).toBeVisible();
31+
});
32+
33+
it('calls onEdit when Edit is clicked', async () => {
34+
const ip = reservedIPsFactory.build();
35+
36+
const { getByLabelText, getByText } = renderWithTheme(
37+
<ReservedIpsActionMenu handlers={mockHandlers} ip={ip} />
38+
);
39+
40+
await userEvent.click(
41+
getByLabelText(`Action menu for Reserved IP ${ip.address}`)
42+
);
43+
await userEvent.click(getByText('Edit'));
44+
45+
expect(mockHandlers.onEdit).toHaveBeenCalledWith(ip);
46+
});
47+
48+
it('calls onUnreserve when Unreserve is clicked', async () => {
49+
const ip = reservedIPsFactory.build();
50+
51+
const { getByLabelText, getByText } = renderWithTheme(
52+
<ReservedIpsActionMenu handlers={mockHandlers} ip={ip} />
53+
);
54+
55+
await userEvent.click(
56+
getByLabelText(`Action menu for Reserved IP ${ip.address}`)
57+
);
58+
await userEvent.click(getByText('Unreserve'));
59+
60+
expect(mockHandlers.onUnreserve).toHaveBeenCalledWith(ip);
61+
});
62+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as React from 'react';
2+
3+
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
4+
5+
import type { IPAddress } from '@linode/api-v4';
6+
import type { Action } from 'src/components/ActionMenu/ActionMenu';
7+
8+
export interface ReservedIpsActionHandlers {
9+
onEdit: (ip: IPAddress) => void;
10+
onUnreserve: (ip: IPAddress) => void;
11+
}
12+
13+
interface Props {
14+
handlers: ReservedIpsActionHandlers;
15+
ip: IPAddress;
16+
}
17+
18+
export const ReservedIpsActionMenu = ({ handlers, ip }: Props) => {
19+
const actions: Action[] = [
20+
{
21+
onClick: () => handlers.onEdit(ip),
22+
title: 'Edit',
23+
},
24+
{
25+
onClick: () => handlers.onUnreserve(ip),
26+
title: 'Unreserve',
27+
},
28+
];
29+
30+
return (
31+
<ActionMenu
32+
actionsList={actions}
33+
ariaLabel={`Action menu for Reserved IP ${ip.address}`}
34+
/>
35+
);
36+
};
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react';
2+
import * as React from 'react';
3+
4+
import { reservedIPsFactory } from 'src/factories/networking';
5+
import { makeResourcePage } from 'src/mocks/serverHandlers';
6+
import { http, HttpResponse, server } from 'src/mocks/testServer';
7+
import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers';
8+
9+
import { ReservedIpsLanding } from './ReservedIpsLanding';
10+
import { headers } from './ReservedIpsLandingEmptyStateData';
11+
12+
const queryMocks = vi.hoisted(() => ({
13+
useProfile: vi.fn().mockReturnValue({ data: { restricted: false } }),
14+
}));
15+
16+
vi.mock('@linode/queries', async () => {
17+
const actual = await vi.importActual('@linode/queries');
18+
return {
19+
...actual,
20+
useProfile: queryMocks.useProfile,
21+
};
22+
});
23+
24+
beforeAll(() => mockMatchMedia());
25+
26+
const loadingTestId = 'circle-progress';
27+
const reservedIPsEndpoint = '*/networking/reserved/ips';
28+
29+
describe('Reserved IPs Landing', () => {
30+
it('renders loading state initially', async () => {
31+
server.use(
32+
http.get(reservedIPsEndpoint, () => {
33+
return HttpResponse.json(makeResourcePage([]));
34+
})
35+
);
36+
37+
const { getByTestId } = renderWithTheme(<ReservedIpsLanding />);
38+
39+
expect(getByTestId(loadingTestId)).toBeInTheDocument();
40+
41+
await waitForElementToBeRemoved(getByTestId(loadingTestId), {
42+
timeout: 3000,
43+
});
44+
});
45+
46+
it('renders the empty state when there are no reserved IPs', async () => {
47+
server.use(
48+
http.get(reservedIPsEndpoint, () => {
49+
return HttpResponse.json(makeResourcePage([]));
50+
})
51+
);
52+
53+
const { getByTestId, getByText } = renderWithTheme(<ReservedIpsLanding />);
54+
55+
await waitForElementToBeRemoved(getByTestId(loadingTestId));
56+
57+
expect(getByText(headers.description)).toBeInTheDocument();
58+
});
59+
60+
it('renders the table with reserved IPs', async () => {
61+
const reservedIPs = reservedIPsFactory.buildList(3, {
62+
region: 'us-east',
63+
reserved: true,
64+
});
65+
66+
server.use(
67+
http.get(reservedIPsEndpoint, () => {
68+
return HttpResponse.json(makeResourcePage(reservedIPs));
69+
})
70+
);
71+
72+
const { getAllByText, getByTestId, queryAllByText } = renderWithTheme(
73+
<ReservedIpsLanding />
74+
);
75+
76+
await waitForElementToBeRemoved(getByTestId(loadingTestId), {
77+
timeout: 3000,
78+
});
79+
80+
// Table column headers
81+
getAllByText('IP Address');
82+
getAllByText('Assigned Resource');
83+
getAllByText('Region');
84+
getAllByText('Tags');
85+
86+
// Check mocked IP addresses rendered in the table
87+
queryAllByText(reservedIPs[0].address);
88+
});
89+
90+
it('renders the "Reserve an IP Address" button', async () => {
91+
server.use(
92+
http.get(reservedIPsEndpoint, () => {
93+
return HttpResponse.json(makeResourcePage([]));
94+
})
95+
);
96+
97+
const { container, getByTestId } = renderWithTheme(<ReservedIpsLanding />);
98+
99+
await waitForElementToBeRemoved(getByTestId(loadingTestId));
100+
101+
const reserveIPButton = container.querySelector('button');
102+
103+
expect(reserveIPButton).toBeInTheDocument();
104+
expect(reserveIPButton).toHaveTextContent('Reserve an IP Address');
105+
});
106+
107+
it('renders a row with action menu for each reserved IP', async () => {
108+
const reservedIPs = reservedIPsFactory.buildList(3, {
109+
assigned_entity: null,
110+
reserved: true,
111+
});
112+
113+
server.use(
114+
http.get(reservedIPsEndpoint, () => {
115+
return HttpResponse.json(makeResourcePage(reservedIPs));
116+
})
117+
);
118+
119+
const { getByLabelText, getByTestId } = renderWithTheme(
120+
<ReservedIpsLanding />
121+
);
122+
123+
await waitForElementToBeRemoved(getByTestId(loadingTestId), {
124+
timeout: 3000,
125+
});
126+
127+
const actionMenu = getByLabelText(
128+
`Action menu for Reserved IP ${reservedIPs[0].address}`
129+
);
130+
expect(actionMenu).toBeInTheDocument();
131+
});
132+
133+
it('opens the action menu with correct options', async () => {
134+
const reservedIPs = reservedIPsFactory.buildList(1, {
135+
assigned_entity: null,
136+
reserved: true,
137+
});
138+
139+
server.use(
140+
http.get(reservedIPsEndpoint, () => {
141+
return HttpResponse.json(makeResourcePage(reservedIPs));
142+
})
143+
);
144+
145+
const { getByLabelText, getByTestId, getByText } = renderWithTheme(
146+
<ReservedIpsLanding />
147+
);
148+
149+
await waitForElementToBeRemoved(getByTestId(loadingTestId), {
150+
timeout: 3000,
151+
});
152+
153+
const actionMenu = getByLabelText(
154+
`Action menu for Reserved IP ${reservedIPs[0].address}`
155+
);
156+
157+
await fireEvent.click(actionMenu);
158+
159+
getByText('Edit');
160+
getByText('Unreserve');
161+
});
162+
163+
describe('Restricted users', () => {
164+
it('should have the "Reserve an IP Address" button disabled for restricted users', async () => {
165+
queryMocks.useProfile.mockReturnValue({ data: { restricted: true } });
166+
167+
server.use(
168+
http.get(reservedIPsEndpoint, () => {
169+
return HttpResponse.json(makeResourcePage([]));
170+
})
171+
);
172+
173+
const { container, getByTestId } = renderWithTheme(
174+
<ReservedIpsLanding />
175+
);
176+
177+
await waitForElementToBeRemoved(getByTestId(loadingTestId));
178+
179+
const reserveIPButton = container.querySelector('button');
180+
181+
expect(reserveIPButton).toBeInTheDocument();
182+
expect(reserveIPButton).toHaveTextContent('Reserve an IP Address');
183+
});
184+
185+
it('should have the "Reserve an IP Address" button enabled for users with full access', async () => {
186+
queryMocks.useProfile.mockReturnValue({ data: { restricted: false } });
187+
188+
server.use(
189+
http.get(reservedIPsEndpoint, () => {
190+
return HttpResponse.json(makeResourcePage([]));
191+
})
192+
);
193+
194+
const { container, getByTestId } = renderWithTheme(
195+
<ReservedIpsLanding />
196+
);
197+
198+
await waitForElementToBeRemoved(getByTestId(loadingTestId));
199+
200+
const reserveIPButton = container.querySelector('button');
201+
202+
expect(reserveIPButton).toBeInTheDocument();
203+
expect(reserveIPButton).toHaveTextContent('Reserve an IP Address');
204+
expect(reserveIPButton).toBeEnabled();
205+
});
206+
});
207+
});

0 commit comments

Comments
 (0)