Skip to content
Merged
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
20 changes: 17 additions & 3 deletions apps/app-frontend/src/shell/StorageRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Route, Routes } from 'react-router-dom';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders';
Expand Down Expand Up @@ -33,9 +33,23 @@ describe('StorageRoutes', () => {
expect(screen.getByText('Edit storage backend')).toBeInTheDocument();
});

it('renders the Tiers tab at /admin/infrastructure/storage/tiers', () => {
it('renders the Tiers tab at /admin/infrastructure/storage/tiers', async () => {
renderAt('/admin/infrastructure/storage/tiers');

expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage tiers');
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Create tier' })).toBeInTheDocument();
});
});

it('renders a placeholder for tiers/create', () => {
renderAt('/admin/infrastructure/storage/tiers/create');

expect(screen.getByText('Create storage tier')).toBeInTheDocument();
});

it('renders a placeholder for tiers/:id/edit', () => {
renderAt('/admin/infrastructure/storage/tiers/tier-123/edit');

expect(screen.getByText('Edit storage tier')).toBeInTheDocument();
});
});
8 changes: 8 additions & 0 deletions apps/app-frontend/src/shell/StorageRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export const StorageRoutes = () => {
element={<StoragePlaceholder title={t('Edit storage backend')} />}
/>
<Route path="tiers" element={<StorageManagementPage activeTab="tiers" />} />
<Route
path="tiers/create"
element={<StoragePlaceholder title={t('Create storage tier')} />}
/>
<Route
path="tiers/:id/edit"
element={<StoragePlaceholder title={t('Edit storage tier')} />}
/>
</Routes>
);
};
11 changes: 11 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
"Authorization URL": "Authorization URL",
"Authorization URL is required": "Authorization URL is required",
"Back": "Back",
"Backend IDs are shown in place of names until this recovers. This is separate from the normal fallback shown when a tier references a backend that no longer exists.": "Backend IDs are shown in place of names until this recovers. This is separate from the normal fallback shown when a tier references a backend that no longer exists.",
"Backends": "Backends",
"Bare Metal": "Bare Metal",
"bare metal instance": "bare metal instance",
"Bare metal instance conditions": "Bare metal instance conditions",
"Bare metal instances": "Bare metal instances",
"Bare Metal Machines": "Bare Metal Machines",
"Bare metal provisioning wizard": "Bare metal provisioning wizard",
"Block": "Block",
"Boot disk": "Boot disk",
"Break-glass credentials": "Break-glass credentials",
"Browse catalog items and launch virtual machines, clusters, or bare metal machines from published offerings.": "Browse catalog items and launch virtual machines, clusters, or bare metal machines from published offerings.",
Expand Down Expand Up @@ -130,8 +132,10 @@
"Create instance type": "Create instance type",
"Create security group": "Create security group",
"Create storage backend": "Create storage backend",
"Create storage tier": "Create storage tier",
"Create subnet": "Create subnet",
"Create tenant": "Create tenant",
"Create tier": "Create tier",
"Create virtual machine": "Create virtual machine",
"Create virtual network": "Create virtual network",
"Created": "Created",
Expand Down Expand Up @@ -165,6 +169,7 @@
"Edit Identity provider": "Edit Identity provider",
"Edit rule": "Edit rule",
"Edit storage backend": "Edit storage backend",
"Edit storage tier": "Edit storage tier",
"Editable": "Editable",
"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",
Expand Down Expand Up @@ -194,6 +199,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 tier": "Failed to delete storage tier",
"Failed to delete tenant": "Failed to delete tenant",
"Failed to disable Identity provider": "Failed to disable Identity provider",
"Failed to download kubeconfig": "Failed to download kubeconfig",
Expand Down Expand Up @@ -276,6 +282,7 @@
"Network class is required": "Network class is required",
"Networking": "Networking",
"Next": "Next",
"NFS": "NFS",
"No bare metal instances match your search.": "No bare metal instances match your search.",
"No bare metal instances yet.": "No bare metal instances yet.",
"No catalog items found": "No catalog items found",
Expand All @@ -291,6 +298,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 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.",
"No tenants yet. Register one to get started.": "No tenants yet. Register one to get started.",
Expand Down Expand Up @@ -329,6 +337,7 @@
"Primary domain": "Primary domain",
"Protocol": "Protocol",
"Protocol is required": "Protocol is required",
"Protocol(s)": "Protocol(s)",
"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 @@ -401,6 +410,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 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.",
"This will permanently delete the security group and all its rules. This action cannot be undone.": "This will permanently delete the security group and all its rules. This action cannot be undone.",
Expand All @@ -411,6 +421,7 @@
"Token URL is required": "Token URL is required",
"Type": "Type",
"UDP": "UDP",
"Unable to resolve backend names": "Unable to resolve backend names",
"Unauthorized": "Unauthorized",
"Unknown": "Unknown",
"Unspecified": "Unspecified",
Expand Down
10 changes: 9 additions & 1 deletion libs/ui-components/src/api/v1/private/storage-backends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,20 @@ export const STORAGE_BACKEND_READY_LIST_FILTER = `this.status.state == ${Storage
export const storageBackendIdsFilter = (ids: string[]): string =>
`this.id in [${ids.map((id) => `"${escapeCelStringLiteral(id)}"`).join(', ')}]`;

export const usePrivateStorageBackends = (params: ListParams = {}) => {
type StorageBackendsListOptions = {
enabled?: boolean;
};

export const usePrivateStorageBackends = (
params: ListParams = {},
options: StorageBackendsListOptions = {},
) => {
const client = useApiFetch(StorageBackends);
return useApiQuery({
queryKey: apiQueryKey('v1/private/storage_backends', undefined, params),
queryFn: () => client.list(params),
select: (data) => data.items,
enabled: options.enabled ?? true,
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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 { StorageTier } from '@osac/types/private';

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

interface StorageTierActionsMenuProps {
tier: StorageTier;
}

const StorageTierActionsMenu = ({ tier }: StorageTierActionsMenuProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);

return (
<>
{deleteOpen && (
<StorageTierDeleteConfirmModal
tier={tier}
onClose={() => setDeleteOpen(false)}
onSuccess={() => setDeleteOpen(false)}
/>
)}
<Dropdown
isOpen={open}
onOpenChange={setOpen}
toggle={(ref) => (
<MenuToggle
ref={ref}
variant="plain"
onClick={() => setOpen((o) => !o)}
aria-label={t('Actions for {{name}}', { name: tier.metadata?.name ?? tier.id })}
>
<EllipsisVIcon />
</MenuToggle>
)}
popperProps={{ position: 'right' }}
>
<DropdownList>
<DropdownItem
onClick={() => navigate(`/admin/infrastructure/storage/tiers/${tier.id}/edit`)}
>
{t('Edit')}
</DropdownItem>
<DropdownItem
onClick={() => {
setDeleteOpen(true);
setOpen(false);
}}
>
{t('Delete')}
</DropdownItem>
</DropdownList>
</Dropdown>
</>
);
};

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

import StorageTierDeleteConfirmModal from './StorageTierDeleteConfirmModal';
import * as storageTiersApi from '../../api/v1/private/storage-tiers';

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

const mockTier = {
id: 'tier-1',
metadata: { name: 'fast' },
spec: { description: '', backends: [] },
};

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

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

it('deletes the tier 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(
<StorageTierDeleteConfirmModal
tier={mockTier 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('tier-1', {
onSuccess: expect.any(Function) as unknown,
});
expect(onSuccess).toHaveBeenCalled();
});
});

it('shows the FAILED_PRECONDITION error verbatim and does not call onSuccess when the tier is referenced by a Tenant', async () => {
const user = userEvent.setup();
vi.mocked(storageTiersApi.useDeleteStorageTier).mockReturnValue({
mutate,
reset,
isPending: false,
error: new ConnectError('Storage tier is referenced by a Tenant', Code.FailedPrecondition),
} as unknown as ReturnType<typeof storageTiersApi.useDeleteStorageTier>);
const onSuccess = vi.fn();

render(
<StorageTierDeleteConfirmModal
tier={mockTier as never}
onClose={vi.fn()}
onSuccess={onSuccess}
/>,
);

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

await waitFor(() => {
expect(screen.getByText('Storage tier is referenced by a Tenant')).toBeInTheDocument();
});
expect(onSuccess).not.toHaveBeenCalled();
});

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

render(
<StorageTierDeleteConfirmModal
tier={mockTier as never}
onClose={onClose}
onSuccess={vi.fn()}
/>,
);

await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
});
Loading
Loading