Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
2 changes: 1 addition & 1 deletion apps/app-frontend/src/shell/StorageRoutes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('StorageRoutes', () => {
it('redirects the bare /admin/infrastructure/storage path to the Backends tab', () => {
renderAt('/admin/infrastructure/storage');

expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends');
expect(screen.getByRole('button', { name: 'Create backend' })).toBeInTheDocument();
});

it('renders a placeholder for backends/create', () => {
Expand Down
6 changes: 6 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
"CPU cores": "CPU cores",
"Create": "Create",
"Create an instance type to start defining provider-managed sizes.": "Create an instance type to start defining provider-managed sizes.",
"Create backend": "Create backend",
"Create cluster": "Create cluster",
"Create cluster wizard": "Create cluster wizard",
"Create identity provider": "Create identity provider",
Expand Down Expand Up @@ -174,6 +175,7 @@
"Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.": "Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.",
"Enable": "Enable",
"Enable {{idpName}}?": "Enable {{idpName}}?",
"Endpoint": "Endpoint",
"Error": "Error",
"Error loading external IP pools": "Error loading external IP pools",
"Error loading virtual networks": "Error loading virtual networks",
Expand All @@ -199,6 +201,7 @@
"Failed to delete cluster": "Failed to delete cluster",
"Failed to delete compute instance": "Failed to delete compute instance",
"Failed to delete Identity provider": "Failed to delete Identity provider",
"Failed to delete storage backend": "Failed to delete storage backend",
"Failed to delete storage tier": "Failed to delete storage tier",
"Failed to delete tenant": "Failed to delete tenant",
"Failed to disable Identity provider": "Failed to disable Identity provider",
Expand Down Expand Up @@ -298,6 +301,7 @@
"No published catalog items are available yet.": "No published catalog items are available yet.",
"No security groups match your search.": "No security groups match your search.",
"No security groups yet. Create one to get started.": "No security groups yet. Create one to get started.",
"No storage backends yet. Create one to get started.": "No storage backends yet. Create one to get started.",
"No storage tiers yet. Create one to get started.": "No storage tiers yet. Create one to get started.",
"No subnets yet. Create one to get started.": "No subnets yet. Create one to get started.",
"No tenants match your search.": "No tenants match your search.",
Expand Down Expand Up @@ -338,6 +342,7 @@
"Protocol": "Protocol",
"Protocol is required": "Protocol is required",
"Protocol(s)": "Protocol(s)",
"Provider": "Provider",
"Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.",
"Provision bare metal": "Provision bare metal",
"Provisioning": "Provisioning",
Expand Down Expand Up @@ -410,6 +415,7 @@
"This permanently deletes the cluster and all its resources. This action cannot be undone.": "This permanently deletes the cluster and all its resources. This action cannot be undone.",
"This permanently deletes the compute instance. This action cannot be undone.": "This permanently deletes the compute instance. This action cannot be undone.",
"This permanently deletes the Identity provider and all its resources. This action cannot be undone.": "This permanently deletes the Identity provider and all its resources. This action cannot be undone.",
"This permanently deletes the storage backend. This action cannot be undone.": "This permanently deletes the storage backend. This action cannot be undone.",
"This permanently deletes the storage tier. This action cannot be undone.": "This permanently deletes the storage tier. This action cannot be undone.",
"This permanently deletes the tenant and all its resources. This action cannot be undone.": "This permanently deletes the tenant and all its resources. This action cannot be undone.",
"This will permanently delete the rule. This action cannot be undone. Traffic matching this rule will be blocked.": "This will permanently delete the rule. This action cannot be undone. Traffic matching this rule will be blocked.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Dropdown, DropdownItem, DropdownList, MenuToggle } from '@patternfly/react-core';
import { EllipsisVIcon } from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon';

import type { StorageBackend } from '@osac/types/private';

import StorageBackendDeleteConfirmModal from './StorageBackendDeleteConfirmModal';
import { useTranslation } from '../../hooks/useTranslation';

interface StorageBackendActionsMenuProps {
backend: StorageBackend;
}

const StorageBackendActionsMenu = ({ backend }: StorageBackendActionsMenuProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);

const backendName = backend.metadata?.name ?? backend.id;

return (
<>
{deleteOpen && (
<StorageBackendDeleteConfirmModal
backend={backend}
onClose={() => setDeleteOpen(false)}
onSuccess={() => setDeleteOpen(false)}
/>
)}
<Dropdown
isOpen={open}
onOpenChange={setOpen}
toggle={(ref) => (
<MenuToggle
ref={ref}
variant="plain"
onClick={() => setOpen((o) => !o)}
isExpanded={open}
aria-label={t('Actions for {{name}}', { name: backendName })}
>
<EllipsisVIcon />
</MenuToggle>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
popperProps={{ position: 'right' }}
>
<DropdownList>
<DropdownItem
onClick={() => navigate(`/admin/infrastructure/storage/backends/${backend.id}/edit`)}
>
{t('Edit')}
</DropdownItem>
<DropdownItem
value="delete"
onClick={() => {
setDeleteOpen(true);
setOpen(false);
}}
>
{t('Delete')}
</DropdownItem>
</DropdownList>
</Dropdown>
</>
);
};

export default StorageBackendActionsMenu;
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import StorageBackendDeleteConfirmModal from './StorageBackendDeleteConfirmModal';
import * as storageBackendsApi from '../../api/v1/private/storage-backends';

vi.mock('../../api/v1/private/storage-backends', async (importOriginal) => {
const actual = await importOriginal<typeof storageBackendsApi>();
return {
...actual,
useDeleteStorageBackend: vi.fn(),
};
});

const mockBackend = {
id: 'backend-1',
metadata: { name: 'vast-prod' },
spec: { provider: 'vast', endpoint: 'vast.example.com' },
};

describe('StorageBackendDeleteConfirmModal', () => {
const mutate = vi.fn();
const reset = vi.fn();

beforeEach(() => {
vi.resetAllMocks();
vi.mocked(storageBackendsApi.useDeleteStorageBackend).mockReturnValue({
mutate,
reset,
isPending: false,
error: null,
} as unknown as ReturnType<typeof storageBackendsApi.useDeleteStorageBackend>);
});

it('deletes the backend and calls onSuccess', async () => {
const user = userEvent.setup();
mutate.mockImplementation((_id: string, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.();
return Promise.resolve(undefined);
});
const onSuccess = vi.fn();

render(
<StorageBackendDeleteConfirmModal
backend={mockBackend as never}
onClose={vi.fn()}
onSuccess={onSuccess}
/>,
);

expect(screen.getByRole('dialog')).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: /^Delete$/i }));

await waitFor(() => {
expect(mutate).toHaveBeenCalledWith('backend-1', {
onSuccess: expect.any(Function) as unknown,
});
expect(onSuccess).toHaveBeenCalled();
});
});

it('shows the FAILED_PRECONDITION error verbatim and does not call onSuccess', async () => {
const user = userEvent.setup();
vi.mocked(storageBackendsApi.useDeleteStorageBackend).mockReturnValue({
mutate,
reset,
isPending: false,
error: new Error('[failed_precondition] backend is referenced by an active storage tier'),
} as unknown as ReturnType<typeof storageBackendsApi.useDeleteStorageBackend>);
const onSuccess = vi.fn();

render(
<StorageBackendDeleteConfirmModal
backend={mockBackend as never}
onClose={vi.fn()}
onSuccess={onSuccess}
/>,
);

await user.click(screen.getByRole('button', { name: /^Delete$/i }));

await waitFor(() => {
expect(
screen.getByText(/backend is referenced by an active storage tier/i),
).toBeInTheDocument();
});
expect(onSuccess).not.toHaveBeenCalled();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('calls onClose when Cancel is clicked', async () => {
const user = userEvent.setup();
const onClose = vi.fn();

render(
<StorageBackendDeleteConfirmModal
backend={mockBackend as never}
onClose={onClose}
onSuccess={vi.fn()}
/>,
);

await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
Alert,
Button,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Stack,
StackItem,
} from '@patternfly/react-core';

import type { StorageBackend } from '@osac/types/private';

import { useDeleteStorageBackend } from '../../api/v1/private/storage-backends';
import { useTranslation } from '../../hooks/useTranslation';
import { getErrorMessage } from '../../utils/error';

interface StorageBackendDeleteConfirmModalProps {
backend: StorageBackend;
onClose: () => void;
onSuccess: () => void;
}

const StorageBackendDeleteConfirmModal = ({
backend,
onClose,
onSuccess,
}: StorageBackendDeleteConfirmModalProps) => {
const { t } = useTranslation();
const { mutate, isPending, error } = useDeleteStorageBackend();

const backendName = backend.metadata?.name ?? backend.id;

return (
<Modal
variant="small"
isOpen
onClose={isPending ? undefined : onClose}
aria-labelledby="storage-backend-delete-confirm-title"
>
<ModalHeader
title={t('Delete {{name}}?', { name: backendName })}
titleIconVariant="warning"
labelId="storage-backend-delete-confirm-title"
/>
<ModalBody>
<Stack hasGutter>
<StackItem>
{t('This permanently deletes the storage backend. This action cannot be undone.')}
</StackItem>
{error && (
<StackItem>
<Alert variant="danger" title={t('Failed to delete storage backend')} isInline>
{getErrorMessage(error)}
</Alert>
</StackItem>
)}
</Stack>
</ModalBody>
<ModalFooter>
<Button variant="link" onClick={onClose} isDisabled={isPending}>
{t('Cancel')}
</Button>
<Button
variant="danger"
onClick={() => mutate(backend.id, { onSuccess })}
isDisabled={isPending}
isLoading={isPending}
>
{t('Delete')}
</Button>
</ModalFooter>
</Modal>
);
};

export default StorageBackendDeleteConfirmModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { StorageBackendState } from '@osac/types/private';

import StorageBackendStatusLabel from './StorageBackendStatusLabel';
import { renderWithProviders } from '../../test-utils/TestProviders';

describe('StorageBackendStatusLabel', () => {
it('maps READY to ready/Ready', () => {
renderWithProviders(<StorageBackendStatusLabel state={StorageBackendState.READY} />);
expect(screen.getByText('Ready')).toBeInTheDocument();
});

it('maps UNSPECIFIED to unspecified/Unspecified', () => {
renderWithProviders(<StorageBackendStatusLabel state={StorageBackendState.UNSPECIFIED} />);
expect(screen.getByText('Unspecified')).toBeInTheDocument();
});

it('maps undefined to unspecified/Unspecified', () => {
renderWithProviders(<StorageBackendStatusLabel />);
expect(screen.getByText('Unspecified')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { TFunction } from 'i18next';

import { StorageBackendState } from '@osac/types/private';

import { useTranslation } from '../../hooks/useTranslation';
import { ResourceStatusLabel, StatusLabelProps } from '../Resource/ResourceStatusLabel';

interface StorageBackendStatusLabelProps {
state?: StorageBackendState;
}

const storageBackendStatusMap = (t: TFunction): Record<StorageBackendState, StatusLabelProps> => ({
[StorageBackendState.READY]: {
status: 'ready',
text: t('Ready'),
},
[StorageBackendState.UNSPECIFIED]: {
status: 'unspecified',
text: t('Unspecified'),
},
});

const StorageBackendStatusLabel = ({ state }: StorageBackendStatusLabelProps) => {
const { t } = useTranslation();

const statusMap = storageBackendStatusMap(t);

const status =
state !== undefined ? statusMap[state] : statusMap[StorageBackendState.UNSPECIFIED];

return <ResourceStatusLabel {...status} />;
};

export default StorageBackendStatusLabel;
Loading
Loading