Skip to content

Commit 0b2a2da

Browse files
upcoming: [UIE-9741] - Implement Add Lock Modal. (#13339)
* upcoming: [UIE-9741] - Implement Add Lock Modal. * Added changeset: Implemented Add Lock Dialog accesible from Linode action menu * Address review comments. * Remove LKE cluster validation as individual Linodes can now be locked/unlocked irrespective of LKE association. * Update text as per UX recommendation. * Address review comments from Ganesh. * Update packages/manager/.changeset/pr-13339-upcoming-features-1769622645991.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent d9c2563 commit 0b2a2da

File tree

14 files changed

+456
-8
lines changed

14 files changed

+456
-8
lines changed
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 Add Lock Dialog accessible from Linode action menu ([#13339](https://github.com/linode/manager/pull/13339))

packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const LinodeExample: Story = {
2929
<h2 style={{ margin: 0 }}>Linode Details: </h2>
3030
<LinodeEntityDetail
3131
handlers={{
32+
onOpenAddLockDialog: action('onOpenAddLockDialog'),
3233
onOpenDeleteDialog: action('onOpenDeleteDialog'),
3334
onOpenMigrateDialog: action('onOpenMigrateDialog'),
3435
onOpenPowerDialog: action('onOpenPowerDialog'),

packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export const Default: Story = {
100100
transfer: 2000,
101101
vcpus: 1,
102102
}}
103+
onOpenAddLockDialog={action('onOpenAddLockDialog')}
103104
onOpenDeleteDialog={action('onOpenDeleteDialog')}
104105
onOpenMigrateDialog={action('onOpenMigrateDialog')}
105106
onOpenPowerDialog={action('onOpenPowerDialog')}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import * as React from 'react';
4+
5+
import { http, HttpResponse, server } from 'src/mocks/testServer';
6+
import { renderWithTheme } from 'src/utilities/testHelpers';
7+
8+
import { AddLockDialog } from './AddLockDialog';
9+
10+
const defaultProps = {
11+
linodeId: 123,
12+
linodeLabel: 'my-linode',
13+
onClose: vi.fn(),
14+
open: true,
15+
};
16+
17+
describe('AddLockDialog', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
});
21+
22+
it('should render the dialog with correct title and content', () => {
23+
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);
24+
25+
expect(getByText('Add lock?')).toBeVisible();
26+
expect(getByText('Choose the type of lock to apply.')).toBeVisible();
27+
expect(getByText('Apply Lock')).toBeVisible();
28+
expect(getByText('Cancel')).toBeVisible();
29+
});
30+
31+
it('should display both lock type options', () => {
32+
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);
33+
34+
expect(getByText('Prevent deletion')).toBeVisible();
35+
expect(
36+
getByText('Prevents this Linode from being deleted or rebuilt.')
37+
).toBeVisible();
38+
39+
expect(
40+
getByText('Prevent deletion (including attached resources)')
41+
).toBeVisible();
42+
expect(
43+
getByText(
44+
'Prevents this Linode and its attached resources (Disks, Configurations, IP Addresses, and Subinterfaces) from being deleted or rebuilt.'
45+
)
46+
).toBeVisible();
47+
});
48+
49+
it('should have "Prevent deletion" selected by default', () => {
50+
const { getByRole } = renderWithTheme(<AddLockDialog {...defaultProps} />);
51+
52+
const preventDeletionRadio = getByRole('radio', {
53+
name: /Prevent deletion Prevents this Linode from being deleted or rebuilt/i,
54+
});
55+
56+
expect(preventDeletionRadio).toBeChecked();
57+
});
58+
59+
it('should allow changing lock type selection', async () => {
60+
const { getByRole } = renderWithTheme(<AddLockDialog {...defaultProps} />);
61+
62+
const preventDeletionWithSubresourcesRadio = getByRole('radio', {
63+
name: /Prevent deletion \(including attached resources\)/i,
64+
});
65+
66+
await userEvent.click(preventDeletionWithSubresourcesRadio);
67+
68+
expect(preventDeletionWithSubresourcesRadio).toBeChecked();
69+
});
70+
71+
it('should call onClose when Cancel is clicked', async () => {
72+
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);
73+
74+
const cancelButton = getByText('Cancel');
75+
await userEvent.click(cancelButton);
76+
77+
expect(defaultProps.onClose).toHaveBeenCalled();
78+
});
79+
80+
it('should submit with correct payload and call onClose on success', async () => {
81+
let capturedPayload: unknown;
82+
83+
server.use(
84+
http.post('*/v4beta/locks', async ({ request }) => {
85+
capturedPayload = await request.json();
86+
return HttpResponse.json({
87+
created: '2026-01-28T00:00:00',
88+
entity: {
89+
id: 123,
90+
label: 'my-linode',
91+
type: 'linode',
92+
url: '/v4beta/linodes/instances/123',
93+
},
94+
id: 1,
95+
lock_type: 'cannot_delete',
96+
});
97+
})
98+
);
99+
100+
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);
101+
102+
const applyLockButton = getByText('Apply Lock');
103+
await userEvent.click(applyLockButton);
104+
105+
await waitFor(() => {
106+
expect(defaultProps.onClose).toHaveBeenCalled();
107+
});
108+
109+
expect(capturedPayload).toEqual({
110+
entity_id: 123,
111+
entity_type: 'linode',
112+
lock_type: 'cannot_delete',
113+
});
114+
});
115+
116+
it('should submit with cannot_delete_with_subresources when selected', async () => {
117+
let capturedPayload: unknown;
118+
119+
server.use(
120+
http.post('*/v4beta/locks', async ({ request }) => {
121+
capturedPayload = await request.json();
122+
return HttpResponse.json({
123+
created: '2026-01-28T00:00:00',
124+
entity: {
125+
id: 123,
126+
label: 'my-linode',
127+
type: 'linode',
128+
url: '/v4beta/linodes/instances/123',
129+
},
130+
id: 1,
131+
lock_type: 'cannot_delete_with_subresources',
132+
});
133+
})
134+
);
135+
136+
const { getByRole, getByText } = renderWithTheme(
137+
<AddLockDialog {...defaultProps} />
138+
);
139+
140+
const preventDeletionWithSubresourcesRadio = getByRole('radio', {
141+
name: /Prevent deletion \(including attached resources\)/i,
142+
});
143+
await userEvent.click(preventDeletionWithSubresourcesRadio);
144+
145+
const applyLockButton = getByText('Apply Lock');
146+
await userEvent.click(applyLockButton);
147+
148+
await waitFor(() => {
149+
expect(capturedPayload).toEqual({
150+
entity_id: 123,
151+
entity_type: 'linode',
152+
lock_type: 'cannot_delete_with_subresources',
153+
});
154+
});
155+
});
156+
157+
it('should reset state when dialog is reopened', async () => {
158+
const { getByRole, rerender } = renderWithTheme(
159+
<AddLockDialog {...defaultProps} />
160+
);
161+
162+
// Select the second option
163+
const preventDeletionWithSubresourcesRadio = getByRole('radio', {
164+
name: /Prevent deletion \(including attached resources\)/i,
165+
});
166+
await userEvent.click(preventDeletionWithSubresourcesRadio);
167+
expect(preventDeletionWithSubresourcesRadio).toBeChecked();
168+
169+
// Close and reopen the dialog
170+
rerender(<AddLockDialog {...defaultProps} open={false} />);
171+
rerender(<AddLockDialog {...defaultProps} open={true} />);
172+
173+
// The default option should be selected again
174+
const preventDeletionRadio = getByRole('radio', {
175+
name: /Prevent deletion Prevents this Linode from being deleted or rebuilt/i,
176+
});
177+
expect(preventDeletionRadio).toBeChecked();
178+
});
179+
180+
it('should not render dialog content when open is false', () => {
181+
const { queryByText } = renderWithTheme(
182+
<AddLockDialog {...defaultProps} open={false} />
183+
);
184+
185+
expect(queryByText('Add lock?')).not.toBeInTheDocument();
186+
});
187+
188+
it('should not submit if linodeId is undefined', async () => {
189+
const mockOnClose = vi.fn();
190+
191+
server.use(
192+
http.post('*/v4beta/locks', () => {
193+
return HttpResponse.json({});
194+
})
195+
);
196+
197+
const { getByText } = renderWithTheme(
198+
<AddLockDialog
199+
{...defaultProps}
200+
linodeId={undefined}
201+
onClose={mockOnClose}
202+
/>
203+
);
204+
205+
const applyLockButton = getByText('Apply Lock');
206+
await userEvent.click(applyLockButton);
207+
208+
// Wait a bit to ensure no async operation completes
209+
await new Promise((resolve) => setTimeout(resolve, 100));
210+
211+
// onClose should not have been called since the submission should be blocked
212+
expect(mockOnClose).not.toHaveBeenCalled();
213+
});
214+
});

0 commit comments

Comments
 (0)