-
Notifications
You must be signed in to change notification settings - Fork 399
upcoming: [UIE-9741] - Implement Add Lock Modal. #13339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
tanushree-akamai
merged 8 commits into
linode:develop
from
tanushree-akamai:feature/UIE-9741-resprot2-add-lock-modal
Feb 2, 2026
Merged
Changes from 2 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
19087ec
upcoming: [UIE-9741] - Implement Add Lock Modal.
tanushree-akamai 359c594
Added changeset: Implemented Add Lock Dialog accesible from Linode acβ¦
tanushree-akamai a4f19d8
Address review comments.
tanushree-akamai 18eda43
Remove LKE cluster validation as individual Linodes can now be lockedβ¦
tanushree-akamai 422b6b3
Update text as per UX recommendation.
tanushree-akamai 41264ee
Address review comments from Ganesh.
tanushree-akamai f76f65f
Update packages/manager/.changeset/pr-13339-upcoming-features-1769622β¦
tanushree-akamai 648c913
Merge branch 'develop' into feature/UIE-9741-resprot2-add-lock-modal
tanushree-akamai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
5 changes: 5 additions & 0 deletions
5
packages/manager/.changeset/pr-13339-upcoming-features-1769622645991.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
214 changes: 214 additions & 0 deletions
214
packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
|
||
| 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.') | ||
tanushree-akamai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ).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
|
||
| 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(); | ||
| }); | ||
| }); | ||
151 changes: 151 additions & 0 deletions
151
packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
tanushree-akamai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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, | ||
| })); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.