Skip to content
45 changes: 45 additions & 0 deletions apps/app-frontend/src/shell/AppShell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { SessionProvider } from '@osac/ui-components/hooks/use-session';
import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders';

vi.mock('./StorageRoutes', () => ({
StorageRoutes: () => <h1>Storage routes</h1>,
}));

import { AppShell } from './AppShell';

const renderAppShell = (entry: string) =>
renderWithProviders(
<SessionProvider role="admin" username="test-admin" tenantId="tenant-1">
<AppShell logout={vi.fn().mockResolvedValue(undefined)} />
</SessionProvider>,
{
apiFixtures: { privateInstanceTypes: [] },
routerEntries: [entry],
},
);

describe('AppShell', () => {
it('renders the storage route through the admin shell', () => {
renderAppShell('/admin/infrastructure/storage/backends');

expect(screen.getByRole('heading', { name: 'Storage routes' })).toBeInTheDocument();
});

it('renders the instance type list route through the admin shell', async () => {
renderAppShell('/admin/infrastructure/instance-types');

expect(screen.getByRole('heading', { name: 'Instance types' })).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('No instance types yet.')).toBeInTheDocument();
});
});

it('renders the instance type create shell through the admin shell', () => {
renderAppShell('/admin/infrastructure/instance-types/create');

expect(screen.getByRole('heading', { name: 'Create instance type' })).toBeInTheDocument();
});
});
11 changes: 10 additions & 1 deletion apps/app-frontend/src/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ClusterRoutes } from '@osac/ui-components/pages/tenant/ClusterRoutes';
import { VmCreatePage } from '@osac/ui-components/pages/tenant/VmCreatePage';
import { VmListPage } from '@osac/ui-components/pages/tenant/VmListPage';

import { InstanceTypeRoutes } from './InstanceTypeRoutes';
import { ShellMasthead } from './ShellMasthead';
import { defaultRouteForRole } from './shellRoutes';
import { ShellSidebar } from './ShellSidebar';
Expand Down Expand Up @@ -49,13 +50,21 @@ export const AppShell = ({ logout }: { logout: () => Promise<void> }) => {
}
/>
<Route
path="/admin/storage/*"
path="/admin/infrastructure/storage/*"
element={
<ShellRoute>
<StorageRoutes />
</ShellRoute>
}
/>
<Route
path="/admin/infrastructure/instance-types/*"
element={
<ShellRoute>
<InstanceTypeRoutes />
</ShellRoute>
}
/>
<Route
path="/tenant/identity-provider/*"
element={
Expand Down
35 changes: 35 additions & 0 deletions apps/app-frontend/src/shell/InstanceTypeRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

vi.mock('@osac/ui-components/components/InstanceType/AdminInstanceTypeListPage', () => ({
default: () => <h1>Instance types</h1>,
}));

vi.mock('@osac/ui-components/components/InstanceType/AdminInstanceTypeCreatePage', () => ({
default: () => <h1>Create instance type</h1>,
}));

import { InstanceTypeRoutes } from './InstanceTypeRoutes';

const renderRoutes = (initialEntry: string) => (
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/admin/infrastructure/instance-types/*" element={<InstanceTypeRoutes />} />
</Routes>
</MemoryRouter>
);

describe('InstanceTypeRoutes', () => {
it('renders the list page on the index route', () => {
render(renderRoutes('/admin/infrastructure/instance-types'));

expect(screen.getByRole('heading', { name: 'Instance types' })).toBeInTheDocument();
});

it('renders the create page shell on the create route', () => {
render(renderRoutes('/admin/infrastructure/instance-types/create'));

expect(screen.getByRole('heading', { name: 'Create instance type' })).toBeInTheDocument();
});
});
11 changes: 11 additions & 0 deletions apps/app-frontend/src/shell/InstanceTypeRoutes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Route, Routes } from 'react-router-dom';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we have this file in ui-components/src/components/InstanceType ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InstanceTypeRoutes.tsx mirrors the existing TenantRoutes.tsx exactly — a thin wrapper composing page components, living alongside StorageRoutes.tsx and TenantRoutes.tsx in apps/app-frontend/src/shell/. All routing files live there because they depend on react-router-dom, an app-level dependency not otherwise used inside the framework-agnostic ui-components package. Moving it would break with the established convention and pull a routing dependency into a shared package.
Proposed response: "This follows the same pattern as TenantRoutes.tsx/StorageRoutes.tsx — all our routing wrappers live in apps/app-frontend/src/shell since they depend on react-router-dom, which ui-components doesn't otherwise use. Keeping it here for consistency.

@rawagner rawagner Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no consistency now. Some routes are defined in app, others in ui-components (for example <ClusterRoutes />).

I think it is better to place these route definitions into ui-components. The components there expect a specific route structure (as they use navigate with specific strings).


import AdminInstanceTypeCreatePage from '@osac/ui-components/components/InstanceType/AdminInstanceTypeCreatePage';
import AdminInstanceTypeListPage from '@osac/ui-components/components/InstanceType/AdminInstanceTypeListPage';

export const InstanceTypeRoutes = () => (
<Routes>
<Route index element={<AdminInstanceTypeListPage />} />
<Route path="create" element={<AdminInstanceTypeCreatePage />} />
</Routes>
);
14 changes: 7 additions & 7 deletions apps/app-frontend/src/shell/StorageRoutes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,32 @@ import { StorageRoutes } from './StorageRoutes';
const renderAt = (path: string) =>
renderWithProviders(
<Routes>
<Route path="/admin/storage/*" element={<StorageRoutes />} />
<Route path="/admin/infrastructure/storage/*" element={<StorageRoutes />} />
</Routes>,
{ routerEntries: [path] },
);

describe('StorageRoutes', () => {
it('redirects the bare /admin/storage path to the Backends tab', () => {
renderAt('/admin/storage');
it('redirects the bare /admin/infrastructure/storage path to the Backends tab', () => {
renderAt('/admin/infrastructure/storage');

expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage backends');
});

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

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

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

expect(screen.getByText('Edit storage backend')).toBeInTheDocument();
});

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

expect(screen.getByRole('tabpanel')).toHaveTextContent('Storage tiers');
});
Expand Down
26 changes: 24 additions & 2 deletions apps/app-frontend/src/shell/shellNav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,35 @@ describe('navRowsForRole', () => {
}
});

it('includes Tenants and Storage under Administration for admin role', () => {
it('includes only Tenants under Administration for admin role', () => {
expect(findSection('admin', 'nav-administration')?.children).toEqual([
{ id: 'tenant', label: 'Tenants', path: '/admin/tenants' },
{ id: 'storage', label: 'Storage', path: '/admin/storage' },
]);
});

it('Infrastructure section shows up only for admin role and contains storage and instance types', () => {
expect(findSection('admin', 'nav-infrastructure')).toEqual({
kind: 'section',
sectionId: 'nav-infrastructure',
label: 'Infrastructure',
children: [
{
id: 'storage',
label: 'Storage',
path: '/admin/infrastructure/storage',
},
{
id: 'instance-types',
label: 'Instance types',
path: '/admin/infrastructure/instance-types',
},
],
});
for (const role of ['tenant-user', 'tenant-admin', 'tenant-idp-manager'] as UserRole[]) {
expect(findSection(role, 'nav-infrastructure')).toBeUndefined();
}
});

it('IDP administration shows up only for idp manager', () => {
expect(findSection('tenant-idp-manager', 'nav-tenant-administration')).toBeDefined();
for (const role of ['tenant-user', 'tenant-admin', 'admin'] as UserRole[]) {
Expand Down
18 changes: 16 additions & 2 deletions apps/app-frontend/src/shell/shellNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,23 @@ const getAdminNav = (t: TFunction): NavSection[] => [
kind: 'section',
sectionId: 'nav-administration',
label: t('Administration'),
children: [{ id: 'tenant', label: t('Tenants'), path: '/admin/tenants' }],
},
{
kind: 'section',
sectionId: 'nav-infrastructure',
label: t('Infrastructure'),
children: [
{ id: 'tenant', label: t('Tenants'), path: '/admin/tenants' },
{ id: 'storage', label: t('Storage'), path: '/admin/storage' },
{
id: 'storage',
label: t('Storage'),
path: '/admin/infrastructure/storage',
},
{
id: 'instance-types',
label: t('Instance types'),
path: '/admin/infrastructure/instance-types',
},
],
},
...getBaseNav(t),
Expand Down
12 changes: 12 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"Actions": "Actions",
"Actions for {{name}}": "Actions for {{name}}",
"Active": "Active",
"Add": "Add",
"Add domain": "Add domain",
"Add node set": "Add node set",
Expand Down Expand Up @@ -118,12 +119,15 @@
"Copy": "Copy",
"Could not load host types": "Could not load host types",
"Could not load instance types": "Could not load instance types",
"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 cluster": "Create cluster",
"Create cluster wizard": "Create cluster wizard",
"Create identity provider": "Create identity provider",
"Create Identity provider": "Create Identity provider",
"Create Identity provider steps": "Create Identity provider steps",
"Create instance type": "Create instance type",
"Create security group": "Create security group",
"Create storage backend": "Create storage backend",
"Create subnet": "Create subnet",
Expand All @@ -142,6 +146,7 @@
"Delete security group?": "Delete security group?",
"Deleting": "Deleting",
"deprecated": "deprecated",
"Deprecated": "Deprecated",
"Description": "Description",
"Destination CIDR": "Destination CIDR",
"Details": "Details",
Expand Down Expand Up @@ -224,7 +229,9 @@
"Identity providers": "Identity providers",
"IdP manager": "IdP manager",
"Inbound Rules": "Inbound Rules",
"Infrastructure": "Infrastructure",
"Instance type": "Instance type",
"Instance types": "Instance types",
"Internal IP": "Internal IP",
"Invalid IPv4 CIDR format (e.g., 192.168.1.0/24)": "Invalid IPv4 CIDR format (e.g., 192.168.1.0/24)",
"Invalid IPv4 CIDR notation": "Invalid IPv4 CIDR notation",
Expand All @@ -240,15 +247,18 @@
"Issuer is required": "Issuer is required",
"JWKS URL": "JWKS URL",
"Keep editing": "Keep editing",
"Lifecycle state": "Lifecycle state",
"Loading cluster password": "Loading cluster password",
"Loading security groups...": "Loading security groups...",
"Loading subnets...": "Loading subnets...",
"Logout URL": "Logout URL",
"Manage firewall rules for your virtual networks.": "Manage firewall rules for your virtual networks.",
"Manage identity providers for your tenant.": "Manage identity providers for your tenant.",
"Manage provider-defined instance types for this cloud platform.": "Manage provider-defined instance types for this cloud platform.",
"Manage storage backends and tiers for this cloud platform.": "Manage storage backends and tiers for this cloud platform.",
"Manage tenants for this cloud platform.": "Manage tenants for this cloud platform.",
"Manage virtual networks for your compute instances.": "Manage virtual networks for your compute instances.",
"Memory (GiB)": "Memory (GiB)",
"Message": "Message",
"Must be a valid domain (e.g. example.com)": "Must be a valid domain (e.g. example.com)",
"Must be a valid URL (e.g. https://example.com)": "Must be a valid URL (e.g. https://example.com)",
Expand All @@ -271,6 +281,7 @@
"No identity providers match your search.": "No identity providers match your search.",
"No identity providers yet. Create one to get started.": "No identity providers yet. Create one to get started.",
"No inbound rules yet. Add one to allow incoming traffic.": "No inbound rules yet. Add one to allow incoming traffic.",
"No instance types yet.": "No instance types yet.",
"No node sets added yet.": "No node sets added yet.",
"No node sets configured.": "No node sets configured.",
"No outbound rules yet. Add one to allow outgoing traffic.": "No outbound rules yet. Add one to allow outgoing traffic.",
Expand All @@ -288,6 +299,7 @@
"Node Sets": "Node Sets",
"Nodes": "Nodes",
"Not defined": "Not defined",
"Obsolete": "Obsolete",
"OIDC": "OIDC",
"OIDC configuration": "OIDC configuration",
"Open catalog item details for {{title}}": "Open catalog item details for {{title}}",
Expand Down
1 change: 1 addition & 0 deletions libs/ui-components/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type ApiRoute =
| 'v1/external_ip_attachments'
| 'v1/external_ip_pools'
| 'v1/console_sessions'
| 'v1/private/instance_types'
| 'v1/private/tenants'
| 'v1/private/storage_backends'
| 'v1/private/storage_tiers'
Expand Down
71 changes: 71 additions & 0 deletions libs/ui-components/src/api/v1/private/instance-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { type ReactNode, createElement } from 'react';
import { create } from '@bufbuild/protobuf';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import {
InstanceTypeSchema,
InstanceTypeState,
type InstanceType as PrivateInstanceType,
} from '@osac/types/private';

import { useAdminInstanceTypes } from './instance-type';
import { createMockConnectTransport } from '../../../test-utils/createMockConnectTransport';
import { ApiProvider } from '../../api-context';

const makeInstanceType = (
id: string,
state: InstanceTypeState = InstanceTypeState.ACTIVE,
): PrivateInstanceType =>
create(InstanceTypeSchema, {
id,
metadata: { name: `instance-type-${id}` },
spec: {
description: `${id} description`,
cores: 4,
memoryGib: 16,
state,
},
});

const makeWrapper = (transport: ReturnType<typeof createMockConnectTransport>) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(
ApiProvider,
{ transport } as React.ComponentProps<typeof ApiProvider>,
createElement(QueryClientProvider, { client: queryClient }, children),
);
return { wrapper, queryClient };
};

describe('useAdminInstanceTypes', () => {
it('returns all private instance type items from the list response', async () => {
const transport = createMockConnectTransport({
privateInstanceTypes: [
makeInstanceType('active-1', InstanceTypeState.ACTIVE),
makeInstanceType('deprecated-1', InstanceTypeState.DEPRECATED),
],
});
const { wrapper } = makeWrapper(transport);
const { result } = renderHook(() => useAdminInstanceTypes(), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.map((item) => item.id)).toEqual(['active-1', 'deprecated-1']);
});

it('stores query results under the private instance type cache key', async () => {
const transport = createMockConnectTransport({
privateInstanceTypes: [makeInstanceType('active-1')],
});
const { wrapper, queryClient } = makeWrapper(transport);
const { result } = renderHook(() => useAdminInstanceTypes(), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(queryClient.getQueryData(['v1/private/instance_types'])).toBeDefined();
expect(queryClient.getQueryData(['v1/instance_types'])).toBeUndefined();
});
});
Loading
Loading