Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Implemented Add Lock Dialog accesible from Linode action menu ([#13339](https://github.com/linode/manager/pull/13339))
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const LinodeExample: Story = {
<h2 style={{ margin: 0 }}>Linode Details: </h2>
<LinodeEntityDetail
handlers={{
onOpenAddLockDialog: action('onOpenAddLockDialog'),
onOpenDeleteDialog: action('onOpenDeleteDialog'),
onOpenMigrateDialog: action('onOpenMigrateDialog'),
onOpenPowerDialog: action('onOpenPowerDialog'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const Default: Story = {
transfer: 2000,
vcpus: 1,
}}
onOpenAddLockDialog={action('onOpenAddLockDialog')}
onOpenDeleteDialog={action('onOpenDeleteDialog')}
onOpenMigrateDialog={action('onOpenMigrateDialog')}
onOpenPowerDialog={action('onOpenPowerDialog')}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';

import { http, HttpResponse, server } from 'src/mocks/testServer';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { AddLockDialog } from './AddLockDialog';

const defaultProps = {
linodeId: 123,
linodeLabel: 'my-linode',
onClose: vi.fn(),
open: true,
};

describe('AddLockDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should render the dialog with correct title and content', () => {
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);

expect(getByText('Add lock?')).toBeVisible();
expect(getByText('Choose the type of lock to apply.')).toBeVisible();
expect(getByText('Apply Lock')).toBeVisible();

Check warning on line 27 in packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.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":22,"nodeType":"Literal","endLine":27,"endColumn":34}
expect(getByText('Cancel')).toBeVisible();
});

it('should display both lock type options', () => {
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);

expect(getByText('Prevent deletion')).toBeVisible();
expect(
getByText('Protects this Linode from being deleted or rebuilt.')
).toBeVisible();

expect(
getByText('Prevent deletion (including attached resources)')
).toBeVisible();
expect(
getByText(
'Protects this Linode and its attached resources (Disks, Configurations, IP Addresses, and Subinterfaces) from being deleted or rebuilt.'
)
).toBeVisible();
});

it('should have "Prevent deletion" selected by default', () => {
const { getByRole } = renderWithTheme(<AddLockDialog {...defaultProps} />);

const preventDeletionRadio = getByRole('radio', {
name: /Prevent deletion Protects this Linode from being deleted or rebuilt/i,
});

expect(preventDeletionRadio).toBeChecked();
});

it('should allow changing lock type selection', async () => {
const { getByRole } = renderWithTheme(<AddLockDialog {...defaultProps} />);

const preventDeletionWithSubresourcesRadio = getByRole('radio', {
name: /Prevent deletion \(including attached resources\)/i,
});

await userEvent.click(preventDeletionWithSubresourcesRadio);

expect(preventDeletionWithSubresourcesRadio).toBeChecked();
});

it('should call onClose when Cancel is clicked', async () => {
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);

const cancelButton = getByText('Cancel');
await userEvent.click(cancelButton);

expect(defaultProps.onClose).toHaveBeenCalled();
});

it('should submit with correct payload and call onClose on success', async () => {
let capturedPayload: unknown;

server.use(
http.post('*/v4beta/locks', async ({ request }) => {

Check warning on line 84 in packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.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":84,"column":17,"nodeType":"Literal","endLine":84,"endColumn":33}
capturedPayload = await request.json();
return HttpResponse.json({
created: '2026-01-28T00:00:00',
entity: {
id: 123,
label: 'my-linode',
type: 'linode',
url: '/v4beta/linodes/instances/123',
},
id: 1,
lock_type: 'cannot_delete',
});
})
);

const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);

const applyLockButton = getByText('Apply Lock');
await userEvent.click(applyLockButton);

await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});

expect(capturedPayload).toEqual({
entity_id: 123,
entity_type: 'linode',
lock_type: 'cannot_delete',
});
});

it('should submit with cannot_delete_with_subresources when selected', async () => {
let capturedPayload: unknown;

server.use(
http.post('*/v4beta/locks', async ({ request }) => {
capturedPayload = await request.json();
return HttpResponse.json({
created: '2026-01-28T00:00:00',
entity: {
id: 123,
label: 'my-linode',
type: 'linode',
url: '/v4beta/linodes/instances/123',
},
id: 1,
lock_type: 'cannot_delete_with_subresources',
});
})
);

const { getByRole, getByText } = renderWithTheme(
<AddLockDialog {...defaultProps} />
);

const preventDeletionWithSubresourcesRadio = getByRole('radio', {
name: /Prevent deletion \(including attached resources\)/i,
});
await userEvent.click(preventDeletionWithSubresourcesRadio);

const applyLockButton = getByText('Apply Lock');
await userEvent.click(applyLockButton);

await waitFor(() => {
expect(capturedPayload).toEqual({
entity_id: 123,
entity_type: 'linode',
lock_type: 'cannot_delete_with_subresources',
});
});
});

it('should reset state when dialog is reopened', async () => {
const { getByRole, rerender } = renderWithTheme(
<AddLockDialog {...defaultProps} />
);

// Select the second option
const preventDeletionWithSubresourcesRadio = getByRole('radio', {
name: /Prevent deletion \(including attached resources\)/i,
});
await userEvent.click(preventDeletionWithSubresourcesRadio);
expect(preventDeletionWithSubresourcesRadio).toBeChecked();

// Close and reopen the dialog
rerender(<AddLockDialog {...defaultProps} open={false} />);
rerender(<AddLockDialog {...defaultProps} open={true} />);

// The default option should be selected again
const preventDeletionRadio = getByRole('radio', {
name: /Prevent deletion Protects this Linode from being deleted or rebuilt/i,
});
expect(preventDeletionRadio).toBeChecked();
});

it('should not render dialog content when open is false', () => {
const { queryByText } = renderWithTheme(
<AddLockDialog {...defaultProps} open={false} />
);

expect(queryByText('Add lock?')).not.toBeInTheDocument();
});

it('should not submit if linodeId is undefined', async () => {
const mockOnClose = vi.fn();

server.use(
http.post('*/v4beta/locks', () => {
return HttpResponse.json({});
})
);

const { getByText } = renderWithTheme(
<AddLockDialog
{...defaultProps}
linodeId={undefined}
onClose={mockOnClose}
/>
);

const applyLockButton = getByText('Apply Lock');
await userEvent.click(applyLockButton);

// Wait a bit to ensure no async operation completes
await new Promise((resolve) => setTimeout(resolve, 100));

// onClose should not have been called since the submission should be blocked
expect(mockOnClose).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useCreateLockMutation } from '@linode/queries';
import {
ActionsPanel,
FormControlLabel,
Notice,
Radio,
RadioGroup,
Stack,
Typography,
} from '@linode/ui';
import { styled, useTheme } from '@mui/material';
import { useSnackbar } from 'notistack';
import * as React from 'react';

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

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

interface Props {
linodeId: number | undefined;
linodeLabel: string | undefined;
onClose: () => void;
open: boolean;
}

export const AddLockDialog = (props: Props) => {
const { linodeId, linodeLabel, onClose, open } = props;
const { enqueueSnackbar } = useSnackbar();

const [lockType, setLockType] = React.useState<LockType>('cannot_delete');

const { error, isPending, mutateAsync, reset } = useCreateLockMutation();

const handleLockTypeChange = (
_e: React.ChangeEvent<HTMLInputElement>,
value: string
) => {
setLockType(value as LockType);
};

React.useEffect(() => {
if (open) {
reset();
setLockType('cannot_delete');
}
}, [open, reset]);

const handleSubmit = async () => {
if (!linodeId) {
return;
}

await mutateAsync({
entity_id: linodeId,
entity_type: 'linode',
lock_type: lockType,
});

enqueueSnackbar(`Lock applied to ${linodeLabel}.`, {
variant: 'success',
});
onClose();
};

const errorMessage = error
? getAPIErrorOrDefault(error, 'Failed to apply lock.')[0].reason
: undefined;

return (
<ConfirmationDialog
actions={
<ActionsPanel
primaryButtonProps={{
disabled: isPending,
label: 'Apply Lock',
loading: isPending,
onClick: handleSubmit,
}}
secondaryButtonProps={{
disabled: isPending,
label: 'Cancel',
onClick: onClose,
}}
sx={{ padding: 0 }}
/>
}
onClose={onClose}
open={open}
title="Add lock?"
>
{errorMessage && <Notice text={errorMessage} variant="error" />}
<StyledHeading>Choose the type of lock to apply.</StyledHeading>
<RadioGroup
name="lock-type"
onChange={handleLockTypeChange}
value={lockType}
>
<Stack alignItems="flex-start" direction="column" spacing={2}>
<FormControlLabel
control={<Radio />}
label={
<LockOptionLabel
description="Protects this Linode from being deleted or rebuilt."
title="Prevent deletion"
/>
}
value="cannot_delete"
/>
<FormControlLabel
control={<Radio />}
label={
<LockOptionLabel
description="Protects this Linode and its attached resources (Disks, Configurations, IP Addresses, and Subinterfaces) from being deleted or rebuilt."
title="Prevent deletion (including attached resources)"
/>
}
value="cannot_delete_with_subresources"
/>
</Stack>
</RadioGroup>
</ConfirmationDialog>
);
};

interface LockOptionLabelProps {
description: string;
title: string;
}

const LockOptionLabel = ({ description, title }: LockOptionLabelProps) => {
const theme = useTheme();

return (
<span>
<Typography sx={{ font: theme.tokens.alias.Typography.Body.Semibold }}>
{title}
</Typography>
<Typography
sx={{ color: theme.tokens.alias.Content.Text.Secondary.Default }}
>
{description}
</Typography>
</span>
);
};

const StyledHeading = styled(Typography)(({ theme }) => ({
font: theme.tokens.alias.Typography.Heading.S,
marginBottom: theme.tokens.spacing.S20,
}));
Loading