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,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(<ReservedIpsLanding />);

expect(getByTestId(loadingTestId)).toBeInTheDocument();

Check warning on line 39 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":39,"column":12,"nodeType":"Identifier","messageId":"preferImplicitAssert","endLine":39,"endColumn":23}

await waitForElementToBeRemoved(getByTestId(loadingTestId), {

Check warning on line 41 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 🐢 Prefer using queryBy* when waiting for disappearance Raw Output: {"ruleId":"testing-library/prefer-query-by-disappearance","severity":1,"message":"Prefer using queryBy* when waiting for disappearance","line":41,"column":37,"nodeType":"Identifier","messageId":"preferQueryByDisappearance","endLine":41,"endColumn":48}
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(<ReservedIpsLanding />);

await waitForElementToBeRemoved(getByTestId(loadingTestId));

Check warning on line 55 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 🐢 Prefer using queryBy* when waiting for disappearance Raw Output: {"ruleId":"testing-library/prefer-query-by-disappearance","severity":1,"message":"Prefer using queryBy* when waiting for disappearance","line":55,"column":37,"nodeType":"Identifier","messageId":"preferQueryByDisappearance","endLine":55,"endColumn":48}

expect(getByText(headers.description)).toBeInTheDocument();

Check warning on line 57 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":57,"column":12,"nodeType":"Identifier","messageId":"preferImplicitAssert","endLine":57,"endColumn":21}
});

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(
<ReservedIpsLanding />
);

await waitForElementToBeRemoved(getByTestId(loadingTestId), {

Check warning on line 76 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 🐢 Prefer using queryBy* when waiting for disappearance Raw Output: {"ruleId":"testing-library/prefer-query-by-disappearance","severity":1,"message":"Prefer using queryBy* when waiting for disappearance","line":76,"column":37,"nodeType":"Identifier","messageId":"preferQueryByDisappearance","endLine":76,"endColumn":48}
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(<ReservedIpsLanding />);

await waitForElementToBeRemoved(getByTestId(loadingTestId));

Check warning on line 99 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 🐢 Prefer using queryBy* when waiting for disappearance Raw Output: {"ruleId":"testing-library/prefer-query-by-disappearance","severity":1,"message":"Prefer using queryBy* when waiting for disappearance","line":99,"column":37,"nodeType":"Identifier","messageId":"preferQueryByDisappearance","endLine":99,"endColumn":48}

const reserveIPButton = container.querySelector('button');

Check warning on line 101 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 🐢 Avoid direct Node access. Prefer using the methods from Testing Library. Raw Output: {"ruleId":"testing-library/no-node-access","severity":1,"message":"Avoid direct Node access. Prefer using the methods from Testing Library.","line":101,"column":39,"nodeType":"MemberExpression","messageId":"noNodeAccess"}

Check warning on line 101 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 🐢 Avoid direct Node access. Prefer using the methods from Testing Library. Raw Output: {"ruleId":"testing-library/no-node-access","severity":1,"message":"Avoid direct Node access. Prefer using the methods from Testing Library.","line":101,"column":39,"nodeType":"MemberExpression","messageId":"noNodeAccess"}

Check warning on line 101 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 🐢 Avoid using container methods. Prefer using the methods from Testing Library, such as "getByRole()" Raw Output: {"ruleId":"testing-library/no-container","severity":1,"message":"Avoid using container methods. Prefer using the methods from Testing Library, such as \"getByRole()\"","line":101,"column":29,"nodeType":"MemberExpression","messageId":"noContainer","endLine":101,"endColumn":52}

expect(reserveIPButton).toBeInTheDocument();
expect(reserveIPButton).toHaveTextContent('Reserve an IP Address');

Check warning on line 104 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 3 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 3 times.","line":104,"column":47,"nodeType":"Literal","endLine":104,"endColumn":70}
});

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(
<ReservedIpsLanding />
);

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(
<ReservedIpsLanding />
);

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(
<ReservedIpsLanding />
);

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(
<ReservedIpsLanding />
);

await waitForElementToBeRemoved(getByTestId(loadingTestId));

const reserveIPButton = container.querySelector('button');

expect(reserveIPButton).toBeInTheDocument();
expect(reserveIPButton).toHaveTextContent('Reserve an IP Address');
expect(reserveIPButton).toBeEnabled();
});
});
});
Loading
Loading