Skip to content

Commit b8f3057

Browse files
upcoming: [UIE-9742] - Implement disabling of required functionalities when a linode is locked. (#13377)
* upcoming: [UIE-9742] - Implement disabling of required functionalities when a linode is locked. * Added changeset: Implements disabling of delete and rebuild actions when a Linode has active locks * upcoming: [UIE-9742] - Demo feedback to add linode label to Add Lock and Remove Lock modal titles. * Addressed review comments from bnussman-akamai * Addressed review comments from Ganesh.
1 parent c7799cf commit b8f3057

19 files changed

+442
-30
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+
Implements disabling of delete and rebuild actions when a Linode has active locks ([#13377](https://github.com/linode/manager/pull/13377))

packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.test.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as React from 'react';
44

55
import { NO_PERMISSION_TOOLTIP_TEXT } from 'src/constants';
66
import { linodeConfigFactory } from 'src/factories';
7+
import { LINODE_LOCKED_DELETE_CONFIG_TOOLTIP } from 'src/features/Linodes/constants';
78
import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers';
89

910
import { ConfigActionMenu } from './LinodeConfigActionMenu';
@@ -28,12 +29,23 @@ const queryMocks = vi.hoisted(() => ({
2829
},
2930
})),
3031
useNavigate: vi.fn(() => navigate),
32+
useLinodeQuery: vi.fn().mockReturnValue({
33+
data: { locks: [] as string[] },
34+
}),
3135
}));
3236

3337
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
3438
usePermissions: queryMocks.userPermissions,
3539
}));
3640

41+
vi.mock('@linode/queries', async () => {
42+
const actual = await vi.importActual('@linode/queries');
43+
return {
44+
...actual,
45+
useLinodeQuery: queryMocks.useLinodeQuery,
46+
};
47+
});
48+
3749
const defaultProps = {
3850
config: linodeConfigFactory.build(),
3951
linodeId: 0,
@@ -114,4 +126,107 @@ describe('ConfigActionMenu', () => {
114126
const deleteBtn = screen.getByTestId('Delete');
115127
expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true');
116128
});
129+
130+
describe('Lock functionality', () => {
131+
it('should disable Delete action when Linode is locked', async () => {
132+
queryMocks.userPermissions.mockReturnValue({
133+
data: {
134+
reboot_linode: true,
135+
update_linode: true,
136+
clone_linode: true,
137+
delete_linode: true,
138+
},
139+
});
140+
queryMocks.useLinodeQuery.mockReturnValue({
141+
data: { locks: ['cannot_delete_with_subresources'] },
142+
});
143+
144+
renderWithTheme(<ConfigActionMenu {...defaultProps} />);
145+
146+
const actionBtn = screen.getByRole('button');
147+
await userEvent.click(actionBtn);
148+
149+
const deleteBtn = screen.getByTestId('Delete');
150+
expect(deleteBtn).toHaveAttribute('aria-disabled', 'true');
151+
});
152+
153+
it('should show lock tooltip for Delete when Linode is locked', async () => {
154+
queryMocks.userPermissions.mockReturnValue({
155+
data: {
156+
reboot_linode: true,
157+
update_linode: true,
158+
clone_linode: true,
159+
delete_linode: true,
160+
},
161+
});
162+
queryMocks.useLinodeQuery.mockReturnValue({
163+
data: { locks: ['cannot_delete_with_subresources'] },
164+
});
165+
166+
renderWithTheme(<ConfigActionMenu {...defaultProps} />);
167+
168+
const actionBtn = screen.getByRole('button');
169+
await userEvent.click(actionBtn);
170+
171+
const tooltip = screen.getByLabelText(
172+
LINODE_LOCKED_DELETE_CONFIG_TOOLTIP
173+
);
174+
expect(tooltip).toBeInTheDocument();
175+
});
176+
177+
it('should enable Delete action when Linode is not locked', async () => {
178+
queryMocks.userPermissions.mockReturnValue({
179+
data: {
180+
reboot_linode: true,
181+
update_linode: true,
182+
clone_linode: true,
183+
delete_linode: true,
184+
},
185+
});
186+
queryMocks.useLinodeQuery.mockReturnValue({
187+
data: { locks: [] },
188+
});
189+
190+
renderWithTheme(<ConfigActionMenu {...defaultProps} />);
191+
192+
const actionBtn = screen.getByRole('button');
193+
await userEvent.click(actionBtn);
194+
195+
const deleteBtn = screen.getByTestId('Delete');
196+
expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true');
197+
});
198+
199+
it('should not affect other actions when Linode is locked', async () => {
200+
queryMocks.userPermissions.mockReturnValue({
201+
data: {
202+
reboot_linode: true,
203+
update_linode: true,
204+
clone_linode: true,
205+
delete_linode: true,
206+
},
207+
});
208+
queryMocks.useLinodeQuery.mockReturnValue({
209+
data: { locks: ['cannot_delete'] },
210+
});
211+
212+
renderWithTheme(<ConfigActionMenu {...defaultProps} />);
213+
214+
const actionBtn = screen.getByRole('button');
215+
await userEvent.click(actionBtn);
216+
217+
// Boot, Edit, Clone should still be enabled
218+
expect(screen.getByTestId('Boot')).not.toHaveAttribute(
219+
'aria-disabled',
220+
'true'
221+
);
222+
expect(screen.getByTestId('Edit')).not.toHaveAttribute(
223+
'aria-disabled',
224+
'true'
225+
);
226+
expect(screen.getByTestId('Clone')).not.toHaveAttribute(
227+
'aria-disabled',
228+
'true'
229+
);
230+
});
231+
});
117232
});

packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { useLinodeQuery } from '@linode/queries';
12
import { useNavigate } from '@tanstack/react-router';
23
import * as React from 'react';
34

45
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
56
import { NO_PERMISSION_TOOLTIP_TEXT } from 'src/constants';
67
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
8+
import { LINODE_LOCKED_DELETE_CONFIG_TOOLTIP } from 'src/features/Linodes/constants';
79

810
import type { Config } from '@linode/api-v4/lib/linodes';
911
import type { Action } from 'src/components/ActionMenu/ActionMenu';
@@ -22,6 +24,10 @@ export const ConfigActionMenu = (props: Props) => {
2224
const [isOpen, setIsOpen] = React.useState<boolean>(false);
2325
const navigate = useNavigate();
2426

27+
const { data: linode } = useLinodeQuery(linodeId);
28+
const isLinodeSubResourcesLocked =
29+
linode?.locks?.includes('cannot_delete_with_subresources') ?? false;
30+
2531
const { data: permissions, isLoading } = usePermissions(
2632
'linode',
2733
['reboot_linode', 'update_linode', 'clone_linode', 'delete_linode'],
@@ -63,12 +69,14 @@ export const ConfigActionMenu = (props: Props) => {
6369
: undefined,
6470
},
6571
{
66-
disabled: !permissions.delete_linode,
72+
disabled: !permissions.delete_linode || isLinodeSubResourcesLocked,
6773
onClick: onDelete,
6874
title: 'Delete',
69-
tooltip: !permissions.delete_linode
70-
? NO_PERMISSION_TOOLTIP_TEXT
71-
: undefined,
75+
tooltip: isLinodeSubResourcesLocked
76+
? LINODE_LOCKED_DELETE_CONFIG_TOOLTIP
77+
: !permissions.delete_linode
78+
? NO_PERMISSION_TOOLTIP_TEXT
79+
: undefined,
7280
},
7381
];
7482

packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ describe('AddLockDialog', () => {
2222
it('should render the dialog with correct title and content', () => {
2323
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);
2424

25-
expect(getByText('Add lock?')).toBeVisible();
25+
expect(getByText('Add lock to my-linode?')).toBeVisible();
2626
expect(getByText('Choose the type of lock to apply.')).toBeVisible();
2727
expect(getByText('Apply Lock')).toBeVisible();
2828
expect(getByText('Cancel')).toBeVisible();
@@ -182,7 +182,7 @@ describe('AddLockDialog', () => {
182182
<AddLockDialog {...defaultProps} open={false} />
183183
);
184184

185-
expect(queryByText('Add lock?')).not.toBeInTheDocument();
185+
expect(queryByText('Add lock to my-linode?')).not.toBeInTheDocument();
186186
});
187187

188188
it('should not submit if linodeId is undefined', async () => {

packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const AddLockDialog = (props: Props) => {
8787
}
8888
onClose={onClose}
8989
open={open}
90-
title="Add lock?"
90+
title={`Add lock to ${linodeLabel ?? ''}?`}
9191
>
9292
{errorMessage && <Notice text={errorMessage} variant="error" />}
9393
<StyledHeading>Choose the type of lock to apply.</StyledHeading>

packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/RemoveLockDialog.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('RemoveLockDialog', () => {
5050
<RemoveLockDialog {...defaultProps} />
5151
);
5252

53-
expect(getByText('Remove Lock?')).toBeVisible();
53+
expect(getByText('Remove lock from test-linode?')).toBeVisible();
5454
});
5555

5656
it('should display correct description for cannot_delete lock type', () => {
@@ -103,7 +103,7 @@ describe('RemoveLockDialog', () => {
103103
<RemoveLockDialog {...defaultProps} open={false} />
104104
);
105105

106-
expect(queryByText('Remove Lock?')).toBeNull();
106+
expect(queryByText('Remove lock from test-linode?')).toBeNull();
107107
});
108108

109109
it('should fetch locks and delete lock on submit', async () => {

packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/RemoveLockDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const RemoveLockDialog = (props: Props) => {
9898
}
9999
onClose={onClose}
100100
open={open}
101-
title="Remove Lock?"
101+
title={`Remove lock from ${linodeLabel ?? ''}?`}
102102
>
103103
{localError && <Notice text={localError} variant="error" />}
104104
<Typography>{getLockTypeDescription(linodeLocks)}</Typography>

packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ export const LinodeIPAddressRow = (props: LinodeIPAddressRowProps) => {
5858
} = props;
5959

6060
const { data: ips } = useLinodeIPsQuery(linodeId);
61+
const { data: linode } = useLinodeQuery(linodeId);
62+
const isLinodeSubResourcesLocked =
63+
linode?.locks?.includes('cannot_delete_with_subresources') ?? false;
6164
const { data: maskSensitiveDataPreference } = usePreferences(
6265
(preferences) => preferences?.maskSensitiveData
6366
);
@@ -111,6 +114,7 @@ export const LinodeIPAddressRow = (props: LinodeIPAddressRowProps) => {
111114
ipAddress={_ip}
112115
ipType={type}
113116
isLinodeInterface={isLinodeInterface}
117+
isLinodeSubResourcesLocked={isLinodeSubResourcesLocked}
114118
isOnlyPublicIP={isOnlyPublicIP}
115119
onEdit={handleOpenEditRDNS}
116120
onRemove={openRemoveIPDialog}
@@ -123,6 +127,7 @@ export const LinodeIPAddressRow = (props: LinodeIPAddressRowProps) => {
123127
ipAddress={_range}
124128
ipType={type}
125129
isLinodeInterface={isLinodeInterface}
130+
isLinodeSubResourcesLocked={isLinodeSubResourcesLocked}
126131
isOnlyPublicIP={isOnlyPublicIP}
127132
onEdit={() => handleOpenEditRDNSForRange(_range)}
128133
onRemove={openRemoveIPRangeDialog}

packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceActionMenu.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import { useLinodeQuery } from '@linode/queries';
12
import React from 'react';
23

34
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
5+
import { LINODE_LOCKED_DELETE_INTERFACE_TOOLTIP } from 'src/features/Linodes/constants';
46

57
import type { LinodeInterfaceType } from './utilities';
68

79
interface Props {
810
handlers: InterfaceActionHandlers;
911
id: number;
12+
linodeId: number;
1013
type: LinodeInterfaceType;
1114
}
1215

@@ -17,7 +20,11 @@ export interface InterfaceActionHandlers {
1720
}
1821

1922
export const LinodeInterfaceActionMenu = (props: Props) => {
20-
const { handlers, id, type } = props;
23+
const { handlers, id, linodeId, type } = props;
24+
25+
const { data: linode } = useLinodeQuery(linodeId);
26+
const isLinodeSubResourcesLocked =
27+
linode?.locks?.includes('cannot_delete_with_subresources') ?? false;
2128

2229
const editOptions =
2330
type === 'VLAN'
@@ -34,7 +41,14 @@ export const LinodeInterfaceActionMenu = (props: Props) => {
3441
title: 'Edit',
3542
...editOptions,
3643
},
37-
{ onClick: () => handlers.onDelete(id), title: 'Delete' },
44+
{
45+
disabled: isLinodeSubResourcesLocked,
46+
onClick: () => handlers.onDelete(id),
47+
title: 'Delete',
48+
tooltip: isLinodeSubResourcesLocked
49+
? LINODE_LOCKED_DELETE_INTERFACE_TOOLTIP
50+
: undefined,
51+
},
3852
];
3953

4054
return (

packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceTableRow.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ export const LinodeInterfaceTableRow = (props: Props) => {
5454
</TableCell>
5555
</Hidden>
5656
<TableCell actionCell>
57-
<LinodeInterfaceActionMenu handlers={handlers} id={id} type={type} />
57+
<LinodeInterfaceActionMenu
58+
handlers={handlers}
59+
id={id}
60+
linodeId={linodeId}
61+
type={type}
62+
/>
5863
</TableCell>
5964
</TableRow>
6065
);

0 commit comments

Comments
 (0)