Skip to content

Commit 5476c17

Browse files
upcoming: [UIE-10433] - Implement feature to Unreserve IP address (#13577)
1 parent 54393ec commit 5476c17

4 files changed

Lines changed: 250 additions & 6 deletions

File tree

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+
Reserved IPs - Unreserve an IP address ([#13577](https://github.com/linode/manager/pull/13577))

packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@ import type { ReservedIpsActionHandlers } from './ReservedIpsActionMenu';
1717
import type { IPAddress } from '@linode/api-v4';
1818

1919
const preferenceKey = 'reserved-ips';
20+
import { UnreserveIPDialog } from './UnreserveIPDialog';
2021

2122
export const ReservedIpsLanding = () => {
2223
const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);
2324
const [drawerMode, setDrawerMode] =
2425
React.useState<ReserveIPDrawerMode>('create');
2526
const [selectedIP, setSelectedIP] = React.useState<IPAddress | undefined>();
2627

27-
// TODO: Integrate Unreserve dialog
28-
// const [isUnreserveDialogOpen, setIsUnreserveDialogOpen] = React.useState(false);
28+
const [isUnreserveDialogOpen, setIsUnreserveDialogOpen] = React.useState(false);
2929

3030
const pagination = usePaginationV2({
3131
currentRoute: '/reserved-ips',
@@ -73,10 +73,9 @@ export const ReservedIpsLanding = () => {
7373

7474
const handlers: ReservedIpsActionHandlers = {
7575
onEdit: (ip) => openDrawer('edit', ip),
76-
onUnreserve: (_ip) => {
77-
// TODO: Integrate Unreserve dialog
78-
// setSelectedIP(ip);
79-
// setIsUnreserveDialogOpen(true);
76+
onUnreserve: (ip) => {
77+
setSelectedIP(ip);
78+
setIsUnreserveDialogOpen(true);
8079
},
8180
};
8281

@@ -137,6 +136,16 @@ export const ReservedIpsLanding = () => {
137136
onClose={closeDrawer}
138137
open={isDrawerOpen}
139138
/>
139+
{selectedIP && (
140+
<UnreserveIPDialog
141+
ipAddress={selectedIP}
142+
onClose={() => {
143+
setIsUnreserveDialogOpen(false);
144+
setSelectedIP(undefined);
145+
}}
146+
open={isUnreserveDialogOpen}
147+
/>
148+
)}
140149
</>
141150
);
142151
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import * as React from 'react';
4+
5+
import { ipAddressFactory } from 'src/factories';
6+
import { renderWithTheme } from 'src/utilities/testHelpers';
7+
8+
import { UnreserveIPDialog } from './UnreserveIPDialog';
9+
10+
const mockMutateAsync = vi.fn();
11+
const mockReset = vi.fn();
12+
const mockEnqueueSnackbar = vi.fn();
13+
const mockOnClose = vi.fn();
14+
15+
const queryMocks = vi.hoisted(() => ({
16+
useUnReserveIPMutation: vi.fn(),
17+
}));
18+
19+
vi.mock('@linode/queries', async (importOriginal) => ({
20+
...(await importOriginal()),
21+
useUnReserveIPMutation: queryMocks.useUnReserveIPMutation,
22+
}));
23+
24+
vi.mock('notistack', async (importOriginal) => ({
25+
...(await importOriginal()),
26+
useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }),
27+
}));
28+
29+
const ipAddress = ipAddressFactory.build({ address: '203.0.113.10' });
30+
31+
const defaultProps = {
32+
ipAddress,
33+
onClose: mockOnClose,
34+
open: true,
35+
};
36+
37+
beforeEach(() => {
38+
mockMutateAsync.mockReset();
39+
mockReset.mockReset();
40+
mockEnqueueSnackbar.mockReset();
41+
mockOnClose.mockReset();
42+
43+
queryMocks.useUnReserveIPMutation.mockReturnValue({
44+
isPending: false,
45+
mutateAsync: mockMutateAsync,
46+
reset: mockReset,
47+
});
48+
});
49+
50+
describe('UnreserveIPDialog', () => {
51+
it('renders the dialog with the correct title', () => {
52+
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);
53+
54+
expect(screen.getByText('Unreserve 203.0.113.10')).toBeVisible();
55+
});
56+
57+
it('renders the confirmation message', () => {
58+
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);
59+
60+
expect(
61+
screen.getByText(
62+
/Unreserving this IP will remove it from your reserved list/i
63+
)
64+
).toBeVisible();
65+
});
66+
67+
it('renders the Unreserve and Cancel buttons', () => {
68+
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);
69+
70+
expect(screen.getByRole('button', { name: 'Unreserve' })).toBeVisible();
71+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeVisible();
72+
});
73+
74+
it('does not render when open is false', () => {
75+
renderWithTheme(<UnreserveIPDialog {...defaultProps} open={false} />);
76+
77+
expect(screen.queryByText('Unreserve 203.0.113.10?')).toBeNull();
78+
});
79+
80+
it('calls onClose when Cancel is clicked', async () => {
81+
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);
82+
83+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
84+
85+
expect(mockOnClose).toHaveBeenCalled();
86+
});
87+
88+
it('shows success snackbar, and closes on successful submit', async () => {
89+
mockMutateAsync.mockResolvedValueOnce({});
90+
91+
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);
92+
93+
await userEvent.click(screen.getByRole('button', { name: 'Unreserve' }));
94+
95+
await waitFor(() => {
96+
expect(mockMutateAsync).toHaveBeenCalled();
97+
});
98+
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
99+
'203.0.113.10 has been unreserved.',
100+
{ variant: 'success' }
101+
);
102+
expect(mockOnClose).toHaveBeenCalled();
103+
});
104+
105+
it('shows an error notice when the API call fails', async () => {
106+
mockMutateAsync.mockRejectedValueOnce([
107+
{ reason: 'IP address could not be unreserved.' },
108+
]);
109+
110+
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);
111+
112+
await userEvent.click(screen.getByRole('button', { name: 'Unreserve' }));
113+
114+
await waitFor(() => {
115+
expect(
116+
screen.getByText('IP address could not be unreserved.')
117+
).toBeVisible();
118+
});
119+
expect(mockOnClose).not.toHaveBeenCalled();
120+
});
121+
122+
it('clears the error when the retry succeeds after a prior failure', async () => {
123+
mockMutateAsync
124+
.mockRejectedValueOnce([{ reason: 'Temporary network error.' }])
125+
.mockResolvedValueOnce({});
126+
127+
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);
128+
129+
// First attempt fails — error appears
130+
await userEvent.click(screen.getByRole('button', { name: 'Unreserve' }));
131+
await waitFor(() =>
132+
expect(screen.getByText('Temporary network error.')).toBeVisible()
133+
);
134+
135+
// Retry — should succeed and call onClose
136+
await userEvent.click(screen.getByRole('button', { name: 'Unreserve' }));
137+
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
138+
});
139+
140+
it('disables both buttons while the request is pending', () => {
141+
queryMocks.useUnReserveIPMutation.mockReturnValue({
142+
isPending: true,
143+
mutateAsync: mockMutateAsync,
144+
reset: mockReset,
145+
});
146+
147+
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);
148+
149+
expect(screen.getByRole('button', { name: 'Unreserve' })).toBeDisabled();
150+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
151+
});
152+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useUnReserveIPMutation } from '@linode/queries';
2+
import { ActionsPanel, Notice, Typography } from '@linode/ui';
3+
import { useSnackbar } from 'notistack';
4+
import * as React from 'react';
5+
6+
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
7+
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
8+
9+
import type { IPAddress } from '@linode/api-v4';
10+
11+
interface Props {
12+
ipAddress: IPAddress;
13+
onClose: () => void;
14+
open: boolean;
15+
}
16+
17+
export const UnreserveIPDialog = (props: Props) => {
18+
const { ipAddress, onClose, open } = props;
19+
const { enqueueSnackbar } = useSnackbar();
20+
21+
const { isPending, mutateAsync, reset } = useUnReserveIPMutation(
22+
ipAddress.address
23+
);
24+
25+
const [error, setError] = React.useState<null | string>(null);
26+
27+
// Reset mutation state when dialog opens
28+
React.useEffect(() => {
29+
if (open) {
30+
reset();
31+
setError(null);
32+
}
33+
}, [open, reset]);
34+
35+
const handleSubmit = async () => {
36+
try {
37+
await mutateAsync();
38+
enqueueSnackbar(`${ipAddress.address} has been unreserved.`, {
39+
variant: 'success',
40+
});
41+
onClose();
42+
} catch (err) {
43+
setError(
44+
getAPIErrorOrDefault(err, 'Failed to unreserve IP address.')[0]?.reason
45+
);
46+
}
47+
};
48+
49+
return (
50+
<ConfirmationDialog
51+
actions={
52+
<ActionsPanel
53+
primaryButtonProps={{
54+
disabled: isPending,
55+
label: 'Unreserve',
56+
loading: isPending,
57+
onClick: handleSubmit,
58+
}}
59+
secondaryButtonProps={{
60+
disabled: isPending,
61+
label: 'Cancel',
62+
onClick: onClose,
63+
}}
64+
sx={{ padding: 0 }}
65+
/>
66+
}
67+
onClose={onClose}
68+
open={open}
69+
title={`Unreserve ${ipAddress.address}`}
70+
>
71+
{error && <Notice text={error} variant="error" />}
72+
<Typography>
73+
Unreserving this IP will remove it from your reserved list and make it
74+
unavailable to assign. This action can&apos;t be undone.
75+
</Typography>
76+
</ConfirmationDialog>
77+
);
78+
};

0 commit comments

Comments
 (0)