Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Implemented Remove Lock Dialog from Linode Action Menu ([#13348](https://github.com/linode/manager/pull/13348))
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const LinodeExample: Story = {
onOpenMigrateDialog: action('onOpenMigrateDialog'),
onOpenPowerDialog: action('onOpenPowerDialog'),
onOpenRebuildDialog: action('onOpenRebuildDialog'),
onOpenRemoveLockDialog: action('onOpenRemoveLockDialog'),
onOpenRescueDialog: action('onOpenRescueDialog'),
onOpenResizeDialog: action('onOpenResizeDialog'),
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const Default: Story = {
}}
linodeId={12434}
linodeLabel="linode-001"
linodeLocks={['cannot_delete']}
linodeRegion="us-east"
linodeStatus="running"
linodeType={{
Expand Down Expand Up @@ -105,6 +106,7 @@ export const Default: Story = {
onOpenMigrateDialog={action('onOpenMigrateDialog')}
onOpenPowerDialog={action('onOpenPowerDialog')}
onOpenRebuildDialog={action('onOpenRebuildDialog')}
onOpenRemoveLockDialog={action('onOpenRemoveLockDialog')}
onOpenRescueDialog={action('onOpenRescueDialog')}
onOpenResizeDialog={action('onOpenResizeDialog')}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export const LinodeEntityDetail = (props: Props) => {
isSummaryView={isSummaryView}
linodeId={linode.id}
linodeLabel={linode.label}
linodeLocks={linode.locks}
linodeMaintenancePolicySet={
linode.maintenance?.maintenance_policy_set ??
linode.maintenance_policy // Attempt to use ongoing maintenance policy. Otherwise, fallback to policy set on Linode.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
MaintenancePolicySlug,
} from '@linode/api-v4';
import type { Linode, LinodeType } from '@linode/api-v4/lib/linodes/types';
import type { LockType } from '@linode/api-v4/lib/locks/types';
import type { TypographyProps } from '@linode/ui';
import type { LinodeMaintenance } from 'src/utilities/linodes';

Expand All @@ -47,6 +48,7 @@ export interface HeaderProps {
isSummaryView?: boolean;
linodeId: number;
linodeLabel: string;
linodeLocks: LockType[];
linodeMaintenancePolicySet: MaintenancePolicySlug | undefined;
linodeRegionDisplay: string;
linodeStatus: Linode['status'];
Expand Down Expand Up @@ -76,6 +78,7 @@ export const LinodeEntityDetailHeader = (
linodeLabel,
linodeRegionDisplay,
linodeStatus,
linodeLocks,
linodeMaintenancePolicySet,
maintenance,
openNotificationMenu,
Expand Down Expand Up @@ -182,6 +185,7 @@ export const LinodeEntityDetailHeader = (
linodeBackups={backups}
linodeId={linodeId}
linodeLabel={linodeLabel}
linodeLocks={linodeLocks}
linodeRegion={linodeRegionDisplay}
linodeStatus={linodeStatus}
linodeType={type ?? undefined}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';

import { lockFactory } from 'src/factories/locks';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { RemoveLockDialog } from './RemoveLockDialog';

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

const mockGetLocks = vi.fn();
const mockDeleteLock = vi.fn();
const mockEnqueueSnackbar = vi.fn();

vi.mock('@linode/api-v4', async () => {
const actual = await vi.importActual('@linode/api-v4');
return {
...actual,
deleteLock: () => mockDeleteLock(),
getLocks: () => mockGetLocks(),
};
});

vi.mock('notistack', async () => {
const actual = await vi.importActual('notistack');
return {
...actual,
useSnackbar: () => ({
enqueueSnackbar: mockEnqueueSnackbar,
}),
};
});

const defaultProps = {
linodeId: 1,
linodeLabel: 'test-linode',
linodeLocks: ['cannot_delete'] as LockType[],
onClose: vi.fn(),
open: true,
};

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

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

expect(getByText('Remove Lock?')).toBeVisible();
});

it('should display correct description for cannot_delete lock type', () => {
const { getByText } = renderWithTheme(
<RemoveLockDialog {...defaultProps} linodeLocks={['cannot_delete']} />
);

expect(
getByText('Unlocking will allow this Linode to be deleted or rebuilt.')
).toBeVisible();
});

it('should display correct description for cannot_delete_with_subresources lock type', () => {
const { getByText } = renderWithTheme(
<RemoveLockDialog
{...defaultProps}
linodeLocks={['cannot_delete_with_subresources']}
/>
);

expect(
getByText(
'Unlocking will allow this Linode and all its attached resources to be deleted or rebuilt.'
)
).toBeVisible();
});

it('should have Remove Lock and Cancel buttons', () => {
const { getByText } = renderWithTheme(
<RemoveLockDialog {...defaultProps} />
);

expect(getByText('Remove Lock')).toBeVisible();
expect(getByText('Cancel')).toBeVisible();
});

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

await userEvent.click(getByText('Cancel'));

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

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

expect(queryByText('Remove Lock?')).toBeNull();
});

it('should fetch locks and delete lock on submit', async () => {
const lock = lockFactory.build({
entity: { id: 1, type: 'linode' },
id: 123,
});

mockGetLocks.mockResolvedValueOnce({
data: [lock],
page: 1,
pages: 1,
results: 1,
});
mockDeleteLock.mockResolvedValueOnce({});

const onClose = vi.fn();
const { getByText } = renderWithTheme(
<RemoveLockDialog {...defaultProps} onClose={onClose} />
);

await userEvent.click(getByText('Remove Lock'));

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

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

await waitFor(() => {
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
'Lock removed from test-linode.',
{ variant: 'success' }
);
});

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

it('should show error when no lock is found', async () => {
mockGetLocks.mockResolvedValueOnce({
data: [],
page: 1,
pages: 1,
results: 0,
});

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

await userEvent.click(getByText('Remove Lock'));

await waitFor(() => {
expect(getByText('No active lock found for this Linode.')).toBeVisible();
});
});

it('should show error when getLocks fails', async () => {
mockGetLocks.mockRejectedValueOnce([{ reason: 'Failed to fetch locks' }]);

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

await userEvent.click(getByText('Remove Lock'));

await waitFor(() => {
expect(getByText('Failed to fetch locks')).toBeVisible();
});
});

it('should show error when deleteLock fails', async () => {
const lock = lockFactory.build({
entity: { id: 1, type: 'linode' },
id: 123,
});

mockGetLocks.mockResolvedValueOnce({
data: [lock],
page: 1,
pages: 1,
results: 1,
});
mockDeleteLock.mockRejectedValueOnce([{ reason: 'Failed to delete lock' }]);

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

await userEvent.click(getByText('Remove Lock'));

await waitFor(() => {
expect(getByText('Failed to delete lock')).toBeVisible();
});
});

it('should disable buttons while loading', async () => {
// Make getLocks hang to simulate loading state
mockGetLocks.mockImplementation(
() => new Promise(() => {}) // Never resolves
);

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

await userEvent.click(getByText('Remove Lock'));

await waitFor(() => {
expect(getByText('Remove Lock').closest('button')).toBeDisabled();
expect(getByText('Cancel').closest('button')).toBeDisabled();
});
});

it('should reset error state when dialog reopens', async () => {
mockGetLocks.mockResolvedValueOnce({
data: [],
page: 1,
pages: 1,
results: 0,
});

const { getByText, rerender, queryByText } = renderWithTheme(
<RemoveLockDialog {...defaultProps} />
);

// Trigger error
await userEvent.click(getByText('Remove Lock'));

await waitFor(() => {
expect(getByText('No active lock found for this Linode.')).toBeVisible();
});

// Close dialog
rerender(<RemoveLockDialog {...defaultProps} open={false} />);

// Reopen dialog
rerender(<RemoveLockDialog {...defaultProps} open={true} />);

// Error should be cleared
expect(queryByText('No active lock found for this Linode.')).toBeNull();
});
});
Loading