diff --git a/packages/manager/.changeset/pr-13339-upcoming-features-1769622645991.md b/packages/manager/.changeset/pr-13339-upcoming-features-1769622645991.md new file mode 100644 index 00000000000..d65b25520f2 --- /dev/null +++ b/packages/manager/.changeset/pr-13339-upcoming-features-1769622645991.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implemented Add Lock Dialog accessible from Linode action menu ([#13339](https://github.com/linode/manager/pull/13339)) diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx index d9cb9f55a56..1fd835d3f3e 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx @@ -29,6 +29,7 @@ export const LinodeExample: Story = {

Linode Details:

{ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the dialog with correct title and content', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Add lock?')).toBeVisible(); + expect(getByText('Choose the type of lock to apply.')).toBeVisible(); + expect(getByText('Apply Lock')).toBeVisible(); + expect(getByText('Cancel')).toBeVisible(); + }); + + it('should display both lock type options', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Prevent deletion')).toBeVisible(); + expect( + getByText('Prevents this Linode from being deleted or rebuilt.') + ).toBeVisible(); + + expect( + getByText('Prevent deletion (including attached resources)') + ).toBeVisible(); + expect( + getByText( + 'Prevents 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(); + + const preventDeletionRadio = getByRole('radio', { + name: /Prevent deletion Prevents this Linode from being deleted or rebuilt/i, + }); + + expect(preventDeletionRadio).toBeChecked(); + }); + + it('should allow changing lock type selection', async () => { + const { getByRole } = renderWithTheme(); + + 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(); + + 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 }) => { + 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(); + + 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( + + ); + + 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( + + ); + + // 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(); + rerender(); + + // The default option should be selected again + const preventDeletionRadio = getByRole('radio', { + name: /Prevent deletion Prevents this Linode from being deleted or rebuilt/i, + }); + expect(preventDeletionRadio).toBeChecked(); + }); + + it('should not render dialog content when open is false', () => { + const { queryByText } = renderWithTheme( + + ); + + 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( + + ); + + 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(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.tsx new file mode 100644 index 00000000000..4fa76251777 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.tsx @@ -0,0 +1,167 @@ +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('cannot_delete'); + + const { error, isPending, mutateAsync, reset } = useCreateLockMutation(); + + const handleLockTypeChange = ( + _e: React.ChangeEvent, + 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 ( + + } + onClose={onClose} + open={open} + title="Add lock?" + > + {errorMessage && } + Choose the type of lock to apply. + + + } + label={ + + } + value="cannot_delete" + /> + } + label={ + + } + value="cannot_delete_with_subresources" + /> + + + + ); +}; + +interface LockOptionLabelProps { + description: string; + title: string; +} + +const LockOptionLabel = ({ description, title }: LockOptionLabelProps) => { + const theme = useTheme(); + + return ( + + + {title} + + + {description} + + + ); +}; + +/** + * Custom styled FormControlLabel to align the radio button with multi-line labels. + * Since our labels have a title and description (two lines), the MUI default alignment doesn't work for us. + * We use `alignItems: flex-start` to position the radio button at the top. The `marginTop: 10` on the label + * compensates for the radio button's internal padding, ensuring the label text aligns visually + * with the radio button. + */ +const RadioButtonAlignedFormControlLabel = styled(FormControlLabel)( + ({ theme }) => ({ + alignItems: 'flex-start', + '& .MuiFormControlLabel-label': { + marginTop: 10, + }, + }) +); + +const StyledHeading = styled(Typography)(({ theme }) => ({ + font: theme.tokens.alias.Typography.Heading.S, + marginBottom: theme.tokens.spacing.S20, +})); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx index 6b603bea58a..d8eddcc24ea 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx @@ -22,6 +22,7 @@ import { addMaintenanceToLinodes } from 'src/utilities/linodes'; import { DeleteLinodeDialog } from '../../LinodesLanding/DeleteLinodeDialog'; import { EnableBackupsDialog } from '../LinodeBackup/EnableBackupsDialog'; +import { AddLockDialog } from '../LinodeLock/AddLockDialog'; import { LinodeRebuildDialog } from '../LinodeRebuild/LinodeRebuildDialog'; import { RescueDialog } from '../LinodeRescue/RescueDialog'; import { LinodeResize } from '../LinodeResize/LinodeResize'; @@ -63,6 +64,7 @@ export const LinodeDetailHeader = () => { ); const [powerAction, setPowerAction] = React.useState('Reboot'); const [powerDialogOpen, setPowerDialogOpen] = React.useState(false); + const [addLockDialogOpen, setAddLockDialogOpen] = React.useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(search.delete); const [rebuildDialogOpen, setRebuildDialogOpen] = React.useState( search.rebuild @@ -89,6 +91,7 @@ export const LinodeDetailHeader = () => { navigate({ search: undefined }); } + setAddLockDialogOpen(false); setPowerDialogOpen(false); setDeleteDialogOpen(false); setResizeDialogOpen(false); @@ -134,6 +137,10 @@ export const LinodeDetailHeader = () => { setPowerAction(action); }; + const onOpenAddLockDialog = () => { + setAddLockDialogOpen(true); + }; + const onOpenDeleteDialog = () => { setDeleteDialogOpen(true); }; @@ -155,6 +162,7 @@ export const LinodeDetailHeader = () => { }; const handlers = { + onOpenAddLockDialog, onOpenDeleteDialog, onOpenMigrateDialog, onOpenPowerDialog, @@ -219,6 +227,12 @@ export const LinodeDetailHeader = () => { linodeLabel={linode.label} onClose={closeDialogs} /> + { + openDialog('add_lock', linode.id, linode.label), onOpenDeleteDialog: () => openDialog('delete', linode.id, linode.label), onOpenMigrateDialog: () => diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx index 3c94974d57d..e1761c63d7b 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx @@ -18,6 +18,7 @@ const props: LinodeActionMenuProps = { linodeRegion: 'us-east', linodeStatus: 'running', linodeType: extendedTypes[0], + onOpenAddLockDialog: vi.fn(), onOpenDeleteDialog: vi.fn(), onOpenMigrateDialog: vi.fn(), onOpenPowerDialog: vi.fn(), diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx index 9790af2da33..4843faf6256 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx @@ -256,7 +256,7 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { // props.onOpenUnlockDialog(); } else { sendLinodeActionMenuItemEvent('Lock Linode'); - // props.onOpenAddLockDialog(); + props.onOpenAddLockDialog(); } }, title: isLocked ? 'Unlock' : 'Lock', diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx index d1c9937f99b..c11a5bd9c0c 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx @@ -33,6 +33,7 @@ describe('LinodeRow', () => { const renderedLinode = ( {}, onOpenDeleteDialog: () => {}, onOpenMigrateDialog: () => {}, onOpenPowerDialog: () => {}, diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index 82c8e6027d2..386d91735e4 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -22,6 +22,7 @@ import { } from 'src/utilities/analytics/customEventAnalytics'; import { EnableBackupsDialog } from '../LinodesDetail/LinodeBackup/EnableBackupsDialog'; +import { AddLockDialog } from '../LinodesDetail/LinodeLock/AddLockDialog'; import { LinodeRebuildDialog } from '../LinodesDetail/LinodeRebuild/LinodeRebuildDialog'; import { RescueDialog } from '../LinodesDetail/LinodeRescue/RescueDialog'; import { LinodeResize } from '../LinodesDetail/LinodeResize/LinodeResize'; @@ -54,6 +55,7 @@ import type { LinodeWithMaintenance } from 'src/utilities/linodes'; import type { RegionFilter } from 'src/utilities/storage'; interface State { + addLockDialogOpen: boolean; deleteDialogOpen: boolean; enableBackupsDialogOpen: boolean; groupByTag: boolean; @@ -69,6 +71,7 @@ interface State { } export interface LinodeHandlers { + onOpenAddLockDialog: () => void; onOpenDeleteDialog: () => void; onOpenMigrateDialog: () => void; onOpenPowerDialog: (action: Action) => void; @@ -109,6 +112,7 @@ type CombinedProps = LinodesLandingProps & class ListLinodes extends React.Component { state: State = { + addLockDialogOpen: false, deleteDialogOpen: false, enableBackupsDialogOpen: false, groupByTag: false, @@ -133,6 +137,7 @@ class ListLinodes extends React.Component { closeDialogs = () => { this.setState({ + addLockDialogOpen: false, deleteDialogOpen: false, enableBackupsDialogOpen: false, linodeMigrateOpen: false, @@ -143,8 +148,21 @@ class ListLinodes extends React.Component { }); }; + openAddLockDialog = (linodeID: number, linodeLabel: string) => { + this.setState({ + addLockDialogOpen: true, + selectedLinodeID: linodeID, + selectedLinodeLabel: linodeLabel, + }); + }; + openDialog = (type: DialogType, linodeID: number, linodeLabel?: string) => { switch (type) { + case 'add_lock': + this.setState({ + addLockDialogOpen: true, + }); + break; case 'delete': this.setState({ deleteDialogOpen: true, @@ -217,6 +235,7 @@ class ListLinodes extends React.Component { : undefined; const componentProps = { + openAddLockDialog: this.openAddLockDialog, openDialog: this.openDialog, openPowerActionDialog: this.openPowerDialog, someLinodesHaveMaintenance: @@ -317,6 +336,12 @@ class ListLinodes extends React.Component { onClose={this.closeDialogs} open={this.state.deleteDialogOpen} /> + )} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx index 7a6b45406d1..a2160ad8a43 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx @@ -21,6 +21,8 @@ export const ListView = (props: RenderLinodesProps) => { {data.map((linode, idx: number) => ( + openDialog('add_lock', linode.id, linode.label), onOpenDeleteDialog: () => openDialog('delete', linode.id, linode.label), onOpenMigrateDialog: () => diff --git a/packages/manager/src/features/Linodes/types.ts b/packages/manager/src/features/Linodes/types.ts index ce7295268c1..85b06cd0be2 100644 --- a/packages/manager/src/features/Linodes/types.ts +++ b/packages/manager/src/features/Linodes/types.ts @@ -1,6 +1,7 @@ import type { BaseQueryParams, LinodeCreateType } from '@linode/utilities'; export type DialogType = + | 'add_lock' | 'delete' | 'detach_vlan' | 'enable_backups' diff --git a/packages/manager/src/mocks/presets/crud/handlers/locks.ts b/packages/manager/src/mocks/presets/crud/handlers/locks.ts index 5b1b6ca0dca..1f7126625c0 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/locks.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/locks.ts @@ -10,12 +10,11 @@ * Update ENTITY_TYPE_CONFIG below with the new resource type mapping * */ -import { http } from 'msw'; +import { http, HttpResponse } from 'msw'; import { lockFactory } from 'src/factories'; import { queueEvents } from 'src/mocks/utilities/events'; import { - makeErrorResponse, makeNotFoundResponse, makePaginatedResponse, makeResponse, @@ -24,6 +23,7 @@ import { import { mswDB } from '../../../indexedDB'; import type { + APIError, CreateLockPayload, Entity, LockType, @@ -53,20 +53,20 @@ const ENTITY_TYPE_CONFIG: Record< const validateLockCreation = async ( payload: CreateLockPayload, mockState: MockState -): Promise => { +): Promise => { const { entity_id, entity_type, lock_type } = payload; // Check if entity type is supported const entityConfig = ENTITY_TYPE_CONFIG[entity_type]; if (!entityConfig) { - return `Unsupported entity type: ${entity_type}`; + return { reason: `Unsupported entity type: ${entity_type}` }; } // Check if entity exists and is accessible const entities = await mswDB.getAll(entityConfig.store); const entity = entities?.find((e: Entity) => e.id === entity_id); if (!entity) { - return 'The specified entity could not be found.'; + return { reason: 'The specified entity could not be found.' }; } // Check if entity already has a lock of conflicting type @@ -87,7 +87,11 @@ const validateLockCreation = async ( lock.lock_type === 'cannot_delete_with_subresources' ); if (hasDeleteLock) { - return 'This resource already has a lock. Only one delete protection lock is allowed at a time.'; + return { + field: 'lock_type', + reason: + 'This resource already has a lock. Only one delete protection lock is allowed at a time.', + }; } } } @@ -225,7 +229,17 @@ export const createLock = (mockState: MockState) => [ // Validate the lock creation const validationError = await validateLockCreation(payload, mockState); if (validationError) { - return makeErrorResponse(validationError, 400); + return HttpResponse.json( + { + errors: [ + { + ...(validationError.field && { field: validationError.field }), + reason: validationError.reason, + }, + ], + }, + { status: 400 } + ); } // Get entity configuration for URL building