Skip to content
Merged
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
---

Reserved IPs - Unreserve an IP address ([#13577](https://github.com/linode/manager/pull/13577))
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
import type { IPAddress } from '@linode/api-v4';

const preferenceKey = 'reserved-ips';
import { UnreserveIPDialog } from './UnreserveIPDialog';

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

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

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 🐢 Insert `⏎···` Raw Output: {"ruleId":"prettier/prettier","severity":1,"message":"Insert `⏎···`","line":28,"column":60,"nodeType":null,"messageId":"insert","endLine":28,"endColumn":60,"fix":{"range":[1261,1261],"text":"

const pagination = usePaginationV2({
currentRoute: '/reserved-ips',
Expand Down Expand Up @@ -73,10 +73,9 @@

const handlers: ReservedIpsActionHandlers = {
onEdit: (ip) => openDrawer('edit', ip),
onUnreserve: (_ip) => {
// TODO: Integrate Unreserve dialog
// setSelectedIP(ip);
// setIsUnreserveDialogOpen(true);
onUnreserve: (ip) => {
setSelectedIP(ip);
setIsUnreserveDialogOpen(true);
},
};

Expand Down Expand Up @@ -137,6 +136,16 @@
onClose={closeDrawer}
open={isDrawerOpen}
/>
{selectedIP && (
<UnreserveIPDialog
ipAddress={selectedIP}
onClose={() => {
setIsUnreserveDialogOpen(false);
setSelectedIP(undefined);
}}
open={isUnreserveDialogOpen}
/>
)}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';

import { ipAddressFactory } from 'src/factories';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { UnreserveIPDialog } from './UnreserveIPDialog';

const mockMutateAsync = vi.fn();
const mockReset = vi.fn();
const mockEnqueueSnackbar = vi.fn();
const mockOnClose = vi.fn();

const queryMocks = vi.hoisted(() => ({
useUnReserveIPMutation: vi.fn(),
}));

vi.mock('@linode/queries', async (importOriginal) => ({
...(await importOriginal()),
useUnReserveIPMutation: queryMocks.useUnReserveIPMutation,
}));

vi.mock('notistack', async (importOriginal) => ({
...(await importOriginal()),
useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }),
}));

const ipAddress = ipAddressFactory.build({ address: '203.0.113.10' });

const defaultProps = {
ipAddress,
onClose: mockOnClose,
open: true,
};

beforeEach(() => {
mockMutateAsync.mockReset();
mockReset.mockReset();
mockEnqueueSnackbar.mockReset();
mockOnClose.mockReset();

queryMocks.useUnReserveIPMutation.mockReturnValue({
isPending: false,
mutateAsync: mockMutateAsync,
reset: mockReset,
});
});

describe('UnreserveIPDialog', () => {
it('renders the dialog with the correct title', () => {
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);

expect(screen.getByText('Unreserve 203.0.113.10')).toBeVisible();
});

it('renders the confirmation message', () => {
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);

expect(
screen.getByText(
/Unreserving this IP will remove it from your reserved list/i
)
).toBeVisible();
});

it('renders the Unreserve and Cancel buttons', () => {
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);

expect(screen.getByRole('button', { name: 'Unreserve' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeVisible();
});

it('does not render when open is false', () => {
renderWithTheme(<UnreserveIPDialog {...defaultProps} open={false} />);

expect(screen.queryByText('Unreserve 203.0.113.10?')).toBeNull();
});

it('calls onClose when Cancel is clicked', async () => {
renderWithTheme(<UnreserveIPDialog {...defaultProps} />);

await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));

expect(mockOnClose).toHaveBeenCalled();
});

it('shows success snackbar, and closes on successful submit', async () => {
mockMutateAsync.mockResolvedValueOnce({});

renderWithTheme(<UnreserveIPDialog {...defaultProps} />);

await userEvent.click(screen.getByRole('button', { name: 'Unreserve' }));

await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled();
});
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
'203.0.113.10 has been unreserved.',
{ variant: 'success' }
);
expect(mockOnClose).toHaveBeenCalled();
});

it('shows an error notice when the API call fails', async () => {
mockMutateAsync.mockRejectedValueOnce([
{ reason: 'IP address could not be unreserved.' },
]);

renderWithTheme(<UnreserveIPDialog {...defaultProps} />);

await userEvent.click(screen.getByRole('button', { name: 'Unreserve' }));

await waitFor(() => {
expect(
screen.getByText('IP address could not be unreserved.')
).toBeVisible();
});
expect(mockOnClose).not.toHaveBeenCalled();
});

it('clears the error when the retry succeeds after a prior failure', async () => {
mockMutateAsync
.mockRejectedValueOnce([{ reason: 'Temporary network error.' }])
.mockResolvedValueOnce({});

renderWithTheme(<UnreserveIPDialog {...defaultProps} />);

// First attempt fails β€” error appears
await userEvent.click(screen.getByRole('button', { name: 'Unreserve' }));
await waitFor(() =>
expect(screen.getByText('Temporary network error.')).toBeVisible()
);

// Retry β€” should succeed and call onClose
await userEvent.click(screen.getByRole('button', { name: 'Unreserve' }));
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
});

it('disables both buttons while the request is pending', () => {
queryMocks.useUnReserveIPMutation.mockReturnValue({
isPending: true,
mutateAsync: mockMutateAsync,
reset: mockReset,
});

renderWithTheme(<UnreserveIPDialog {...defaultProps} />);

expect(screen.getByRole('button', { name: 'Unreserve' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useUnReserveIPMutation } from '@linode/queries';
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.

Let's update the casing of this to be useUnreserveIPMutation

import { ActionsPanel, Notice, Typography } from '@linode/ui';
import { useSnackbar } from 'notistack';
import * as React from 'react';

import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';

import type { IPAddress } from '@linode/api-v4';

interface Props {
ipAddress: IPAddress;
onClose: () => void;
open: boolean;
}

export const UnreserveIPDialog = (props: Props) => {
const { ipAddress, onClose, open } = props;
const { enqueueSnackbar } = useSnackbar();

const { isPending, mutateAsync, reset } = useUnReserveIPMutation(
ipAddress.address
);

const [error, setError] = React.useState<null | string>(null);

// Reset mutation state when dialog opens
React.useEffect(() => {
if (open) {
reset();
setError(null);
}
}, [open, reset]);

const handleSubmit = async () => {
try {
await mutateAsync();
enqueueSnackbar(`${ipAddress.address} has been unreserved.`, {
variant: 'success',
});
onClose();
} catch (err) {
setError(
getAPIErrorOrDefault(err, 'Failed to unreserve IP address.')[0]?.reason
);
}
};

return (
<ConfirmationDialog
actions={
<ActionsPanel
primaryButtonProps={{
disabled: isPending,
label: 'Unreserve',
loading: isPending,
onClick: handleSubmit,
}}
secondaryButtonProps={{
disabled: isPending,
label: 'Cancel',
onClick: onClose,
}}
sx={{ padding: 0 }}
/>
}
onClose={onClose}
open={open}
title={`Unreserve ${ipAddress.address}`}
>
{error && <Notice text={error} variant="error" />}
<Typography>
Unreserving this IP will remove it from your reserved list and make it
unavailable to assign. This action can&apos;t be undone.
</Typography>
</ConfirmationDialog>
);
};
Loading