diff --git a/apps/app-frontend/src/main.tsx b/apps/app-frontend/src/main.tsx
index 0ac147f7..7dcd1a8b 100644
--- a/apps/app-frontend/src/main.tsx
+++ b/apps/app-frontend/src/main.tsx
@@ -8,10 +8,12 @@ import { ApiProvider, connectErrorInterceptor } from '@osac/ui-components/api/ap
import App from './App';
import './i18n';
+import { wellKnownTypeRegistry } from './wellKnownTypeRegistry';
const connectTransport = createConnectTransport({
baseUrl: '/api/fulfillment',
interceptors: [connectErrorInterceptor],
+ jsonOptions: { registry: wellKnownTypeRegistry },
});
// CSS load order is intentional: base → addons → local overrides
diff --git a/apps/app-frontend/src/shell/AdminCatalogRoutes.test.tsx b/apps/app-frontend/src/shell/AdminCatalogRoutes.test.tsx
new file mode 100644
index 00000000..2d9fd630
--- /dev/null
+++ b/apps/app-frontend/src/shell/AdminCatalogRoutes.test.tsx
@@ -0,0 +1,57 @@
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AdminCatalogRoutes } from './AdminCatalogRoutes';
+
+vi.mock('@osac/ui-components/pages/admin/cluster/ClusterCatalogItemCreatePage', () => ({
+ default: () =>
Cluster create page
,
+}));
+vi.mock(
+ '@osac/ui-components/pages/admin/compute-instance/ComputeInstanceCatalogItemCreatePage',
+ () => ({
+ default: () => Compute instance create page
,
+ }),
+);
+vi.mock(
+ '@osac/ui-components/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemCreatePage',
+ () => ({
+ default: () => Bare metal create page
,
+ }),
+);
+vi.mock('@osac/ui-components/pages/admin/CatalogManagementListPage', () => ({
+ default: () => Catalog management list page
,
+}));
+vi.mock('@osac/ui-components/hooks/useTranslation', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+// Mounted at /admin/catalog/* to match the real route registered in AppShell.tsx.
+const renderAt = (path: string) =>
+ render(
+
+
+ } />
+
+ ,
+ );
+
+describe('AdminCatalogRoutes', () => {
+ it('renders the cluster create page for :type = cluster', () => {
+ renderAt('/admin/catalog/cluster/create');
+
+ expect(screen.getByText('Cluster create page')).toBeInTheDocument();
+ });
+
+ it('renders the compute instance create page for :type = compute-instance', () => {
+ renderAt('/admin/catalog/compute-instance/create');
+
+ expect(screen.getByText('Compute instance create page')).toBeInTheDocument();
+ });
+
+ it('renders the bare metal create page for :type = baremetal-instance', () => {
+ renderAt('/admin/catalog/baremetal-instance/create');
+
+ expect(screen.getByText('Bare metal create page')).toBeInTheDocument();
+ });
+});
diff --git a/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx b/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx
index fad49d9f..53f3ebf7 100644
--- a/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx
+++ b/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx
@@ -1,12 +1,20 @@
import { Route, Routes } from 'react-router-dom';
+import BareMetalInstanceCatalogItemCreatePage from '@osac/ui-components/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemCreatePage';
import CatalogManagementListPage from '@osac/ui-components/pages/admin/CatalogManagementListPage';
+import ClusterCatalogItemCreatePage from '@osac/ui-components/pages/admin/cluster/ClusterCatalogItemCreatePage';
+import ComputeInstanceCatalogItemCreatePage from '@osac/ui-components/pages/admin/compute-instance/ComputeInstanceCatalogItemCreatePage';
export const AdminCatalogRoutes = () => {
return (
} />
- } />
+ } />
+ } />
+ }
+ />
} />
} />
diff --git a/apps/app-frontend/src/wellKnownTypeRegistry.test.ts b/apps/app-frontend/src/wellKnownTypeRegistry.test.ts
new file mode 100644
index 00000000..1148163b
--- /dev/null
+++ b/apps/app-frontend/src/wellKnownTypeRegistry.test.ts
@@ -0,0 +1,41 @@
+import { fromJson } from '@bufbuild/protobuf';
+import { AnySchema } from '@bufbuild/protobuf/wkt';
+import { describe, expect, it } from 'vitest';
+
+import { wellKnownTypeRegistry } from './wellKnownTypeRegistry';
+
+describe('wellKnownTypeRegistry', () => {
+ it.each([
+ ['google.protobuf.BoolValue', true],
+ ['google.protobuf.BytesValue', 'AQI='],
+ ['google.protobuf.StringValue', 'hello'],
+ ['google.protobuf.Int32Value', 42],
+ ['google.protobuf.Int64Value', '42'],
+ ['google.protobuf.UInt32Value', 42],
+ ['google.protobuf.UInt64Value', '42'],
+ ['google.protobuf.DoubleValue', 4.2],
+ ['google.protobuf.FloatValue', 4.2],
+ ['google.protobuf.Value', { nested: true }],
+ ['google.protobuf.Struct', { nested: true }],
+ ['google.protobuf.ListValue', [1, 2, 3]],
+ ['google.protobuf.Timestamp', '2026-01-01T00:00:00Z'],
+ ['google.protobuf.Duration', '5s'],
+ ])('resolves %s packed in a google.protobuf.Any', (typeName, value) => {
+ const decode = () =>
+ fromJson(
+ AnySchema,
+ { '@type': `type.googleapis.com/${typeName}`, value },
+ { registry: wellKnownTypeRegistry },
+ );
+ expect(decode).not.toThrow();
+ });
+
+ it('fails to resolve an Any-packed well-known type without the registry', () => {
+ const decode = () =>
+ fromJson(AnySchema, {
+ '@type': 'type.googleapis.com/google.protobuf.BoolValue',
+ value: true,
+ });
+ expect(decode).toThrow(/not in the type registry/);
+ });
+});
diff --git a/apps/app-frontend/src/wellKnownTypeRegistry.ts b/apps/app-frontend/src/wellKnownTypeRegistry.ts
new file mode 100644
index 00000000..8870229e
--- /dev/null
+++ b/apps/app-frontend/src/wellKnownTypeRegistry.ts
@@ -0,0 +1,42 @@
+import { createRegistry } from '@bufbuild/protobuf';
+import {
+ BoolValueSchema,
+ BytesValueSchema,
+ DoubleValueSchema,
+ DurationSchema,
+ FloatValueSchema,
+ Int32ValueSchema,
+ Int64ValueSchema,
+ ListValueSchema,
+ StringValueSchema,
+ StructSchema,
+ TimestampSchema,
+ UInt32ValueSchema,
+ UInt64ValueSchema,
+ ValueSchema,
+} from '@bufbuild/protobuf/wkt';
+
+// google.protobuf.Any is decoded by looking up its packed type by name in a registry — unlike a
+// field with a static well-known type (e.g. metadata.creation_timestamp), Any's packed type is
+// only known at runtime from its "@type" URL. Without an entry here, @bufbuild/protobuf throws
+// "cannot decode message google.protobuf.Any from JSON: is not in the type registry".
+//
+// ClusterTemplateParameterDefinition.default (and the equivalent field on other template types) is
+// documented to pack one of these well-known types, so all of them must be registered for the
+// catalog item wizards' template dropdowns to decode successfully.
+export const wellKnownTypeRegistry = createRegistry(
+ BoolValueSchema,
+ BytesValueSchema,
+ DoubleValueSchema,
+ DurationSchema,
+ FloatValueSchema,
+ Int32ValueSchema,
+ Int64ValueSchema,
+ ListValueSchema,
+ StringValueSchema,
+ StructSchema,
+ TimestampSchema,
+ UInt32ValueSchema,
+ UInt64ValueSchema,
+ ValueSchema,
+);
diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json
index 8ed870df..63edbc4c 100644
--- a/libs/i18n/locales/en/translation.json
+++ b/libs/i18n/locales/en/translation.json
@@ -1,9 +1,13 @@
{
+ "Access": "Access",
"Actions": "Actions",
"Actions for {{name}}": "Actions for {{name}}",
"Add": "Add",
+ "Add additional disk": "Add additional disk",
"Add node set": "Add node set",
"Add rule": "Add rule",
+ "Additional disk {{number}}": "Additional disk {{number}}",
+ "Additional disk size (GiB)": "Additional disk size (GiB)",
"Administration": "Administration",
"All": "All",
"API URL": "API URL",
@@ -11,12 +15,14 @@
"At least one node set is required": "At least one node set is required",
"Attach": "Attach",
"Attach public IP": "Attach public IP",
+ "Back": "Back",
"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",
+ "Boot disk size (GiB)": "Boot disk size (GiB)",
"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.",
"Cancel": "Cancel",
"Catalog": "Catalog",
@@ -106,17 +112,30 @@
"Console URL": "Console URL",
"Copied": "Copied",
"Copy": "Copy",
+ "Could not create catalog item": "Could not create catalog item",
"Could not load host types": "Could not load host types",
"Could not load instance types": "Could not load instance types",
"Create": "Create",
+ "Create bare metal catalog item": "Create bare metal catalog item",
+ "Create bare metal catalog item steps": "Create bare metal catalog item steps",
"Create cluster": "Create cluster",
+ "Create cluster catalog item": "Create cluster catalog item",
+ "Create cluster catalog item steps": "Create cluster catalog item steps",
"Create cluster wizard": "Create cluster wizard",
"Create security group": "Create security group",
"Create subnet": "Create subnet",
"Create virtual machine": "Create virtual machine",
+ "Create virtual machine catalog item": "Create virtual machine catalog item",
+ "Create virtual machine catalog item steps": "Create virtual machine catalog item steps",
"Create virtual network": "Create virtual network",
"Created": "Created",
"Creator": "Creator",
+ "Default nodes": "Default nodes",
+ "Default value": "Default value",
+ "Default value is required for non-editable fields": "Default value is required for non-editable fields",
+ "Define a curated bare metal offering for tenants to provision from.": "Define a curated bare metal offering for tenants to provision from.",
+ "Define a curated cluster offering for tenants to provision from.": "Define a curated cluster offering for tenants to provision from.",
+ "Define a curated virtual machine offering for tenants to provision from.": "Define a curated virtual machine offering for tenants to provision from.",
"Delete": "Delete",
"Delete {{name}}?": "Delete {{name}}?",
"Delete rule": "Delete rule",
@@ -125,6 +144,7 @@
"Delete security group?": "Delete security group?",
"Deleting": "Deleting",
"deprecated": "deprecated",
+ "Description": "Description",
"Destination CIDR": "Destination CIDR",
"Details": "Details",
"Download kubeconfig": "Download kubeconfig",
@@ -187,9 +207,18 @@
"Loading cluster password": "Loading cluster password",
"Loading security groups...": "Loading security groups...",
"Loading subnets...": "Loading subnets...",
+ "Loading...": "Loading...",
"Manage firewall rules for your virtual networks.": "Manage firewall rules for your virtual networks.",
"Manage virtual networks for your compute instances.": "Manage virtual networks for your compute instances.",
+ "Maximum (optional)": "Maximum (optional)",
+ "Maximum must be greater than or equal to minimum": "Maximum must be greater than or equal to minimum",
+ "Maximum nodes": "Maximum nodes",
"Message": "Message",
+ "Minimum (optional)": "Minimum (optional)",
+ "Minimum nodes": "Minimum nodes",
+ "Must be a number": "Must be a number",
+ "Must be a valid IPv4 CIDR notation (for example 10.128.0.0/14)": "Must be a valid IPv4 CIDR notation (for example 10.128.0.0/14)",
+ "Must be a valid regular expression": "Must be a valid regular expression",
"Name": "Name",
"Name cannot end with a hyphen": "Name cannot end with a hyphen",
"Name cannot start with a hyphen": "Name cannot start with a hyphen",
@@ -197,7 +226,9 @@
"Name must be a valid DNS label (RFC 1035).": "Name must be a valid DNS label (RFC 1035).",
"Name must be at most 63 characters long": "Name must be at most 63 characters long",
"Name must only contain lowercase letters (a-z), digits (0-9), and hyphens (-)": "Name must only contain lowercase letters (a-z), digits (0-9), and hyphens (-)",
+ "Network attachments": "Network attachments",
"Networking": "Networking",
+ "Next": "Next",
"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",
@@ -222,6 +253,7 @@
"Open catalog item details for {{title}}": "Open catalog item details for {{title}}",
"Optional cloud-init user data (max 64 KB).": "Optional cloud-init user data (max 64 KB).",
"Organization": "Organization",
+ "Organization is required": "Organization is required",
"Organization: {{name}}": "Organization: {{name}}",
"Outbound Rules": "Outbound Rules",
"Overview": "Overview",
@@ -240,6 +272,8 @@
"Port To": "Port To",
"Port To is required for TCP/UDP": "Port To is required for TCP/UDP",
"Port To must be >= Port From": "Port To must be >= Port From",
+ "Project": "Project",
+ "Project is required": "Project is required",
"Project: {{name}}": "Project: {{name}}",
"Protocol": "Protocol",
"Protocol is required": "Protocol is required",
@@ -253,13 +287,17 @@
"Pull secret": "Pull secret",
"Pull secret is required": "Pull secret is required",
"Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).": "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).",
+ "Regular expression the tenant-provided value must match.": "Regular expression the tenant-provided value must match.",
"Release image": "Release image",
"Release image is required": "Release image is required",
+ "Remove additional disk": "Remove additional disk",
"Remove node set": "Remove node set",
"Restart": "Restart",
"Retry": "Retry",
+ "Run strategy": "Run strategy",
"Running": "Running",
"Save": "Save",
+ "Scope": "Scope",
"Search by name": "Search by name",
"Search catalog items": "Search catalog items",
"Search security groups by name…": "Search security groups by name…",
@@ -267,14 +305,24 @@
"Security groups": "Security groups",
"Select a catalog item": "Select a catalog item",
"Select a catalog item, configure, and provision an OpenShift cluster.": "Select a catalog item, configure, and provision an OpenShift cluster.",
+ "Select a project": "Select a project",
+ "Select a template": "Select a template",
+ "Select a template to configure node sets": "Select a template to configure node sets",
+ "Select a value": "Select a value",
"Select a virtual network": "Select a virtual network",
+ "Select an organization": "Select an organization",
"Select host type": "Select host type",
+ "Select organization": "Select organization",
+ "Select project": "Select project",
"Service CIDR": "Service CIDR",
"Service CIDR must not overlap the pod CIDR.": "Service CIDR must not overlap the pod CIDR.",
"Services": "Services",
"Sign in again": "Sign in again",
"Size": "Size",
+ "Size (GiB)": "Size (GiB)",
+ "Size must be a positive number": "Size must be a positive number",
"Source CIDR": "Source CIDR",
+ "Source Ref": "Source Ref",
"SSH public key": "SSH public key",
"SSH public key must be in the form \"[TYPE] key [[EMAIL]]\". Supported types are ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.": "SSH public key must be in the form \"[TYPE] key [[EMAIL]]\". Supported types are ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.",
"Start": "Start",
@@ -287,15 +335,20 @@
"Subnets": "Subnets",
"Take over": "Take over",
"TCP": "TCP",
+ "Template": "Template",
+ "Template is required": "Template is required",
"The console is available when the virtual machine is running.": "The console is available when the virtual machine is running.",
"This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.": "This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.",
"This field is required": "This field is required",
"This might be because the console is already open in another session.": "This might be because the console is already open in another session.",
"This permanently deletes the bare metal instance. This action cannot be undone.": "This permanently deletes the bare metal instance. This action cannot be undone.",
"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 step has validation errors": "This step has validation errors",
+ "This template has no node sets defined": "This template has no node sets defined",
"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.",
"Timed out waiting for the graphical console to finish connecting": "Timed out waiting for the graphical console to finish connecting",
+ "Title": "Title",
"UDP": "UDP",
"Unauthorized": "Unauthorized",
"Unknown": "Unknown",
@@ -305,6 +358,7 @@
"User data": "User data",
"User Data is required": "User Data is required",
"User data must not exceed 64 KB.": "User data must not exceed 64 KB.",
+ "Validation pattern (optional)": "Validation pattern (optional)",
"View and manage your bare metal instances.": "View and manage your bare metal instances.",
"View password": "View password",
"Virtual machine conditions": "Virtual machine conditions",
diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts
index 484276cc..cf64d99b 100644
--- a/libs/types/src/index.ts
+++ b/libs/types/src/index.ts
@@ -11,8 +11,8 @@ export * from './osac/public/v1/compute_instance_catalog_items_service_pb.js'
export * from './osac/public/v1/instance_type_type_pb.js'
export * from './osac/public/v1/instance_types_service_pb.js'
-export * from './osac/public/v1/organization_type_pb.js'
-export * from './osac/public/v1/organizations_service_pb.js'
+export * from './osac/public/v1/tenant_type_pb.js'
+export * from './osac/public/v1/tenants_service_pb.js'
export * from './osac/public/v1/user_type_pb.js'
export * from './osac/public/v1/users_service_pb.js'
@@ -55,4 +55,9 @@ export * from './osac/public/v1/field_definition_type_pb.js'
export * from './osac/public/v1/baremetal_instance_type_pb.js';
export * from './osac/public/v1/baremetal_instances_service_pb.js';
export * from './osac/public/v1/baremetal_instance_catalog_item_type_pb.js';
-export * from './osac/public/v1/baremetal_instance_catalog_items_service_pb.js';
\ No newline at end of file
+export * from './osac/public/v1/baremetal_instance_catalog_items_service_pb.js';
+export * from './osac/public/v1/baremetal_instance_template_type_pb.js';
+export * from './osac/public/v1/baremetal_instance_templates_service_pb.js';
+
+export * from './osac/public/v1/project_type_pb.js';
+export * from './osac/public/v1/projects_service_pb.js';
\ No newline at end of file
diff --git a/libs/ui-components/src/api/types.ts b/libs/ui-components/src/api/types.ts
index ccd54911..faac72e1 100644
--- a/libs/ui-components/src/api/types.ts
+++ b/libs/ui-components/src/api/types.ts
@@ -14,7 +14,6 @@ export type ApiRoute =
| 'v1/host_types'
| 'v1/instance_types'
| 'v1/clusters'
- | 'v1/organizations'
| 'v1/users'
| 'v1/capabilities'
| 'v1/network_classes'
@@ -23,11 +22,17 @@ export type ApiRoute =
| 'v1/security_groups'
| 'v1/baremetal_instance_catalog_items'
| 'v1/baremetal_instances'
+ | 'v1/baremetal_instance_templates'
| 'v1/public_ips'
| 'v1/public_ip_attachments'
| 'v1/private/compute_instance_catalog_items'
| 'v1/private/cluster_catalog_items'
| 'v1/private/baremetal_instance_catalog_items'
+ | 'v1/cluster_templates_private'
+ | 'v1/compute_instance_templates_private'
+ | 'v1/baremetal_instance_templates_private'
+ | 'v1/projects'
+ | 'v1/tenants_private'
| 'v1/console_sessions';
/**
diff --git a/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts b/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts
new file mode 100644
index 00000000..5f877082
--- /dev/null
+++ b/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts
@@ -0,0 +1,65 @@
+import { createRouterTransport } from '@connectrpc/connect';
+import { waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { BareMetalInstanceTemplates } from '@osac/types';
+import { BareMetalInstanceTemplates as PrivateBareMetalInstanceTemplates } from '@osac/types/private';
+
+import {
+ useAdminBareMetalInstanceTemplates,
+ useBareMetalInstanceTemplates,
+} from './baremetal-instance-templates';
+import { renderHookWithTransport as renderWithTransport } from '../../test-utils/renderHookWithTransport';
+
+const makeTemplate = (id: string) => ({ id, metadata: { name: `template-${id}` } });
+
+describe('useBareMetalInstanceTemplates', () => {
+ it('lists public bare metal instance templates', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(BareMetalInstanceTemplates, { list: () => ({ items: [makeTemplate('a')] }) });
+ });
+
+ const { result } = renderWithTransport(() => useBareMetalInstanceTemplates(), transport);
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('a')]);
+ });
+});
+
+describe('useAdminBareMetalInstanceTemplates', () => {
+ it('calls the private client for providerAdmin', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(BareMetalInstanceTemplates, { list: () => ({ items: [] }) });
+ router.service(PrivateBareMetalInstanceTemplates, {
+ list: () => ({ items: [makeTemplate('admin')] }),
+ });
+ });
+
+ const { result } = renderWithTransport(
+ () => useAdminBareMetalInstanceTemplates(),
+ transport,
+ 'providerAdmin',
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('admin')]);
+ });
+
+ it('calls the public client for tenantAdmin', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(BareMetalInstanceTemplates, {
+ list: () => ({ items: [makeTemplate('tenant')] }),
+ });
+ router.service(PrivateBareMetalInstanceTemplates, { list: () => ({ items: [] }) });
+ });
+
+ const { result } = renderWithTransport(
+ () => useAdminBareMetalInstanceTemplates(),
+ transport,
+ 'tenantAdmin',
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('tenant')]);
+ });
+});
diff --git a/libs/ui-components/src/api/v1/baremetal-instance-templates.ts b/libs/ui-components/src/api/v1/baremetal-instance-templates.ts
new file mode 100644
index 00000000..0dcbc718
--- /dev/null
+++ b/libs/ui-components/src/api/v1/baremetal-instance-templates.ts
@@ -0,0 +1,25 @@
+import { BareMetalInstanceTemplates } from '@osac/types';
+
+import { useSession } from '../../hooks/use-session';
+import { useApiFetch } from '../api-context';
+import { apiQueryKey } from '../types';
+import { useApiQuery } from '../use-api-query';
+import { usePrivateBareMetalInstanceTemplates } from './private/baremetal-instance-templates';
+
+export const useBareMetalInstanceTemplates = (enabled = true) => {
+ const client = useApiFetch(BareMetalInstanceTemplates);
+ return useApiQuery({
+ queryKey: apiQueryKey('v1/baremetal_instance_templates'),
+ queryFn: () => client.list({}),
+ select: (data) => data.items,
+ enabled,
+ });
+};
+
+export const useAdminBareMetalInstanceTemplates = (enabled = true) => {
+ const { role } = useSession();
+ const isProviderAdmin = role === 'providerAdmin';
+ const publicResult = useBareMetalInstanceTemplates(enabled && !isProviderAdmin);
+ const privateResult = usePrivateBareMetalInstanceTemplates(enabled && isProviderAdmin);
+ return isProviderAdmin ? privateResult : publicResult;
+};
diff --git a/libs/ui-components/src/api/v1/baremetal-instance.test.ts b/libs/ui-components/src/api/v1/baremetal-instance.test.ts
index fe0e21ca..37778d06 100644
--- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts
+++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts
@@ -2,7 +2,7 @@ import React, { type ReactNode, createElement } from 'react';
import { createRouterTransport } from '@connectrpc/connect';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import type { BareMetalInstanceCatalogItem } from '@osac/types';
import {
@@ -10,13 +10,16 @@ import {
BareMetalInstanceRunStrategy,
BareMetalInstances,
} from '@osac/types';
+import { BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems } from '@osac/types/private';
import {
type PatchBareMetalInstanceInput,
useBareMetalInstanceCatalogItems,
+ useCreateBareMetalInstanceCatalogItem,
usePatchBareMetalInstance,
} from './baremetal-instance';
import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers';
+import { renderHookWithTransport as renderWithTransport } from '../../test-utils/renderHookWithTransport';
import { ApiProvider } from '../api-context';
const item: BareMetalInstanceCatalogItem = {
@@ -45,6 +48,50 @@ describe('useBareMetalInstanceCatalogItems', () => {
});
});
+const makeItem = (id: string) => ({ id, title: `item-${id}` });
+
+describe('useCreateBareMetalInstanceCatalogItem', () => {
+ it('calls the private client for providerAdmin', async () => {
+ const createFn = vi.fn(() => ({ object: makeItem('a') }));
+ const transport = createRouterTransport((router) => {
+ router.service(PrivateBareMetalInstanceCatalogItems, { create: createFn });
+ });
+
+ const { result } = renderWithTransport(
+ () => useCreateBareMetalInstanceCatalogItem(),
+ transport,
+ 'providerAdmin',
+ );
+
+ act(() => {
+ result.current.mutate({ title: 'item-a', published: false });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(createFn).toHaveBeenCalled();
+ });
+
+ it('calls the public client for tenantAdmin', async () => {
+ const createFn = vi.fn(() => ({ object: makeItem('b') }));
+ const transport = createRouterTransport((router) => {
+ router.service(BareMetalInstanceCatalogItems, { create: createFn });
+ });
+
+ const { result } = renderWithTransport(
+ () => useCreateBareMetalInstanceCatalogItem(),
+ transport,
+ 'tenantAdmin',
+ );
+
+ act(() => {
+ result.current.mutate({ title: 'item-b', published: false });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(createFn).toHaveBeenCalled();
+ });
+});
+
const makeBmi = (id: string) => ({
id,
metadata: { name: `bmi-${id}` },
diff --git a/libs/ui-components/src/api/v1/baremetal-instance.ts b/libs/ui-components/src/api/v1/baremetal-instance.ts
index 7cd547e3..7ddb6eb5 100644
--- a/libs/ui-components/src/api/v1/baremetal-instance.ts
+++ b/libs/ui-components/src/api/v1/baremetal-instance.ts
@@ -2,12 +2,18 @@ import { type MessageInitShape } from '@bufbuild/protobuf';
import { useMutation } from '@tanstack/react-query';
import {
+ BareMetalInstanceCatalogItemSchema,
BareMetalInstanceCatalogItems,
BareMetalInstanceRunStrategy,
BareMetalInstanceSchema,
BareMetalInstances,
} from '@osac/types';
+import {
+ BareMetalInstanceCatalogItemSchema as PrivateBareMetalInstanceCatalogItemSchema,
+ BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems,
+} from '@osac/types/private';
+import { useSession } from '../../hooks/use-session';
import { useApiFetch } from '../api-context';
import { type ListParams, apiQueryKey } from '../types';
import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query';
@@ -46,6 +52,25 @@ export const invalidateBareMetalInstancesQueries = async (qc: ApiQueryClient) =>
await qc.invalidateQueries({ queryKey: apiQueryKey('v1/baremetal_instances') });
};
+export const useCreateBareMetalInstanceCatalogItem = () => {
+ const { role } = useSession();
+ const isProviderAdmin = role === 'providerAdmin';
+ const publicClient = useApiFetch(BareMetalInstanceCatalogItems);
+ const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems);
+ const qc = useApiQueryClient();
+ return useMutation({
+ mutationFn: (item: MessageInitShape) =>
+ (isProviderAdmin
+ ? privateClient.create({
+ object: item as MessageInitShape,
+ })
+ : publicClient.create({ object: item })
+ ).then((response) => response.object),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: apiQueryKey('v1/baremetal_instance_catalog_items') }),
+ });
+};
+
export type BareMetalPowerAction = 'start' | 'stop' | 'restart';
export type PatchBareMetalInstanceInput =
diff --git a/libs/ui-components/src/api/v1/cluster-catalog-item.test.ts b/libs/ui-components/src/api/v1/cluster-catalog-item.test.ts
index 43cb0967..c845790d 100644
--- a/libs/ui-components/src/api/v1/cluster-catalog-item.test.ts
+++ b/libs/ui-components/src/api/v1/cluster-catalog-item.test.ts
@@ -1,10 +1,14 @@
-import { describe } from 'vitest';
+import { createRouterTransport } from '@connectrpc/connect';
+import { act, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
import type { ClusterCatalogItem } from '@osac/types';
import { ClusterCatalogItems } from '@osac/types';
+import { ClusterCatalogItems as PrivateClusterCatalogItems } from '@osac/types/private';
-import { useClusterCatalogItems } from './cluster-catalog-item';
+import { useClusterCatalogItems, useCreateClusterCatalogItem } from './cluster-catalog-item';
import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers';
+import { renderHookWithTransport as renderWithTransport } from '../../test-utils/renderHookWithTransport';
const item: ClusterCatalogItem = {
$typeName: 'osac.public.v1.ClusterCatalogItem',
@@ -31,3 +35,47 @@ describe('useClusterCatalogItems', () => {
}),
});
});
+
+const makeItem = (id: string) => ({ id, title: `item-${id}` });
+
+describe('useCreateClusterCatalogItem', () => {
+ it('calls the private client for providerAdmin', async () => {
+ const createFn = vi.fn(() => ({ object: makeItem('a') }));
+ const transport = createRouterTransport((router) => {
+ router.service(PrivateClusterCatalogItems, { create: createFn });
+ });
+
+ const { result } = renderWithTransport(
+ () => useCreateClusterCatalogItem(),
+ transport,
+ 'providerAdmin',
+ );
+
+ act(() => {
+ result.current.mutate({ title: 'item-a', published: false });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(createFn).toHaveBeenCalled();
+ });
+
+ it('calls the public client for tenantAdmin', async () => {
+ const createFn = vi.fn(() => ({ object: makeItem('b') }));
+ const transport = createRouterTransport((router) => {
+ router.service(ClusterCatalogItems, { create: createFn });
+ });
+
+ const { result } = renderWithTransport(
+ () => useCreateClusterCatalogItem(),
+ transport,
+ 'tenantAdmin',
+ );
+
+ act(() => {
+ result.current.mutate({ title: 'item-b', published: false });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(createFn).toHaveBeenCalled();
+ });
+});
diff --git a/libs/ui-components/src/api/v1/cluster-catalog-item.ts b/libs/ui-components/src/api/v1/cluster-catalog-item.ts
index b1156a24..249aec34 100644
--- a/libs/ui-components/src/api/v1/cluster-catalog-item.ts
+++ b/libs/ui-components/src/api/v1/cluster-catalog-item.ts
@@ -1,8 +1,16 @@
-import { ClusterCatalogItems } from '@osac/types';
+import { type MessageInitShape } from '@bufbuild/protobuf';
+import { useMutation } from '@tanstack/react-query';
+import { ClusterCatalogItemSchema, ClusterCatalogItems } from '@osac/types';
+import {
+ ClusterCatalogItemSchema as PrivateClusterCatalogItemSchema,
+ ClusterCatalogItems as PrivateClusterCatalogItems,
+} from '@osac/types/private';
+
+import { useSession } from '../../hooks/use-session';
import { useApiFetch } from '../api-context';
import { type ListParams, apiQueryKey } from '../types';
-import { useApiQuery } from '../use-api-query';
+import { useApiQuery, useApiQueryClient } from '../use-api-query';
export const useClusterCatalogItems = (params: ListParams = {}, enabled = true) => {
const client = useApiFetch(ClusterCatalogItems);
@@ -23,3 +31,21 @@ export const useClusterCatalogItem = (id: string | undefined) => {
enabled: Boolean(id),
});
};
+
+export const useCreateClusterCatalogItem = () => {
+ const { role } = useSession();
+ const isProviderAdmin = role === 'providerAdmin';
+ const publicClient = useApiFetch(ClusterCatalogItems);
+ const privateClient = useApiFetch(PrivateClusterCatalogItems);
+ const qc = useApiQueryClient();
+ return useMutation({
+ mutationFn: (item: MessageInitShape) =>
+ (isProviderAdmin
+ ? privateClient.create({
+ object: item as MessageInitShape,
+ })
+ : publicClient.create({ object: item })
+ ).then((response) => response.object),
+ onSuccess: () => qc.invalidateQueries({ queryKey: apiQueryKey('v1/cluster_catalog_items') }),
+ });
+};
diff --git a/libs/ui-components/src/api/v1/cluster-templates.test.ts b/libs/ui-components/src/api/v1/cluster-templates.test.ts
new file mode 100644
index 00000000..e5b7800d
--- /dev/null
+++ b/libs/ui-components/src/api/v1/cluster-templates.test.ts
@@ -0,0 +1,58 @@
+import { createRouterTransport } from '@connectrpc/connect';
+import { waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { ClusterTemplates } from '@osac/types';
+import { ClusterTemplates as PrivateClusterTemplates } from '@osac/types/private';
+
+import { useAdminClusterTemplates, useClusterTemplates } from './cluster-templates';
+import { renderHookWithTransport as renderWithTransport } from '../../test-utils/renderHookWithTransport';
+
+const makeTemplate = (id: string) => ({ id, metadata: { name: `template-${id}` } });
+
+describe('useClusterTemplates', () => {
+ it('lists public cluster templates', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(ClusterTemplates, { list: () => ({ items: [makeTemplate('a')] }) });
+ });
+
+ const { result } = renderWithTransport(() => useClusterTemplates(), transport);
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('a')]);
+ });
+});
+
+describe('useAdminClusterTemplates', () => {
+ it('calls the private client for providerAdmin', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(ClusterTemplates, { list: () => ({ items: [] }) });
+ router.service(PrivateClusterTemplates, { list: () => ({ items: [makeTemplate('admin')] }) });
+ });
+
+ const { result } = renderWithTransport(
+ () => useAdminClusterTemplates(),
+ transport,
+ 'providerAdmin',
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('admin')]);
+ });
+
+ it('calls the public client for tenantAdmin', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(ClusterTemplates, { list: () => ({ items: [makeTemplate('tenant')] }) });
+ router.service(PrivateClusterTemplates, { list: () => ({ items: [] }) });
+ });
+
+ const { result } = renderWithTransport(
+ () => useAdminClusterTemplates(),
+ transport,
+ 'tenantAdmin',
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('tenant')]);
+ });
+});
diff --git a/libs/ui-components/src/api/v1/cluster-templates.ts b/libs/ui-components/src/api/v1/cluster-templates.ts
index da0f6746..fec770f1 100644
--- a/libs/ui-components/src/api/v1/cluster-templates.ts
+++ b/libs/ui-components/src/api/v1/cluster-templates.ts
@@ -1,8 +1,10 @@
import { ClusterTemplates } from '@osac/types';
+import { useSession } from '../../hooks/use-session';
import { useApiFetch } from '../api-context';
import { apiQueryKey } from '../types';
import { useApiQuery } from '../use-api-query';
+import { usePrivateClusterTemplates } from './private/cluster-templates';
export const useClusterTemplate = (id: string | undefined) => {
const client = useApiFetch(ClusterTemplates);
@@ -13,3 +15,21 @@ export const useClusterTemplate = (id: string | undefined) => {
enabled: Boolean(id),
});
};
+
+export const useClusterTemplates = (enabled = true) => {
+ const client = useApiFetch(ClusterTemplates);
+ return useApiQuery({
+ queryKey: apiQueryKey('v1/cluster_templates'),
+ queryFn: () => client.list({}),
+ select: (data) => data.items,
+ enabled,
+ });
+};
+
+export const useAdminClusterTemplates = (enabled = true) => {
+ const { role } = useSession();
+ const isProviderAdmin = role === 'providerAdmin';
+ const publicResult = useClusterTemplates(enabled && !isProviderAdmin);
+ const privateResult = usePrivateClusterTemplates(enabled && isProviderAdmin);
+ return isProviderAdmin ? privateResult : publicResult;
+};
diff --git a/libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts b/libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts
index 4af1d13b..5aaa3902 100644
--- a/libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts
+++ b/libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts
@@ -1,10 +1,17 @@
-import { describe } from 'vitest';
+import { createRouterTransport } from '@connectrpc/connect';
+import { act, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
import type { ComputeInstanceCatalogItem } from '@osac/types';
import { ComputeInstanceCatalogItems } from '@osac/types';
+import { ComputeInstanceCatalogItems as PrivateComputeInstanceCatalogItems } from '@osac/types/private';
-import { useComputeInstanceCatalogItems } from './compute-instance-catalog-item';
+import {
+ useComputeInstanceCatalogItems,
+ useCreateComputeInstanceCatalogItem,
+} from './compute-instance-catalog-item';
import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers';
+import { renderHookWithTransport as renderWithTransport } from '../../test-utils/renderHookWithTransport';
const item: ComputeInstanceCatalogItem = {
$typeName: 'osac.public.v1.ComputeInstanceCatalogItem',
@@ -31,3 +38,47 @@ describe('useComputeInstanceCatalogItems', () => {
}),
});
});
+
+const makeItem = (id: string) => ({ id, title: `item-${id}` });
+
+describe('useCreateComputeInstanceCatalogItem', () => {
+ it('calls the private client for providerAdmin', async () => {
+ const createFn = vi.fn(() => ({ object: makeItem('a') }));
+ const transport = createRouterTransport((router) => {
+ router.service(PrivateComputeInstanceCatalogItems, { create: createFn });
+ });
+
+ const { result } = renderWithTransport(
+ () => useCreateComputeInstanceCatalogItem(),
+ transport,
+ 'providerAdmin',
+ );
+
+ act(() => {
+ result.current.mutate({ title: 'item-a', published: false });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(createFn).toHaveBeenCalled();
+ });
+
+ it('calls the public client for tenantAdmin', async () => {
+ const createFn = vi.fn(() => ({ object: makeItem('b') }));
+ const transport = createRouterTransport((router) => {
+ router.service(ComputeInstanceCatalogItems, { create: createFn });
+ });
+
+ const { result } = renderWithTransport(
+ () => useCreateComputeInstanceCatalogItem(),
+ transport,
+ 'tenantAdmin',
+ );
+
+ act(() => {
+ result.current.mutate({ title: 'item-b', published: false });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(createFn).toHaveBeenCalled();
+ });
+});
diff --git a/libs/ui-components/src/api/v1/compute-instance-catalog-item.ts b/libs/ui-components/src/api/v1/compute-instance-catalog-item.ts
index f0341a2e..2391a214 100644
--- a/libs/ui-components/src/api/v1/compute-instance-catalog-item.ts
+++ b/libs/ui-components/src/api/v1/compute-instance-catalog-item.ts
@@ -1,8 +1,16 @@
-import { ComputeInstanceCatalogItems } from '@osac/types';
+import { type MessageInitShape } from '@bufbuild/protobuf';
+import { useMutation } from '@tanstack/react-query';
+import { ComputeInstanceCatalogItemSchema, ComputeInstanceCatalogItems } from '@osac/types';
+import {
+ ComputeInstanceCatalogItemSchema as PrivateComputeInstanceCatalogItemSchema,
+ ComputeInstanceCatalogItems as PrivateComputeInstanceCatalogItems,
+} from '@osac/types/private';
+
+import { useSession } from '../../hooks/use-session';
import { useApiFetch } from '../api-context';
import { type ListParams, apiQueryKey } from '../types';
-import { useApiQuery } from '../use-api-query';
+import { useApiQuery, useApiQueryClient } from '../use-api-query';
export const useComputeInstanceCatalogItems = (params: ListParams = {}, enabled = true) => {
const client = useApiFetch(ComputeInstanceCatalogItems);
@@ -24,3 +32,22 @@ export const useComputeInstanceCatalogItem = (id: string | undefined) => {
enabled: Boolean(trimmedId),
});
};
+
+export const useCreateComputeInstanceCatalogItem = () => {
+ const { role } = useSession();
+ const isProviderAdmin = role === 'providerAdmin';
+ const publicClient = useApiFetch(ComputeInstanceCatalogItems);
+ const privateClient = useApiFetch(PrivateComputeInstanceCatalogItems);
+ const qc = useApiQueryClient();
+ return useMutation({
+ mutationFn: (item: MessageInitShape) =>
+ (isProviderAdmin
+ ? privateClient.create({
+ object: item as MessageInitShape,
+ })
+ : publicClient.create({ object: item })
+ ).then((response) => response.object),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: apiQueryKey('v1/compute_instance_catalog_items') }),
+ });
+};
diff --git a/libs/ui-components/src/api/v1/compute-instance-templates.test.ts b/libs/ui-components/src/api/v1/compute-instance-templates.test.ts
new file mode 100644
index 00000000..ef0ffdf5
--- /dev/null
+++ b/libs/ui-components/src/api/v1/compute-instance-templates.test.ts
@@ -0,0 +1,65 @@
+import { createRouterTransport } from '@connectrpc/connect';
+import { waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { ComputeInstanceTemplates } from '@osac/types';
+import { ComputeInstanceTemplates as PrivateComputeInstanceTemplates } from '@osac/types/private';
+
+import {
+ useAdminComputeInstanceTemplates,
+ useComputeInstanceTemplates,
+} from './compute-instance-templates';
+import { renderHookWithTransport as renderWithTransport } from '../../test-utils/renderHookWithTransport';
+
+const makeTemplate = (id: string) => ({ id, metadata: { name: `template-${id}` } });
+
+describe('useComputeInstanceTemplates', () => {
+ it('lists public compute instance templates', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(ComputeInstanceTemplates, { list: () => ({ items: [makeTemplate('a')] }) });
+ });
+
+ const { result } = renderWithTransport(() => useComputeInstanceTemplates(), transport);
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('a')]);
+ });
+});
+
+describe('useAdminComputeInstanceTemplates', () => {
+ it('calls the private client for providerAdmin', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(ComputeInstanceTemplates, { list: () => ({ items: [] }) });
+ router.service(PrivateComputeInstanceTemplates, {
+ list: () => ({ items: [makeTemplate('admin')] }),
+ });
+ });
+
+ const { result } = renderWithTransport(
+ () => useAdminComputeInstanceTemplates(),
+ transport,
+ 'providerAdmin',
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('admin')]);
+ });
+
+ it('calls the public client for tenantAdmin', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(ComputeInstanceTemplates, {
+ list: () => ({ items: [makeTemplate('tenant')] }),
+ });
+ router.service(PrivateComputeInstanceTemplates, { list: () => ({ items: [] }) });
+ });
+
+ const { result } = renderWithTransport(
+ () => useAdminComputeInstanceTemplates(),
+ transport,
+ 'tenantAdmin',
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeTemplate('tenant')]);
+ });
+});
diff --git a/libs/ui-components/src/api/v1/compute-instance-templates.ts b/libs/ui-components/src/api/v1/compute-instance-templates.ts
new file mode 100644
index 00000000..e426984b
--- /dev/null
+++ b/libs/ui-components/src/api/v1/compute-instance-templates.ts
@@ -0,0 +1,25 @@
+import { ComputeInstanceTemplates } from '@osac/types';
+
+import { useSession } from '../../hooks/use-session';
+import { useApiFetch } from '../api-context';
+import { apiQueryKey } from '../types';
+import { useApiQuery } from '../use-api-query';
+import { usePrivateComputeInstanceTemplates } from './private/compute-instance-templates';
+
+export const useComputeInstanceTemplates = (enabled = true) => {
+ const client = useApiFetch(ComputeInstanceTemplates);
+ return useApiQuery({
+ queryKey: apiQueryKey('v1/compute_instance_templates'),
+ queryFn: () => client.list({}),
+ select: (data) => data.items,
+ enabled,
+ });
+};
+
+export const useAdminComputeInstanceTemplates = (enabled = true) => {
+ const { role } = useSession();
+ const isProviderAdmin = role === 'providerAdmin';
+ const publicResult = useComputeInstanceTemplates(enabled && !isProviderAdmin);
+ const privateResult = usePrivateComputeInstanceTemplates(enabled && isProviderAdmin);
+ return isProviderAdmin ? privateResult : publicResult;
+};
diff --git a/libs/ui-components/src/api/v1/organization.ts b/libs/ui-components/src/api/v1/organization.ts
deleted file mode 100644
index b9f1456a..00000000
--- a/libs/ui-components/src/api/v1/organization.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Organizations } from '@osac/types';
-
-import { useApiFetch } from '../api-context';
-import { type ListParams, apiQueryKey } from '../types';
-import { useApiQuery } from '../use-api-query';
-
-export const useOrganizations = (params: ListParams = {}) => {
- const client = useApiFetch(Organizations);
- return useApiQuery({
- queryKey: apiQueryKey('v1/organizations', undefined, params),
- queryFn: () => client.list(params),
- select: (data) => data.items,
- });
-};
diff --git a/libs/ui-components/src/api/v1/private/baremetal-instance-templates.ts b/libs/ui-components/src/api/v1/private/baremetal-instance-templates.ts
new file mode 100644
index 00000000..ebd2eaa8
--- /dev/null
+++ b/libs/ui-components/src/api/v1/private/baremetal-instance-templates.ts
@@ -0,0 +1,15 @@
+import { BareMetalInstanceTemplates } from '@osac/types/private';
+
+import { useApiFetch } from '../../api-context';
+import { apiQueryKey } from '../../types';
+import { useApiQuery } from '../../use-api-query';
+
+export const usePrivateBareMetalInstanceTemplates = (enabled = true) => {
+ const client = useApiFetch(BareMetalInstanceTemplates);
+ return useApiQuery({
+ queryKey: apiQueryKey('v1/baremetal_instance_templates_private'),
+ queryFn: () => client.list({}),
+ select: (data) => data.items,
+ enabled,
+ });
+};
diff --git a/libs/ui-components/src/api/v1/private/cluster-templates.ts b/libs/ui-components/src/api/v1/private/cluster-templates.ts
new file mode 100644
index 00000000..7aad5d70
--- /dev/null
+++ b/libs/ui-components/src/api/v1/private/cluster-templates.ts
@@ -0,0 +1,15 @@
+import { ClusterTemplates } from '@osac/types/private';
+
+import { useApiFetch } from '../../api-context';
+import { apiQueryKey } from '../../types';
+import { useApiQuery } from '../../use-api-query';
+
+export const usePrivateClusterTemplates = (enabled = true) => {
+ const client = useApiFetch(ClusterTemplates);
+ return useApiQuery({
+ queryKey: apiQueryKey('v1/cluster_templates_private'),
+ queryFn: () => client.list({}),
+ select: (data) => data.items,
+ enabled,
+ });
+};
diff --git a/libs/ui-components/src/api/v1/private/compute-instance-templates.ts b/libs/ui-components/src/api/v1/private/compute-instance-templates.ts
new file mode 100644
index 00000000..6e6d4c50
--- /dev/null
+++ b/libs/ui-components/src/api/v1/private/compute-instance-templates.ts
@@ -0,0 +1,15 @@
+import { ComputeInstanceTemplates } from '@osac/types/private';
+
+import { useApiFetch } from '../../api-context';
+import { apiQueryKey } from '../../types';
+import { useApiQuery } from '../../use-api-query';
+
+export const usePrivateComputeInstanceTemplates = (enabled = true) => {
+ const client = useApiFetch(ComputeInstanceTemplates);
+ return useApiQuery({
+ queryKey: apiQueryKey('v1/compute_instance_templates_private'),
+ queryFn: () => client.list({}),
+ select: (data) => data.items,
+ enabled,
+ });
+};
diff --git a/libs/ui-components/src/api/v1/private/tenant.ts b/libs/ui-components/src/api/v1/private/tenant.ts
new file mode 100644
index 00000000..563c040b
--- /dev/null
+++ b/libs/ui-components/src/api/v1/private/tenant.ts
@@ -0,0 +1,17 @@
+import { Tenants } from '@osac/types/private';
+
+import { useApiFetch } from '../../api-context';
+import { apiQueryKey } from '../../types';
+import { useApiQuery } from '../../use-api-query';
+
+// CSP Admin only (see CatalogItemGeneralFields) — picking which tenant to scope a catalog item
+// to requires visibility across all tenants, which only the private API grants.
+export const usePrivateTenants = (enabled = true) => {
+ const client = useApiFetch(Tenants);
+ return useApiQuery({
+ queryKey: apiQueryKey('v1/tenants_private'),
+ queryFn: () => client.list({}),
+ select: (data) => data.items,
+ enabled,
+ });
+};
diff --git a/libs/ui-components/src/api/v1/projects.test.ts b/libs/ui-components/src/api/v1/projects.test.ts
new file mode 100644
index 00000000..ebbe15f8
--- /dev/null
+++ b/libs/ui-components/src/api/v1/projects.test.ts
@@ -0,0 +1,32 @@
+import React, { type ReactNode, createElement } from 'react';
+import { createRouterTransport } from '@connectrpc/connect';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { Projects } from '@osac/types';
+
+import { useProjects } from './projects';
+import { ApiProvider } from '../api-context';
+
+const makeProject = (id: string) => ({ id, metadata: { name: `project-${id}` } });
+
+describe('useProjects', () => {
+ it('lists projects', async () => {
+ const transport = createRouterTransport((router) => {
+ router.service(Projects, { list: () => ({ items: [makeProject('a')] }) });
+ });
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(
+ ApiProvider,
+ { transport } as React.ComponentProps,
+ createElement(QueryClientProvider, { client: queryClient }, children),
+ );
+
+ const { result } = renderHook(() => useProjects(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject([makeProject('a')]);
+ });
+});
diff --git a/libs/ui-components/src/api/v1/projects.ts b/libs/ui-components/src/api/v1/projects.ts
new file mode 100644
index 00000000..09b1c428
--- /dev/null
+++ b/libs/ui-components/src/api/v1/projects.ts
@@ -0,0 +1,15 @@
+import { Projects } from '@osac/types';
+
+import { useApiFetch } from '../api-context';
+import { apiQueryKey } from '../types';
+import { useApiQuery } from '../use-api-query';
+
+export const useProjects = (enabled = true) => {
+ const client = useApiFetch(Projects);
+ return useApiQuery({
+ queryKey: apiQueryKey('v1/projects'),
+ queryFn: () => client.list({}),
+ select: (data) => data.items,
+ enabled,
+ });
+};
diff --git a/libs/ui-components/src/components/Form/RadioButtonField.tsx b/libs/ui-components/src/components/Form/RadioButtonField.tsx
index 2b032462..c30ee9e3 100644
--- a/libs/ui-components/src/components/Form/RadioButtonField.tsx
+++ b/libs/ui-components/src/components/Form/RadioButtonField.tsx
@@ -1,4 +1,4 @@
-import { FormGroup, Radio } from '@patternfly/react-core';
+import { Flex, FlexItem, FormGroup, Radio } from '@patternfly/react-core';
import { useField } from 'formik';
import { getVisibleFieldError } from './fieldError';
@@ -43,22 +43,25 @@ export const RadioButtonField = ({
role="radiogroup"
isInline={isInline}
>
- {options.map((option) => (
- {
- const parsed =
- option.value === 'true' ? true : option.value === 'false' ? false : option.value;
- void field.onChange({ target: { name, value: parsed } });
- }}
- onBlur={field.onBlur}
- />
- ))}
+
+ {options.map((option) => (
+
+ {
+ const parsed =
+ option.value === 'true' ? true : option.value === 'false' ? false : option.value;
+ void field.onChange({ target: { name, value: parsed } });
+ }}
+ onBlur={field.onBlur}
+ />
+
+ ))}
+
);
diff --git a/libs/ui-components/src/components/Form/SwitchField.test.tsx b/libs/ui-components/src/components/Form/SwitchField.test.tsx
new file mode 100644
index 00000000..009a0fca
--- /dev/null
+++ b/libs/ui-components/src/components/Form/SwitchField.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { SwitchField } from './SwitchField';
+
+const renderSwitch = (initialValue: boolean, isDisabled = false) => {
+ render(
+ undefined}>
+ {({ values }) => (
+ <>
+
+
+ >
+ )}
+ ,
+ );
+};
+
+describe('SwitchField', () => {
+ it('reflects the initial Formik value', () => {
+ renderSwitch(true);
+
+ expect(screen.getByRole('switch', { name: 'Enabled' })).toBeChecked();
+ });
+
+ it('updates Formik when toggled on', async () => {
+ const user = userEvent.setup();
+ renderSwitch(false);
+
+ await user.click(screen.getByRole('switch', { name: 'Enabled' }));
+
+ expect(screen.getByLabelText('formik-value')).toHaveTextContent('true');
+ });
+
+ it('updates Formik when toggled off', async () => {
+ const user = userEvent.setup();
+ renderSwitch(true);
+
+ await user.click(screen.getByRole('switch', { name: 'Enabled' }));
+
+ expect(screen.getByLabelText('formik-value')).toHaveTextContent('false');
+ });
+
+ it('does not respond to clicks when disabled', async () => {
+ const user = userEvent.setup();
+ renderSwitch(false, true);
+
+ await user.click(screen.getByRole('switch', { name: 'Enabled' }));
+
+ expect(screen.getByLabelText('formik-value')).toHaveTextContent('false');
+ });
+});
diff --git a/libs/ui-components/src/components/Form/SwitchField.tsx b/libs/ui-components/src/components/Form/SwitchField.tsx
new file mode 100644
index 00000000..3116e238
--- /dev/null
+++ b/libs/ui-components/src/components/Form/SwitchField.tsx
@@ -0,0 +1,27 @@
+import { FormGroup, Switch } from '@patternfly/react-core';
+import { useField } from 'formik';
+
+interface SwitchFieldProps {
+ name: string;
+ label: string;
+ fieldId: string;
+ isDisabled?: boolean;
+}
+
+export const SwitchField = ({ name, label, fieldId, isDisabled = false }: SwitchFieldProps) => {
+ const [field, , helpers] = useField(name);
+
+ return (
+
+ {
+ void helpers.setValue(checked);
+ }}
+ />
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts
index a9a45dfa..960e8386 100644
--- a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts
+++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts
@@ -80,34 +80,6 @@ describe('catalog display with wire field_definitions', () => {
published: true,
template: '',
fieldDefinitions: [
- {
- $typeName: 'osac.public.v1.FieldDefinition',
- path: 'cores',
- displayName: 'vCPUs',
- editable: true,
- default: {
- $typeName: 'google.protobuf.Value',
- kind: {
- case: 'numberValue',
- value: 4,
- },
- },
- validationSchema: '',
- },
- {
- $typeName: 'osac.public.v1.FieldDefinition',
- path: 'memory_gib',
- displayName: 'RAM (GiB)',
- editable: true,
- default: {
- $typeName: 'google.protobuf.Value',
- kind: {
- case: 'numberValue',
- value: 8,
- },
- },
- validationSchema: '',
- },
{
$typeName: 'osac.public.v1.FieldDefinition',
path: 'boot_disk.size_gib',
@@ -125,12 +97,8 @@ describe('catalog display with wire field_definitions', () => {
],
};
- expect(catalogItemResourceParts(wireItem)).toEqual([
- '4 vCPUs',
- '8 RAM (GiB)',
- '40 Boot disk (GiB)',
- ]);
- expect(catalogItemResourceLine(wireItem)).toBe('4 vCPUs · 8 RAM (GiB) · 40 Boot disk (GiB)');
+ expect(catalogItemResourceParts(wireItem)).toEqual(['40 Boot disk (GiB)']);
+ expect(catalogItemResourceLine(wireItem)).toBe('40 Boot disk (GiB)');
});
it('renders node set resource summary from cluster catalog item JSON', () => {
diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts
index cdedf9ee..29f2a607 100644
--- a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts
+++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts
@@ -71,8 +71,6 @@ export const catalogFieldDefinitionForPath = (
};
const FALLBACK_RESOURCE_LABELS: Record = {
- cores: 'vCPU',
- memory_gib: 'Memory',
'boot_disk.size_gib': 'Boot disk',
};
diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemGeneralFields.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemGeneralFields.test.tsx
new file mode 100644
index 00000000..a50dec99
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/CatalogItemGeneralFields.test.tsx
@@ -0,0 +1,141 @@
+import { screen } from '@testing-library/react';
+import { Formik } from 'formik';
+import { describe, expect, it, vi } from 'vitest';
+
+import { CatalogItemGeneralFields } from './CatalogItemGeneralFields';
+import * as tenantApi from '../../api/v1/private/tenant';
+import * as projectsApi from '../../api/v1/projects';
+import { SessionProvider } from '../../hooks/use-session';
+import { renderWithProviders } from '../../test-utils/TestProviders';
+
+vi.mock('../../api/v1/private/tenant', () => ({ usePrivateTenants: vi.fn() }));
+vi.mock('../../api/v1/projects', () => ({ useProjects: vi.fn() }));
+
+const asQueryResult = (data: T) =>
+ ({ data, isLoading: false, error: null }) as unknown as ReturnType<
+ typeof tenantApi.usePrivateTenants
+ >;
+
+const mockLists = () => {
+ vi.mocked(tenantApi.usePrivateTenants).mockReturnValue(
+ asQueryResult([{ id: 'acme', metadata: { name: 'Acme' } }]),
+ );
+ vi.mocked(projectsApi.useProjects).mockReturnValue(
+ asQueryResult([{ id: 'proj-1', metadata: { name: 'Project One' } }]) as unknown as ReturnType<
+ typeof projectsApi.useProjects
+ >,
+ );
+};
+
+interface Values {
+ title: string;
+ resourceName: string;
+ description: string;
+ template: { value: string; label: string };
+ scope: {
+ level: string;
+ tenant: { value: string; label: string };
+ project: { value: string; label: string };
+ };
+}
+
+const initialValues: Values = {
+ title: '',
+ resourceName: '',
+ description: '',
+ template: { value: '', label: '' },
+ scope: {
+ level: 'general',
+ tenant: { value: '', label: '' },
+ project: { value: '', label: '' },
+ },
+};
+
+const renderFields = (role: 'providerAdmin' | 'tenantAdmin') =>
+ renderWithProviders(
+
+ undefined}>
+
+
+ ,
+ );
+
+describe('CatalogItemGeneralFields', () => {
+ it('renders Title, Name, Description, and Template fields', () => {
+ mockLists();
+ renderFields('providerAdmin');
+
+ expect(screen.getByLabelText(/^Title/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
+ expect(screen.getByLabelText('Description')).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Template/)).toBeInTheDocument();
+ });
+
+ it('shows General/Organization scope options for a CSP Admin', () => {
+ mockLists();
+ renderFields('providerAdmin');
+
+ expect(screen.getByRole('radio', { name: 'General' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'Organization' })).toBeInTheDocument();
+ expect(screen.queryByRole('radio', { name: 'Project' })).not.toBeInTheDocument();
+ });
+
+ it('reveals a tenant selector when a CSP Admin selects Organization scope', async () => {
+ mockLists();
+ const { user } = renderFields('providerAdmin');
+
+ expect(screen.queryByLabelText(/^Select organization/)).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('radio', { name: 'Organization' }));
+
+ expect(screen.getByLabelText(/^Select organization/)).toBeInTheDocument();
+ });
+
+ it('shows Organization/Project scope options for a Tenant Admin', () => {
+ mockLists();
+ renderFields('tenantAdmin');
+
+ expect(screen.getByRole('radio', { name: 'Organization' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'Project' })).toBeInTheDocument();
+ expect(screen.queryByRole('radio', { name: 'General' })).not.toBeInTheDocument();
+ });
+
+ it('reveals a project selector when a Tenant Admin selects Project scope', async () => {
+ mockLists();
+ const { user } = renderFields('tenantAdmin');
+
+ expect(screen.queryByLabelText(/^Select project/)).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('radio', { name: 'Project' }));
+
+ expect(screen.getByLabelText(/^Select project/)).toBeInTheDocument();
+ });
+
+ it('shows the project display name, not its id, in the project selector options', async () => {
+ mockLists();
+ const { user } = renderFields('tenantAdmin');
+
+ await user.click(screen.getByRole('radio', { name: 'Project' }));
+ await user.click(screen.getByLabelText(/^Select project/));
+
+ expect(screen.getByRole('option', { name: 'Project One' })).toBeInTheDocument();
+ expect(screen.queryByRole('option', { name: 'proj-1' })).not.toBeInTheDocument();
+ });
+
+ it('does not call the private Tenants API for a Tenant Admin', () => {
+ mockLists();
+ renderFields('tenantAdmin');
+
+ expect(tenantApi.usePrivateTenants).toHaveBeenCalledWith(false);
+ });
+
+ it('calls the private Tenants API for a CSP Admin', () => {
+ mockLists();
+ renderFields('providerAdmin');
+
+ expect(tenantApi.usePrivateTenants).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemGeneralFields.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemGeneralFields.tsx
new file mode 100644
index 00000000..b4f42c21
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/CatalogItemGeneralFields.tsx
@@ -0,0 +1,100 @@
+import { useField } from 'formik';
+
+import { usePrivateTenants } from '../../api/v1/private/tenant';
+import { useProjects } from '../../api/v1/projects';
+import { useSession } from '../../hooks/use-session';
+import { useTranslation } from '../../hooks/useTranslation';
+import { InputField } from '../Form/InputField';
+import OsacForm from '../Form/OsacForm';
+import { RadioButtonField } from '../Form/RadioButtonField';
+import { SelectField, type SelectFieldOption } from '../Form/SelectField';
+
+interface CatalogItemGeneralFieldsProps {
+ templates: SelectFieldOption[];
+ templatesLoading: boolean;
+}
+
+export const CatalogItemGeneralFields = ({
+ templates,
+ templatesLoading,
+}: CatalogItemGeneralFieldsProps) => {
+ const { t } = useTranslation();
+ const { role } = useSession();
+ const [scopeLevelField] = useField('scope.level');
+ // Only a CSP Admin ever sees the organization dropdown below — a Tenant Admin has no permission
+ // to call the private Tenants API this hook uses, so it must stay disabled for that role.
+ const { data: tenants = [] } = usePrivateTenants(role === 'providerAdmin');
+ const { data: projects = [] } = useProjects();
+
+ const scopeOptions =
+ role === 'providerAdmin'
+ ? [
+ { value: 'general', label: t('General') },
+ { value: 'organization', label: t('Organization') },
+ ]
+ : [
+ { value: 'organization', label: t('Organization') },
+ { value: 'project', label: t('Project') },
+ ];
+
+ return (
+
+
+
+
+
+
+ {role === 'providerAdmin' && scopeLevelField.value === 'organization' ? (
+ ({
+ value: tenant.id,
+ label: tenant.metadata?.name || tenant.id,
+ }))}
+ placeholder={t('Select an organization')}
+ isRequired
+ />
+ ) : null}
+ {role !== 'providerAdmin' && scopeLevelField.value === 'project' ? (
+ ({
+ value: project.id,
+ label: project.metadata?.name || project.id,
+ }))}
+ placeholder={t('Select a project')}
+ isRequired
+ />
+ ) : null}
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemWizardFooter.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemWizardFooter.test.tsx
new file mode 100644
index 00000000..e89f98d4
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/CatalogItemWizardFooter.test.tsx
@@ -0,0 +1,153 @@
+import { useState } from 'react';
+import { Wizard, WizardStep } from '@patternfly/react-core';
+import { screen } from '@testing-library/react';
+import { FormikProvider, useField, useFormik } from 'formik';
+import { describe, expect, it, vi } from 'vitest';
+import * as Yup from 'yup';
+
+import { CatalogItemWizardFooter } from './CatalogItemWizardFooter';
+import { renderWithProviders } from '../../test-utils/TestProviders';
+
+interface Values {
+ title: string;
+ ssh: string;
+}
+
+const STEP_IDS = ['general', 'access'] as const;
+
+const fullFormSchema = Yup.object({
+ title: Yup.string().required('Name is required'),
+ ssh: Yup.string().required('SSH is required'),
+});
+
+const stepSchema = (stepId: (typeof STEP_IDS)[number]) =>
+ stepId === 'general'
+ ? Yup.object({ title: Yup.string().required('Name is required') })
+ : Yup.object({ ssh: Yup.string().required('SSH is required') });
+
+const TextField = ({ name, label }: { name: string; label: string }) => {
+ const [field] = useField(name);
+ return ;
+};
+
+const TestWizard = ({
+ initialValues,
+ onSubmit,
+}: {
+ initialValues: Values;
+ onSubmit: () => void;
+}) => {
+ const [activeStepId, setActiveStepId] = useState<(typeof STEP_IDS)[number]>('general');
+ const [validationAlert, setValidationAlert] = useState(false);
+ const formik = useFormik({
+ initialValues,
+ validationSchema: stepSchema(activeStepId),
+ validateOnBlur: true,
+ validateOnChange: false,
+ onSubmit,
+ });
+
+ return (
+
+ setActiveStepId(id as (typeof STEP_IDS)[number])}
+ fullFormSchema={fullFormSchema}
+ setValidationAlert={setValidationAlert}
+ isPending={false}
+ />
+ }
+ >
+
+ {validationAlert ? Validation error
: null}
+
+
+
+ {validationAlert ? Validation error
: null}
+
+ {formik.errors.title ? (
+
+ ) : null}
+
+
+
+ );
+};
+
+describe('CatalogItemWizardFooter', () => {
+ it('advances to the next step when the current step is valid', async () => {
+ const onSubmit = vi.fn();
+ const { user } = renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument();
+ });
+
+ it('blocks advancing when the current step is invalid', async () => {
+ const onSubmit = vi.fn();
+ const { user } = renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('Validation error')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument();
+ });
+
+ it('blocks final submit when a previously-visited earlier step was cleared', async () => {
+ const onSubmit = vi.fn();
+ const { user } = renderWithProviders(
+ ,
+ );
+
+ // Visit Access (valid title lets Next through), then go back and clear the title.
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Back' }));
+ await user.clear(screen.getByRole('textbox', { name: 'Title' }));
+
+ // Jump forward via the wizard nav (already-visited step; not gated by the footer's Next handler).
+ await user.click(screen.getByRole('button', { name: 'Access' }));
+ await user.type(screen.getByRole('textbox', { name: 'SSH' }), 'valid-key');
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ expect(await screen.findByText('Validation error')).toBeInTheDocument();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('populates Formik field errors for the offending earlier step on a full-form failure', async () => {
+ const onSubmit = vi.fn();
+ const { user } = renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Back' }));
+ await user.clear(screen.getByRole('textbox', { name: 'Title' }));
+ await user.click(screen.getByRole('button', { name: 'Access' }));
+ await user.type(screen.getByRole('textbox', { name: 'SSH' }), 'valid-key');
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ expect(await screen.findByLabelText('title-error')).toHaveTextContent('Name is required');
+ });
+
+ it('submits when the full form is valid on the final step', async () => {
+ const onSubmit = vi.fn();
+ const { user } = renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.type(screen.getByRole('textbox', { name: 'SSH' }), 'valid-key');
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ expect(onSubmit).toHaveBeenCalled();
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemWizardFooter.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemWizardFooter.tsx
new file mode 100644
index 00000000..00c10023
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/CatalogItemWizardFooter.tsx
@@ -0,0 +1,107 @@
+import { useLayoutEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button, Flex, useWizardContext } from '@patternfly/react-core';
+import { type FormikProps, yupToFormErrors } from 'formik';
+import type { AnyObjectSchema, ValidationError } from 'yup';
+
+import { useTranslation } from '../../hooks/useTranslation';
+
+interface CatalogItemWizardFooterProps {
+ formik: FormikProps;
+ stepIds: readonly string[];
+ onActiveStepIdChange: (stepId: string) => void;
+ /** Validated in full (not just the active step's subset) before the final submit is allowed through. */
+ fullFormSchema: AnyObjectSchema;
+ setValidationAlert: (visible: boolean) => void;
+ isPending: boolean;
+}
+
+export const CatalogItemWizardFooter = ({
+ formik,
+ stepIds,
+ onActiveStepIdChange,
+ fullFormSchema,
+ setValidationAlert,
+ isPending,
+}: CatalogItemWizardFooterProps) => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { activeStep, goToStepByIndex } = useWizardContext();
+ const activeStepId =
+ typeof activeStep?.id === 'string' && stepIds.includes(activeStep.id)
+ ? activeStep.id
+ : stepIds[0];
+
+ useLayoutEffect(() => {
+ onActiveStepIdChange(activeStepId);
+ }, [activeStepId, onActiveStepIdChange]);
+
+ const stepIndex = activeStep?.index ?? 1;
+ const isFirst = stepIndex <= 1;
+ // Derived from stepIds, not activeStep.index: the PatternFly step index counts every WizardStep
+ // in the tree, so it would silently drift from stepIds.length if a non-stepIds step (e.g. a
+ // future review step) were ever added.
+ const isLast = stepIds.indexOf(activeStepId) >= stepIds.length - 1;
+
+ const handleBack = () => {
+ if (isFirst || isPending) {
+ return;
+ }
+ setValidationAlert(false);
+ goToStepByIndex(stepIndex - 1);
+ };
+
+ const handleNextOrSubmit = () => {
+ if (isPending) {
+ return;
+ }
+ void formik.validateForm().then((errors) => {
+ if (Object.keys(errors).length > 0) {
+ setValidationAlert(true);
+ return;
+ }
+ if (!isLast) {
+ setValidationAlert(false);
+ goToStepByIndex(stepIndex + 1);
+ return;
+ }
+ // The active step's own schema only covers its own fields — validate the full form here so a
+ // field cleared on a previously-visited earlier step can't slip through on final submit. Use
+ // validate() rather than isValid() so a failure on an earlier step populates Formik's field
+ // errors instead of leaving the admin stuck on Create with no indication of what to fix.
+ void fullFormSchema
+ .validate(formik.values, { abortEarly: false })
+ .then(() => {
+ setValidationAlert(false);
+ void formik.submitForm();
+ })
+ .catch((err: ValidationError) => {
+ formik.setErrors(yupToFormErrors(err));
+ setValidationAlert(true);
+ });
+ });
+ };
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/catalogItemGeneralSchema.ts b/libs/ui-components/src/components/catalogManagement/catalogItemGeneralSchema.ts
new file mode 100644
index 00000000..58b8d512
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/catalogItemGeneralSchema.ts
@@ -0,0 +1,11 @@
+import type { TFunction } from 'i18next';
+import * as Yup from 'yup';
+
+/** Requires a LabeledResourceRef (`{value, label}`) to have a non-empty `value`. */
+export const resourceRefRequiredSchema = (message: string) =>
+ Yup.object({ value: Yup.string().required() }).test('resource-ref-selected', message, (ref) =>
+ Boolean(ref?.value?.trim()),
+ );
+
+export const templateRequiredSchema = (t: TFunction) =>
+ resourceRefRequiredSchema(t('Template is required'));
diff --git a/libs/ui-components/src/components/catalogManagement/catalogItemScope.test.ts b/libs/ui-components/src/components/catalogManagement/catalogItemScope.test.ts
new file mode 100644
index 00000000..0d5934f9
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/catalogItemScope.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildScopePayloadFields, initialScopeForRole } from './catalogItemScope';
+
+describe('initialScopeForRole', () => {
+ it('defaults to general for a CSP Admin', () => {
+ expect(initialScopeForRole('providerAdmin').level).toBe('general');
+ });
+
+ it('defaults to organization for a Tenant Admin', () => {
+ expect(initialScopeForRole('tenantAdmin').level).toBe('organization');
+ });
+});
+
+describe('buildScopePayloadFields', () => {
+ it('sends tenant for a CSP Admin scoped to an organization', () => {
+ const scope = {
+ level: 'organization',
+ tenant: { value: 'acme', label: 'Acme' },
+ project: { value: '', label: '' },
+ };
+ const result = buildScopePayloadFields(scope, 'providerAdmin', 'my-cluster');
+
+ expect(result.tenant).toBe('acme');
+ expect(result.metadata.name).toBe('my-cluster');
+ });
+
+ it('sends an empty tenant for a CSP Admin scoped to general', () => {
+ const scope = {
+ level: 'general',
+ tenant: { value: 'acme', label: 'Acme' },
+ project: { value: '', label: '' },
+ };
+ const result = buildScopePayloadFields(scope, 'providerAdmin', 'my-cluster');
+
+ expect(result.tenant).toBe('');
+ });
+
+ it('sends metadata.project for a Tenant Admin scoped to a project', () => {
+ const scope = {
+ level: 'project',
+ tenant: { value: '', label: '' },
+ project: { value: 'proj-1', label: 'Project One' },
+ };
+ const result = buildScopePayloadFields(scope, 'tenantAdmin', 'my-cluster');
+
+ expect(result.metadata.project).toBe('proj-1');
+ expect(result.metadata.name).toBe('my-cluster');
+ });
+
+ it('sends an empty metadata.project for a Tenant Admin scoped to organization', () => {
+ const scope = {
+ level: 'organization',
+ tenant: { value: '', label: '' },
+ project: { value: 'proj-1', label: 'Project One' },
+ };
+ const result = buildScopePayloadFields(scope, 'tenantAdmin', 'my-cluster');
+
+ expect(result.metadata.project).toBe('');
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/catalogItemScope.ts b/libs/ui-components/src/components/catalogManagement/catalogItemScope.ts
new file mode 100644
index 00000000..647257b3
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/catalogItemScope.ts
@@ -0,0 +1,66 @@
+import type { TFunction } from 'i18next';
+import * as Yup from 'yup';
+
+import type { DemoShellRole } from '../../shellTypes';
+import { EMPTY_LABELED_RESOURCE_REF, type LabeledResourceRef } from '../Form/labeledResourceRef';
+
+export interface ScopeValues {
+ level: string;
+ tenant: LabeledResourceRef;
+ project: LabeledResourceRef;
+}
+
+/** CSP Admin's scope options start at 'general'; Tenant Admin has no 'general' option, so their default must be 'organization'. */
+export const initialScopeForRole = (role: DemoShellRole): ScopeValues => ({
+ level: role === 'providerAdmin' ? 'general' : 'organization',
+ tenant: EMPTY_LABELED_RESOURCE_REF,
+ project: EMPTY_LABELED_RESOURCE_REF,
+});
+
+/**
+ * Requires picking an organization/project once the corresponding scope level is selected — the
+ * dropdown for it only renders for the matching role (see CatalogItemGeneralFields), so a CSP Admin
+ * is only ever asked for `tenant` and a Tenant Admin only ever asked for `project`.
+ */
+export const scopeValidationSchema = (t: TFunction, role: DemoShellRole) =>
+ Yup.object({
+ tenant: Yup.object({ value: Yup.string() }).test(
+ 'organization-selected',
+ t('Organization is required'),
+ function (tenant) {
+ const level = (this.parent as ScopeValues).level;
+ if (role !== 'providerAdmin' || level !== 'organization') {
+ return true;
+ }
+ return Boolean(tenant?.value?.trim());
+ },
+ ),
+ project: Yup.object({ value: Yup.string() }).test(
+ 'project-selected',
+ t('Project is required'),
+ function (project) {
+ const level = (this.parent as ScopeValues).level;
+ if (role === 'providerAdmin' || level !== 'project') {
+ return true;
+ }
+ return Boolean(project?.value?.trim());
+ },
+ ),
+ });
+
+export const buildScopePayloadFields = (
+ scope: ScopeValues,
+ role: DemoShellRole,
+ resourceName: string,
+) =>
+ role === 'providerAdmin'
+ ? {
+ tenant: scope.level === 'organization' ? scope.tenant.value : '',
+ metadata: { name: resourceName },
+ }
+ : {
+ metadata: {
+ name: resourceName,
+ project: scope.level === 'project' ? scope.project.value : '',
+ },
+ };
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/BooleanFieldDefinition.test.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/BooleanFieldDefinition.test.tsx
new file mode 100644
index 00000000..f78c847d
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/BooleanFieldDefinition.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { BooleanFieldDefinition } from './BooleanFieldDefinition';
+
+interface Values {
+ fieldDefinitions: {
+ is_windows: { editable: boolean; default: boolean };
+ };
+}
+
+const renderField = (initialValues: Values) => {
+ render(
+ undefined}>
+ {({ values }) => (
+ <>
+
+
+ >
+ )}
+ ,
+ );
+};
+
+describe('BooleanFieldDefinition', () => {
+ it('reflects the initial default value', () => {
+ renderField({ fieldDefinitions: { is_windows: { editable: true, default: true } } });
+
+ expect(screen.getByRole('switch', { name: 'Default value' })).toBeChecked();
+ });
+
+ it('updates the default value in Formik state', async () => {
+ const user = userEvent.setup();
+ renderField({ fieldDefinitions: { is_windows: { editable: true, default: false } } });
+
+ await user.click(screen.getByRole('switch', { name: 'Default value' }));
+
+ expect(screen.getByLabelText('default-value')).toHaveTextContent('true');
+ });
+
+ it('renders an independent editable toggle', async () => {
+ const user = userEvent.setup();
+ renderField({ fieldDefinitions: { is_windows: { editable: false, default: false } } });
+
+ await user.click(screen.getByRole('switch', { name: 'Editable' }));
+
+ expect(screen.getByRole('switch', { name: 'Editable' })).toBeChecked();
+ expect(screen.getByRole('switch', { name: 'Default value' })).not.toBeChecked();
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/BooleanFieldDefinition.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/BooleanFieldDefinition.tsx
new file mode 100644
index 00000000..c186ab6b
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/BooleanFieldDefinition.tsx
@@ -0,0 +1,24 @@
+import { FieldDefinitionGroup } from './FieldDefinitionGroup';
+import { useTranslation } from '../../../hooks/useTranslation';
+import { SwitchField } from '../../Form/SwitchField';
+
+interface BooleanFieldDefinitionProps {
+ path: string;
+ label: string;
+ fieldId: string;
+}
+
+export const BooleanFieldDefinition = ({ path, label, fieldId }: BooleanFieldDefinitionProps) => {
+ const { t } = useTranslation();
+ const name = `fieldDefinitions.${path}`;
+
+ return (
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/FieldDefinitionGroup.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/FieldDefinitionGroup.tsx
new file mode 100644
index 00000000..48241238
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/FieldDefinitionGroup.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from 'react';
+import { FormFieldGroup, FormFieldGroupHeader, Title } from '@patternfly/react-core';
+
+import { useTranslation } from '../../../hooks/useTranslation';
+import { SwitchField } from '../../Form/SwitchField';
+
+interface FieldDefinitionGroupProps {
+ label: string;
+ fieldId: string;
+ /** `fieldDefinitions.` name prefix, shared with the "Editable" switch below and the caller's own fields. */
+ name: string;
+ children: ReactNode;
+}
+
+/** Shared `FormFieldGroup` scaffolding for field-definition editors: group header plus the "Editable" switch every field kind exposes, ahead of the field-specific inputs passed as children. */
+export const FieldDefinitionGroup = ({
+ label,
+ fieldId,
+ name,
+ children,
+}: FieldDefinitionGroupProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {label}
+
+ ),
+ id: `${fieldId}-group`,
+ }}
+ />
+ }
+ >
+
+ {children}
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NodeSetsFieldEditor.test.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NodeSetsFieldEditor.test.tsx
new file mode 100644
index 00000000..db244e7b
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NodeSetsFieldEditor.test.tsx
@@ -0,0 +1,151 @@
+import { screen, within } from '@testing-library/react';
+import { Formik } from 'formik';
+import { describe, expect, it, vi } from 'vitest';
+
+import { NodeSetsFieldEditor, type NodeSetsTemplateLike } from './NodeSetsFieldEditor';
+import * as hostTypesApi from '../../../api/v1/host-types';
+import { renderWithProviders } from '../../../test-utils/TestProviders';
+
+vi.mock('../../../api/v1/host-types', () => ({
+ useHostTypes: vi.fn(),
+ hostTypeDisplayName: (hostType: { id: string; title?: string }) => hostType.title ?? hostType.id,
+}));
+
+const mockHostTypes = (
+ data: { id: string; title?: string }[] = [
+ { id: 'small', title: 'Small' },
+ { id: 'large', title: 'Large' },
+ ],
+) => {
+ vi.mocked(hostTypesApi.useHostTypes).mockReturnValue({
+ data,
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ } as unknown as ReturnType);
+};
+
+interface NodeSetEntryValue {
+ default?: string;
+ min?: string;
+ max?: string;
+}
+
+interface Values {
+ fieldDefinitions: {
+ node_sets: {
+ entriesByKey: Record;
+ editable: boolean;
+ };
+ };
+}
+
+const twoNodeSetTemplate: NodeSetsTemplateLike = {
+ nodeSets: {
+ workers: { hostType: 'small' },
+ masters: { hostType: 'large' },
+ },
+};
+
+const renderEditor = (initialValues: Values, template: NodeSetsTemplateLike | undefined) =>
+ renderWithProviders(
+ undefined}>
+ {({ values }) => (
+ <>
+
+
+
+
+ >
+ )}
+ ,
+ );
+
+describe('NodeSetsFieldEditor', () => {
+ it('prompts for a template when none is selected', () => {
+ mockHostTypes();
+ renderEditor(
+ { fieldDefinitions: { node_sets: { entriesByKey: {}, editable: true } } },
+ undefined,
+ );
+
+ expect(screen.getByText('Select a template to configure node sets')).toBeInTheDocument();
+ });
+
+ it('shows a message when the selected template has no node sets', () => {
+ mockHostTypes();
+ renderEditor(
+ { fieldDefinitions: { node_sets: { entriesByKey: {}, editable: true } } },
+ { nodeSets: {} },
+ );
+
+ expect(screen.getByText('This template has no node sets defined')).toBeInTheDocument();
+ });
+
+ it('renders one row per template node set, headed by its host type', () => {
+ mockHostTypes();
+ renderEditor(
+ { fieldDefinitions: { node_sets: { entriesByKey: {}, editable: true } } },
+ twoNodeSetTemplate,
+ );
+
+ // A node set always maps to exactly one host type, so the host type alone is shown as the
+ // group's header — no separate key label or badge duplicating the same information.
+ expect(screen.getByText('Small')).toBeInTheDocument();
+ expect(screen.getByText('Large')).toBeInTheDocument();
+ // No free-form host type picker or add/remove controls — the template fully determines them.
+ expect(screen.queryByRole('button', { name: 'Add node set' })).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/^Host type/)).not.toBeInTheDocument();
+ });
+
+ it('lets the admin set an independent default/min/max per template node set', async () => {
+ mockHostTypes();
+ const { user } = renderEditor(
+ { fieldDefinitions: { node_sets: { entriesByKey: {}, editable: true } } },
+ twoNodeSetTemplate,
+ );
+
+ const workersGroup = within(screen.getByRole('group', { name: 'Small' }));
+ await user.type(workersGroup.getByLabelText('Default nodes'), '3');
+ await user.type(workersGroup.getByLabelText('Minimum nodes'), '1');
+ await user.type(workersGroup.getByLabelText('Maximum nodes'), '5');
+
+ expect(workersGroup.getByLabelText('Default nodes')).toHaveValue(3);
+ expect(screen.getByLabelText('workers-min-value')).toHaveTextContent('1');
+ expect(screen.getByLabelText('workers-max-value')).toHaveTextContent('5');
+ });
+
+ it('shows an error when host types fail to load, falling back to the raw id', () => {
+ vi.mocked(hostTypesApi.useHostTypes).mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: new Error('network down'),
+ refetch: vi.fn(),
+ } as unknown as ReturnType);
+ renderEditor(
+ { fieldDefinitions: { node_sets: { entriesByKey: {}, editable: true } } },
+ twoNodeSetTemplate,
+ );
+
+ expect(screen.getByText('Could not load host types')).toBeInTheDocument();
+ expect(screen.getByText('small')).toBeInTheDocument();
+ });
+
+ it('toggles the editable switch', async () => {
+ mockHostTypes();
+ const { user } = renderEditor(
+ { fieldDefinitions: { node_sets: { entriesByKey: {}, editable: false } } },
+ twoNodeSetTemplate,
+ );
+
+ await user.click(screen.getByRole('switch', { name: 'Editable' }));
+
+ expect(screen.getByLabelText('editable-value')).toHaveTextContent('true');
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NodeSetsFieldEditor.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NodeSetsFieldEditor.tsx
new file mode 100644
index 00000000..679aa1d9
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NodeSetsFieldEditor.tsx
@@ -0,0 +1,151 @@
+import { useMemo } from 'react';
+import {
+ Alert,
+ Flex,
+ FlexItem,
+ FormFieldGroup,
+ FormFieldGroupHeader,
+ Stack,
+ StackItem,
+ Title,
+} from '@patternfly/react-core';
+
+import { FieldDefinitionGroup } from './FieldDefinitionGroup';
+import { hostTypeDisplayName, useHostTypes } from '../../../api/v1/host-types';
+import { useTranslation } from '../../../hooks/useTranslation';
+import { getErrorMessage } from '../../../utils/error';
+import { InputField } from '../../Form/InputField';
+
+const NODE_SETS_NAME = 'fieldDefinitions.node_sets';
+
+export interface NodeSetEntry {
+ default: string;
+ min?: string;
+ max?: string;
+}
+
+export interface NodeSetsFieldValue {
+ /** One entry per template node-set key — the only thing an admin can set; host type and the set
+ * of keys are entirely determined by the selected cluster template. */
+ entriesByKey: Record;
+ editable: boolean;
+}
+
+/** The subset of `ClusterTemplate` (public or private — both are structurally compatible here) that
+ * this editor needs. */
+export interface NodeSetsTemplateLike {
+ nodeSets: Record;
+}
+
+interface NodeSetsFieldEditorProps {
+ /**
+ * The cluster template selected in the General step. fulfillment-service validates that a
+ * cluster's `node_sets` map keys and host types exactly match the template's own `node_sets` —
+ * admins can only provide a default `size` per template-defined node set, not add, remove, or
+ * repoint its host type (see fulfillment-service's `PrivateClustersServer.validateNodeSets`).
+ */
+ template: NodeSetsTemplateLike | undefined;
+}
+
+export const NodeSetsFieldEditor = ({ template }: NodeSetsFieldEditorProps) => {
+ const { t } = useTranslation();
+ const {
+ data: hostTypes = [],
+ isLoading: hostTypesLoading,
+ error: hostTypesError,
+ } = useHostTypes();
+
+ const hostTypeById = useMemo(
+ () => new Map(hostTypes.map((hostType) => [hostType.id, hostType])),
+ [hostTypes],
+ );
+
+ const templateNodeSetKeys = useMemo(
+ () => Object.keys(template?.nodeSets ?? {}).sort(),
+ [template],
+ );
+
+ const hostTypeLabel = (hostTypeId: string): string => {
+ if (!hostTypeId) {
+ return t('Unknown');
+ }
+ const hostType = hostTypeById.get(hostTypeId);
+ if (hostType) {
+ return hostTypeDisplayName(hostType);
+ }
+ return hostTypesLoading ? t('Loading...') : hostTypeId;
+ };
+
+ if (!template) {
+ return ;
+ }
+
+ if (templateNodeSetKeys.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+ {hostTypesError ? (
+
+
+ {getErrorMessage(hostTypesError)}
+
+
+ ) : null}
+ {templateNodeSetKeys.map((key) => {
+ const hostTypeId = template.nodeSets[key]?.hostType ?? '';
+ return (
+
+
+ {hostTypeLabel(hostTypeId)}
+
+ ),
+ id: `node-set-group-${key}`,
+ }}
+ />
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NumberFieldDefinition.test.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NumberFieldDefinition.test.tsx
new file mode 100644
index 00000000..b38aee1d
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NumberFieldDefinition.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { NumberFieldDefinition } from './NumberFieldDefinition';
+
+interface Values {
+ fieldDefinitions: {
+ cores: {
+ editable: boolean;
+ default: string;
+ validation?: { minimum?: string; maximum?: string };
+ };
+ };
+}
+
+const renderField = (initialValues: Values) => {
+ render(
+ undefined}>
+ {({ values }) => (
+ <>
+
+
+
+
+ >
+ )}
+ ,
+ );
+};
+
+describe('NumberFieldDefinition', () => {
+ it('reflects the initial default value', () => {
+ renderField({ fieldDefinitions: { cores: { editable: true, default: '4' } } });
+
+ expect(screen.getByLabelText('Default value')).toHaveValue(4);
+ });
+
+ it('updates the default value in Formik state', async () => {
+ const user = userEvent.setup();
+ renderField({ fieldDefinitions: { cores: { editable: true, default: '' } } });
+
+ await user.type(screen.getByLabelText('Default value'), '8');
+
+ expect(screen.getByLabelText('default-value')).toHaveTextContent('8');
+ });
+
+ it('updates the minimum and maximum constraints in Formik state', async () => {
+ const user = userEvent.setup();
+ renderField({ fieldDefinitions: { cores: { editable: true, default: '' } } });
+
+ await user.type(screen.getByLabelText(/Minimum/), '1');
+ await user.type(screen.getByLabelText(/Maximum/), '16');
+
+ expect(screen.getByLabelText('min-value')).toHaveTextContent('1');
+ expect(screen.getByLabelText('max-value')).toHaveTextContent('16');
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NumberFieldDefinition.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NumberFieldDefinition.tsx
new file mode 100644
index 00000000..efe34ba9
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/NumberFieldDefinition.tsx
@@ -0,0 +1,37 @@
+import { FieldDefinitionGroup } from './FieldDefinitionGroup';
+import { useTranslation } from '../../../hooks/useTranslation';
+import { InputField } from '../../Form/InputField';
+
+interface NumberFieldDefinitionProps {
+ path: string;
+ label: string;
+ fieldId: string;
+}
+
+export const NumberFieldDefinition = ({ path, label, fieldId }: NumberFieldDefinitionProps) => {
+ const { t } = useTranslation();
+ const name = `fieldDefinitions.${path}`;
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/ResourceSelectorFieldDefinition.test.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/ResourceSelectorFieldDefinition.test.tsx
new file mode 100644
index 00000000..078424fa
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/ResourceSelectorFieldDefinition.test.tsx
@@ -0,0 +1,78 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { ResourceSelectorFieldDefinition } from './ResourceSelectorFieldDefinition';
+import { EMPTY_LABELED_RESOURCE_REF } from '../../Form/labeledResourceRef';
+
+const options = [
+ { value: 'm5.large', label: 'm5.large' },
+ { value: 'm5.xlarge', label: 'm5.xlarge' },
+];
+
+interface Values {
+ fieldDefinitions: {
+ instance_type: { editable: boolean; default: { value: string; label: string } };
+ };
+}
+
+const renderField = (initialValues: Values, isLoading = false) => {
+ render(
+ undefined}>
+ {({ values }) => (
+ <>
+
+
+ >
+ )}
+ ,
+ );
+};
+
+describe('ResourceSelectorFieldDefinition', () => {
+ it('renders the provided options', async () => {
+ const user = userEvent.setup();
+ renderField({
+ fieldDefinitions: { instance_type: { editable: true, default: EMPTY_LABELED_RESOURCE_REF } },
+ });
+
+ await user.click(screen.getByLabelText(/^Default value/));
+
+ expect(screen.getByRole('option', { name: 'm5.large' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'm5.xlarge' })).toBeInTheDocument();
+ });
+
+ it('shows a loading state', () => {
+ renderField(
+ {
+ fieldDefinitions: {
+ instance_type: { editable: true, default: EMPTY_LABELED_RESOURCE_REF },
+ },
+ },
+ true,
+ );
+
+ expect(screen.getByLabelText(/^Default value/)).toHaveTextContent('Loading...');
+ });
+
+ it('updates Formik when an option is selected', async () => {
+ const user = userEvent.setup();
+ renderField({
+ fieldDefinitions: { instance_type: { editable: true, default: EMPTY_LABELED_RESOURCE_REF } },
+ });
+
+ await user.click(screen.getByLabelText(/^Default value/));
+ await user.click(screen.getByRole('option', { name: 'm5.xlarge' }));
+
+ expect(screen.getByLabelText('default-value')).toHaveTextContent('m5.xlarge');
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/ResourceSelectorFieldDefinition.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/ResourceSelectorFieldDefinition.tsx
new file mode 100644
index 00000000..a31f267b
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/ResourceSelectorFieldDefinition.tsx
@@ -0,0 +1,35 @@
+import { FieldDefinitionGroup } from './FieldDefinitionGroup';
+import { useTranslation } from '../../../hooks/useTranslation';
+import { SelectField, type SelectFieldOption } from '../../Form/SelectField';
+
+interface ResourceSelectorFieldDefinitionProps {
+ path: string;
+ label: string;
+ fieldId: string;
+ options: SelectFieldOption[];
+ isLoading?: boolean;
+}
+
+export const ResourceSelectorFieldDefinition = ({
+ path,
+ label,
+ fieldId,
+ options,
+ isLoading,
+}: ResourceSelectorFieldDefinitionProps) => {
+ const { t } = useTranslation();
+ const name = `fieldDefinitions.${path}`;
+
+ return (
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/StringFieldDefinition.test.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/StringFieldDefinition.test.tsx
new file mode 100644
index 00000000..3b04f918
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/StringFieldDefinition.test.tsx
@@ -0,0 +1,83 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { StringFieldDefinition } from './StringFieldDefinition';
+
+interface Values {
+ fieldDefinitions: {
+ release_image: { editable: boolean; default: string; validation?: { pattern?: string } };
+ };
+}
+
+const renderField = (initialValues: Values) => {
+ render(
+ undefined}>
+ {({ values }) => (
+ <>
+
+
+
+
+ >
+ )}
+ ,
+ );
+};
+
+describe('StringFieldDefinition', () => {
+ it('renders the field path as the group heading', () => {
+ renderField({
+ fieldDefinitions: { release_image: { editable: false, default: 'quay.io/x:latest' } },
+ });
+
+ expect(screen.getByText('Release Image')).toBeInTheDocument();
+ });
+
+ it('reflects the initial editable and default values', () => {
+ renderField({
+ fieldDefinitions: { release_image: { editable: false, default: 'quay.io/x:latest' } },
+ });
+
+ expect(screen.getByRole('switch', { name: 'Editable' })).not.toBeChecked();
+ expect(screen.getByLabelText('Default value')).toHaveValue('quay.io/x:latest');
+ });
+
+ it('updates the editable toggle in Formik state', async () => {
+ const user = userEvent.setup();
+ renderField({ fieldDefinitions: { release_image: { editable: false, default: '' } } });
+
+ await user.click(screen.getByRole('switch', { name: 'Editable' }));
+
+ expect(screen.getByLabelText('editable-value')).toHaveTextContent('true');
+ });
+
+ it('updates the default value in Formik state', async () => {
+ const user = userEvent.setup();
+ renderField({ fieldDefinitions: { release_image: { editable: false, default: '' } } });
+
+ await user.type(screen.getByLabelText('Default value'), 'quay.io/y:latest');
+
+ expect(screen.getByLabelText('default-value')).toHaveTextContent('quay.io/y:latest');
+ });
+
+ it('updates the validation pattern in Formik state', async () => {
+ const user = userEvent.setup();
+ renderField({ fieldDefinitions: { release_image: { editable: true, default: '' } } });
+
+ await user.type(screen.getByLabelText(/Validation pattern/), 'abc123');
+
+ expect(screen.getByLabelText('pattern-value')).toHaveTextContent('abc123');
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/StringFieldDefinition.tsx b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/StringFieldDefinition.tsx
new file mode 100644
index 00000000..27ac9eaa
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/StringFieldDefinition.tsx
@@ -0,0 +1,40 @@
+import { FieldDefinitionGroup } from './FieldDefinitionGroup';
+import { useTranslation } from '../../../hooks/useTranslation';
+import { InputField } from '../../Form/InputField';
+
+interface StringFieldDefinitionProps {
+ path: string;
+ label: string;
+ fieldId: string;
+ multiline?: boolean;
+ helperText?: string;
+}
+
+export const StringFieldDefinition = ({
+ path,
+ label,
+ fieldId,
+ multiline,
+ helperText,
+}: StringFieldDefinitionProps) => {
+ const { t } = useTranslation();
+ const name = `fieldDefinitions.${path}`;
+
+ return (
+
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/fieldDefinitionValue.test.ts b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/fieldDefinitionValue.test.ts
new file mode 100644
index 00000000..c0bfcf7c
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/fieldDefinitionValue.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildFieldDefinition, fieldDefinitionValueSchema } from './fieldDefinitionValue';
+import { tIdentity } from '../../../test-utils/i18n';
+
+describe('buildFieldDefinition', () => {
+ it('builds a field definition from a string default with no validation', () => {
+ const result = buildFieldDefinition('release_image', 'Release Image', {
+ editable: false,
+ default: 'quay.io/openshift/release:latest',
+ });
+
+ expect(result.path).toBe('release_image');
+ expect(result.displayName).toBe('Release Image');
+ expect(result.editable).toBe(false);
+ expect(result.validationSchema).toBe('');
+ expect(result.default).toEqual({
+ kind: { case: 'stringValue', value: 'quay.io/openshift/release:latest' },
+ });
+ });
+
+ it('builds a field definition from a number default', () => {
+ const result = buildFieldDefinition('cores', 'Cores', { editable: true, default: 4 });
+
+ expect(result.default).toEqual({ kind: { case: 'numberValue', value: 4 } });
+ });
+
+ it('builds a field definition from a boolean default', () => {
+ const result = buildFieldDefinition('is_windows', 'Is Windows', {
+ editable: true,
+ default: false,
+ });
+
+ expect(result.default).toEqual({ kind: { case: 'boolValue', value: false } });
+ });
+
+ it('serializes validation constraints as a JSON string', () => {
+ const result = buildFieldDefinition('pod_cidr', 'Pod CIDR', {
+ editable: true,
+ default: '10.128.0.0/14',
+ validation: { pattern: '^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$' },
+ });
+
+ expect(JSON.parse(result.validationSchema)).toEqual({
+ pattern: '^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$',
+ });
+ });
+
+ it('omits validationSchema when no validation is configured', () => {
+ const result = buildFieldDefinition('user_data', 'User Data', {
+ editable: true,
+ default: '',
+ });
+
+ expect(result.validationSchema).toBe('');
+ });
+});
+
+describe('fieldDefinitionValueSchema', () => {
+ const schema = fieldDefinitionValueSchema(tIdentity);
+
+ it('requires a default value when editable is false', async () => {
+ await expect(schema.validate({ editable: false, default: '' })).rejects.toThrow(
+ 'Default value is required for non-editable fields',
+ );
+ });
+
+ it('allows an empty default value when editable is true', async () => {
+ await expect(schema.validate({ editable: true, default: '' })).resolves.toEqual({
+ editable: true,
+ default: '',
+ });
+ });
+
+ it('passes when a non-editable field has a default value', async () => {
+ await expect(schema.validate({ editable: false, default: 'value' })).resolves.toEqual({
+ editable: false,
+ default: 'value',
+ });
+ });
+});
+
+describe('fieldDefinitionValueSchema with a format test', () => {
+ const schema = fieldDefinitionValueSchema(tIdentity, {
+ name: 'even-length',
+ message: 'Value must have an even length',
+ test: (value) => typeof value !== 'string' || value.length % 2 === 0,
+ });
+
+ it('applies the format test to a provided default', async () => {
+ await expect(schema.validate({ editable: true, default: 'odd' })).rejects.toThrow(
+ 'Value must have an even length',
+ );
+ });
+
+ it('passes the format test for a valid default', async () => {
+ await expect(schema.validate({ editable: true, default: 'even' })).resolves.toEqual({
+ editable: true,
+ default: 'even',
+ });
+ });
+
+ it('still enforces the required-when-non-editable rule alongside the format test', async () => {
+ await expect(schema.validate({ editable: false, default: '' })).rejects.toThrow(
+ 'Default value is required for non-editable fields',
+ );
+ });
+});
+
+describe('fieldDefinitionValueSchema validation metadata', () => {
+ const schema = fieldDefinitionValueSchema(tIdentity);
+
+ it('rejects a malformed regex pattern', async () => {
+ await expect(
+ schema.validate({ editable: true, default: '', validation: { pattern: '[unterminated' } }),
+ ).rejects.toThrow('Must be a valid regular expression');
+ });
+
+ it('accepts a well-formed regex pattern', async () => {
+ await expect(
+ schema.validate({ editable: true, default: '', validation: { pattern: '^[a-z]+$' } }),
+ ).resolves.toMatchObject({ validation: { pattern: '^[a-z]+$' } });
+ });
+
+ it('rejects a non-numeric minimum', async () => {
+ await expect(
+ schema.validate({ editable: true, default: '', validation: { minimum: 'not-a-number' } }),
+ ).rejects.toThrow('Must be a number');
+ });
+
+ it('rejects a non-numeric maximum', async () => {
+ await expect(
+ schema.validate({ editable: true, default: '', validation: { maximum: 'not-a-number' } }),
+ ).rejects.toThrow('Must be a number');
+ });
+
+ it('rejects a maximum lower than the minimum', async () => {
+ await expect(
+ schema.validate({ editable: true, default: '', validation: { minimum: '10', maximum: '5' } }),
+ ).rejects.toThrow('Maximum must be greater than or equal to minimum');
+ });
+
+ it('accepts a maximum equal to the minimum', async () => {
+ await expect(
+ schema.validate({ editable: true, default: '', validation: { minimum: '5', maximum: '5' } }),
+ ).resolves.toMatchObject({ validation: { minimum: '5', maximum: '5' } });
+ });
+
+ it('accepts omitted validation metadata', async () => {
+ await expect(schema.validate({ editable: true, default: '' })).resolves.toMatchObject({
+ editable: true,
+ default: '',
+ });
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/fieldDefinitions/fieldDefinitionValue.ts b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/fieldDefinitionValue.ts
new file mode 100644
index 00000000..a340067f
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/fieldDefinitions/fieldDefinitionValue.ts
@@ -0,0 +1,125 @@
+import type { TFunction } from 'i18next';
+import * as Yup from 'yup';
+
+import { plainToProtobufValue } from '../../catalogProvision/protobuf-value';
+
+/** Formik-facing shape for one field definition being authored in the admin catalog item wizard. */
+export interface FieldDefinitionValue {
+ editable: boolean;
+ default: TDefault;
+ /** JSON-Schema-subset constraints, e.g. `{ pattern }` or `{ minimum, maximum }`. Omitted = no validation. */
+ validation?: Record;
+}
+
+/** Wire shape shared by `osac.public.v1.FieldDefinition` and `osac.private.v1.FieldDefinition` (structurally identical). */
+export interface FieldDefinitionInit {
+ path: string;
+ displayName: string;
+ editable: boolean;
+ default: unknown;
+ validationSchema: string;
+}
+
+export const buildFieldDefinition = (
+ path: string,
+ displayName: string,
+ value: FieldDefinitionValue,
+): FieldDefinitionInit => ({
+ path,
+ displayName,
+ editable: value.editable,
+ default: plainToProtobufValue(value.default),
+ validationSchema: value.validation ? JSON.stringify(value.validation) : '',
+});
+
+export interface FieldDefinitionFormatTest {
+ name: string;
+ message: string;
+ test: (value: unknown) => boolean;
+}
+
+const isValidRegexPattern = (pattern: string): boolean => {
+ try {
+ new RegExp(pattern);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const isFiniteNumericString = (value: string): boolean =>
+ value.trim() !== '' && Number.isFinite(Number(value));
+
+/**
+ * Validates the optional JSON-Schema-subset `validation` metadata itself (regex pattern
+ * compiles; minimum/maximum are numeric and minimum <= maximum) — not the `default` value it
+ * will later constrain on the wire.
+ */
+const validationMetadataSchema = (t: TFunction) =>
+ Yup.object({
+ pattern: Yup.string().test(
+ 'valid-regex-pattern',
+ t('Must be a valid regular expression'),
+ (value) => !value || isValidRegexPattern(value),
+ ),
+ minimum: Yup.string().test(
+ 'numeric-minimum',
+ t('Must be a number'),
+ (value) => !value || isFiniteNumericString(value),
+ ),
+ maximum: Yup.string()
+ .test(
+ 'numeric-maximum',
+ t('Must be a number'),
+ (value) => !value || isFiniteNumericString(value),
+ )
+ .test(
+ 'maximum-not-less-than-minimum',
+ t('Maximum must be greater than or equal to minimum'),
+ function (value) {
+ const minimum = (this.parent as { minimum?: string }).minimum;
+ if (
+ !value ||
+ !minimum ||
+ !isFiniteNumericString(value) ||
+ !isFiniteNumericString(minimum)
+ ) {
+ return true;
+ }
+ return Number(value) >= Number(minimum);
+ },
+ ),
+ })
+ .notRequired()
+ .default(undefined);
+
+/**
+ * Shared validation for a field definition value: a default is required when the field is
+ * non-editable, and the `validation` metadata itself must be well-formed. `formatTest` layers an
+ * additional constraint on the default value (e.g. CIDR notation), applied whenever a value is
+ * present regardless of `editable`.
+ */
+export const fieldDefinitionValueSchema = (
+ t: TFunction,
+ formatTest?: FieldDefinitionFormatTest,
+) => {
+ let defaultSchema = Yup.mixed().when('editable', {
+ is: false,
+ then: (schema) =>
+ schema.test(
+ 'required-default',
+ t('Default value is required for non-editable fields'),
+ (value) => value !== undefined && value !== null && value !== '',
+ ),
+ });
+ if (formatTest) {
+ defaultSchema = defaultSchema.test(formatTest.name, formatTest.message, (value) =>
+ value === undefined || value === null || value === '' ? true : formatTest.test(value),
+ );
+ }
+ return Yup.object({
+ editable: Yup.boolean().required(),
+ default: defaultSchema,
+ validation: validationMetadataSchema(t),
+ });
+};
diff --git a/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMAccessStep.test.tsx b/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMAccessStep.test.tsx
new file mode 100644
index 00000000..1bc64bce
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMAccessStep.test.tsx
@@ -0,0 +1,24 @@
+import { screen } from '@testing-library/react';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { BMAccessStep } from './BMAccessStep';
+import { renderWithProviders } from '../../../../test-utils/TestProviders';
+
+const initialValues = {
+ fieldDefinitions: {
+ ssh_public_key: { editable: true, default: '' },
+ },
+};
+
+describe('BMAccessStep', () => {
+ it('renders the SSH public key field', () => {
+ renderWithProviders(
+ undefined}>
+
+ ,
+ );
+
+ expect(screen.getByText('SSH public key')).toBeInTheDocument();
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMAccessStep.tsx b/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMAccessStep.tsx
new file mode 100644
index 00000000..48f33a34
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMAccessStep.tsx
@@ -0,0 +1,18 @@
+import { useTranslation } from '../../../../hooks/useTranslation';
+import OsacForm from '../../../Form/OsacForm';
+import { StringFieldDefinition } from '../../fieldDefinitions/StringFieldDefinition';
+
+export const BMAccessStep = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMConfigurationStep.test.tsx b/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMConfigurationStep.test.tsx
new file mode 100644
index 00000000..e46ae0be
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMConfigurationStep.test.tsx
@@ -0,0 +1,26 @@
+import { screen } from '@testing-library/react';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { BMConfigurationStep } from './BMConfigurationStep';
+import { renderWithProviders } from '../../../../test-utils/TestProviders';
+
+const initialValues = {
+ fieldDefinitions: {
+ run_strategy: { editable: true, default: 'ALWAYS' },
+ user_data: { editable: true, default: '' },
+ },
+};
+
+describe('BMConfigurationStep', () => {
+ it('renders the run strategy and user data fields', () => {
+ renderWithProviders(
+ undefined}>
+
+ ,
+ );
+
+ expect(screen.getByText('Run strategy')).toBeInTheDocument();
+ expect(screen.getByText('User data')).toBeInTheDocument();
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMConfigurationStep.tsx b/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMConfigurationStep.tsx
new file mode 100644
index 00000000..610223aa
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/baremetal-instance/BMConfigurationStep.tsx
@@ -0,0 +1,19 @@
+import { useTranslation } from '../../../../hooks/useTranslation';
+import OsacForm from '../../../Form/OsacForm';
+import { StringFieldDefinition } from '../../fieldDefinitions/StringFieldDefinition';
+
+export const BMConfigurationStep = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterAccessStep.test.tsx b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterAccessStep.test.tsx
new file mode 100644
index 00000000..e0f6a5da
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterAccessStep.test.tsx
@@ -0,0 +1,26 @@
+import { screen } from '@testing-library/react';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { ClusterAccessStep } from './ClusterAccessStep';
+import { renderWithProviders } from '../../../../test-utils/TestProviders';
+
+const initialValues = {
+ fieldDefinitions: {
+ ssh_public_key: { editable: true, default: '' },
+ pull_secret: { editable: true, default: '' },
+ },
+};
+
+describe('ClusterAccessStep', () => {
+ it('renders the SSH public key and pull secret fields', () => {
+ renderWithProviders(
+ undefined}>
+
+ ,
+ );
+
+ expect(screen.getByText('SSH public key')).toBeInTheDocument();
+ expect(screen.getByText('Pull secret')).toBeInTheDocument();
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterAccessStep.tsx b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterAccessStep.tsx
new file mode 100644
index 00000000..b643aeb8
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterAccessStep.tsx
@@ -0,0 +1,24 @@
+import { useTranslation } from '../../../../hooks/useTranslation';
+import OsacForm from '../../../Form/OsacForm';
+import { StringFieldDefinition } from '../../fieldDefinitions/StringFieldDefinition';
+
+export const ClusterAccessStep = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterConfigurationStep.test.tsx b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterConfigurationStep.test.tsx
new file mode 100644
index 00000000..9c2e44f4
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterConfigurationStep.test.tsx
@@ -0,0 +1,62 @@
+import { screen } from '@testing-library/react';
+import { Formik } from 'formik';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ClusterConfigurationStep } from './ClusterConfigurationStep';
+import * as hostTypesApi from '../../../../api/v1/host-types';
+import { renderWithProviders } from '../../../../test-utils/TestProviders';
+
+vi.mock('../../../../api/v1/host-types', () => ({
+ useHostTypes: vi.fn(),
+ hostTypeDisplayName: (hostType: { id: string; title?: string }) => hostType.title ?? hostType.id,
+}));
+
+const initialValues = {
+ template: { value: 'tmpl-1', label: 'Template One' },
+ fieldDefinitions: {
+ release_image: { editable: false, default: '' },
+ node_sets: { entriesByKey: {}, editable: true },
+ },
+};
+
+const templates = [{ id: 'tmpl-1', nodeSets: { workers: { hostType: 'small' } } }];
+
+describe('ClusterConfigurationStep', () => {
+ it('renders the release image and node sets fields for the selected template', () => {
+ vi.mocked(hostTypesApi.useHostTypes).mockReturnValue({
+ data: [{ id: 'small', title: 'Small' }],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ } as unknown as ReturnType);
+
+ renderWithProviders(
+ undefined}>
+
+ ,
+ );
+
+ expect(screen.getByText('Release image')).toBeInTheDocument();
+ expect(screen.getByText('Small')).toBeInTheDocument();
+ });
+
+ it('prompts for a template when the selected template id has no match', () => {
+ vi.mocked(hostTypesApi.useHostTypes).mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ } as unknown as ReturnType);
+
+ renderWithProviders(
+ undefined}
+ >
+
+ ,
+ );
+
+ expect(screen.getByText('Select a template to configure node sets')).toBeInTheDocument();
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterConfigurationStep.tsx b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterConfigurationStep.tsx
new file mode 100644
index 00000000..2650eda6
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterConfigurationStep.tsx
@@ -0,0 +1,35 @@
+import { useFormikContext } from 'formik';
+
+import { useTranslation } from '../../../../hooks/useTranslation';
+import type { LabeledResourceRef } from '../../../Form/labeledResourceRef';
+import OsacForm from '../../../Form/OsacForm';
+import {
+ NodeSetsFieldEditor,
+ type NodeSetsTemplateLike,
+} from '../../fieldDefinitions/NodeSetsFieldEditor';
+import { StringFieldDefinition } from '../../fieldDefinitions/StringFieldDefinition';
+
+interface ClusterConfigurationStepProps {
+ templates: ({ id: string } & NodeSetsTemplateLike)[];
+}
+
+interface ClusterConfigurationFormValues {
+ template: LabeledResourceRef;
+}
+
+export const ClusterConfigurationStep = ({ templates }: ClusterConfigurationStepProps) => {
+ const { t } = useTranslation();
+ const { values } = useFormikContext();
+ const selectedTemplate = templates.find((template) => template.id === values.template.value);
+
+ return (
+
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterNetworkingStep.test.tsx b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterNetworkingStep.test.tsx
new file mode 100644
index 00000000..3058282c
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterNetworkingStep.test.tsx
@@ -0,0 +1,28 @@
+import { screen } from '@testing-library/react';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { ClusterNetworkingStep } from './ClusterNetworkingStep';
+import { renderWithProviders } from '../../../../test-utils/TestProviders';
+
+const initialValues = {
+ fieldDefinitions: {
+ network: {
+ pod_cidr: { editable: true, default: '' },
+ service_cidr: { editable: true, default: '' },
+ },
+ },
+};
+
+describe('ClusterNetworkingStep', () => {
+ it('renders the pod CIDR and service CIDR fields', () => {
+ renderWithProviders(
+ undefined}>
+
+ ,
+ );
+
+ expect(screen.getByText('Pod CIDR')).toBeInTheDocument();
+ expect(screen.getByText('Service CIDR')).toBeInTheDocument();
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterNetworkingStep.tsx b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterNetworkingStep.tsx
new file mode 100644
index 00000000..b4c7739a
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/cluster/ClusterNetworkingStep.tsx
@@ -0,0 +1,24 @@
+import { useTranslation } from '../../../../hooks/useTranslation';
+import OsacForm from '../../../Form/OsacForm';
+import { StringFieldDefinition } from '../../fieldDefinitions/StringFieldDefinition';
+
+export const ClusterNetworkingStep = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMAccessStep.test.tsx b/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMAccessStep.test.tsx
new file mode 100644
index 00000000..44a7e65e
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMAccessStep.test.tsx
@@ -0,0 +1,24 @@
+import { screen } from '@testing-library/react';
+import { Formik } from 'formik';
+import { describe, expect, it } from 'vitest';
+
+import { VMAccessStep } from './VMAccessStep';
+import { renderWithProviders } from '../../../../test-utils/TestProviders';
+
+const initialValues = {
+ fieldDefinitions: {
+ ssh_key: { editable: true, default: '' },
+ },
+};
+
+describe('VMAccessStep', () => {
+ it('renders the SSH public key field', () => {
+ renderWithProviders(
+ undefined}>
+
+ ,
+ );
+
+ expect(screen.getByText('SSH public key')).toBeInTheDocument();
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMAccessStep.tsx b/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMAccessStep.tsx
new file mode 100644
index 00000000..94b5b90d
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMAccessStep.tsx
@@ -0,0 +1,18 @@
+import { useTranslation } from '../../../../hooks/useTranslation';
+import OsacForm from '../../../Form/OsacForm';
+import { StringFieldDefinition } from '../../fieldDefinitions/StringFieldDefinition';
+
+export const VMAccessStep = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMConfigurationStep.test.tsx b/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMConfigurationStep.test.tsx
new file mode 100644
index 00000000..74beebc9
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMConfigurationStep.test.tsx
@@ -0,0 +1,71 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Formik } from 'formik';
+import { describe, expect, it, vi } from 'vitest';
+
+import { VMConfigurationStep } from './VMConfigurationStep';
+import * as instanceTypesApi from '../../../../api/v1/instance-types';
+import { renderWithProviders } from '../../../../test-utils/TestProviders';
+
+vi.mock('../../../../api/v1/instance-types', () => ({ useInstanceTypes: vi.fn() }));
+
+const initialValues = {
+ fieldDefinitions: {
+ instance_type: { editable: false, default: { value: '', label: '' } },
+ image: { source_ref: { editable: false, default: '' } },
+ boot_disk: { size_gib: { editable: false, default: '' } },
+ additional_disks: [] as { rowId: string; sizeGib: string }[],
+ run_strategy: { editable: true, default: 'Always' },
+ user_data: { editable: true, default: '' },
+ },
+};
+
+describe('VMConfigurationStep', () => {
+ it('renders all configuration fields', () => {
+ vi.mocked(instanceTypesApi.useInstanceTypes).mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: null,
+ } as unknown as ReturnType);
+
+ renderWithProviders(
+ undefined}>
+
+ ,
+ );
+
+ expect(screen.getByText('Source Ref')).toBeInTheDocument();
+ expect(screen.getByText('Instance type')).toBeInTheDocument();
+ expect(screen.getByText('Boot disk size (GiB)')).toBeInTheDocument();
+ expect(screen.getByText('Run strategy')).toBeInTheDocument();
+ expect(screen.getByText('User data')).toBeInTheDocument();
+ });
+
+ it('adds and removes additional disk entries', async () => {
+ vi.mocked(instanceTypesApi.useInstanceTypes).mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: null,
+ } as unknown as ReturnType);
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ undefined}>
+ {({ values }) => (
+ <>
+
+
+ >
+ )}
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Add additional disk' }));
+ expect(screen.getByLabelText('disk-count')).toHaveTextContent('1');
+
+ await user.click(screen.getByRole('button', { name: 'Remove additional disk' }));
+ expect(screen.getByLabelText('disk-count')).toHaveTextContent('0');
+ });
+});
diff --git a/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMConfigurationStep.tsx b/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMConfigurationStep.tsx
new file mode 100644
index 00000000..19f548ba
--- /dev/null
+++ b/libs/ui-components/src/components/catalogManagement/steps/compute-instance/VMConfigurationStep.tsx
@@ -0,0 +1,116 @@
+import { ActionGroup, Button, FormFieldGroup, FormFieldGroupHeader } from '@patternfly/react-core';
+import MinusCircleIcon from '@patternfly/react-icons/dist/esm/icons/minus-circle-icon';
+import PlusCircleIcon from '@patternfly/react-icons/dist/esm/icons/plus-circle-icon';
+import { useFormikContext } from 'formik';
+
+import { useInstanceTypes } from '../../../../api/v1/instance-types';
+import { useTranslation } from '../../../../hooks/useTranslation';
+import OsacForm from '../../../Form/OsacForm';
+import { formatInstanceTypeOptionLabel } from '../../../vm/utils';
+import { NumberFieldDefinition } from '../../fieldDefinitions/NumberFieldDefinition';
+import { ResourceSelectorFieldDefinition } from '../../fieldDefinitions/ResourceSelectorFieldDefinition';
+import { StringFieldDefinition } from '../../fieldDefinitions/StringFieldDefinition';
+
+const ADDITIONAL_DISKS_NAME = 'fieldDefinitions.additional_disks';
+
+interface AdditionalDiskEntry {
+ rowId: string;
+}
+
+interface VMConfigurationFormValues {
+ fieldDefinitions: {
+ additional_disks: AdditionalDiskEntry[];
+ };
+}
+
+const AdditionalDisksFieldEditor = () => {
+ const { t } = useTranslation();
+ const { values, setFieldValue } = useFormikContext();
+ const entries = values.fieldDefinitions.additional_disks;
+
+ const addRow = () => {
+ void setFieldValue(ADDITIONAL_DISKS_NAME, [...entries, { rowId: crypto.randomUUID() }]);
+ };
+
+ const removeRow = (rowIndex: number) => {
+ void setFieldValue(
+ ADDITIONAL_DISKS_NAME,
+ entries.filter((_, index) => index !== rowIndex),
+ );
+ };
+
+ return (
+ <>
+ {entries.map((entry, rowIndex) => (
+ removeRow(rowIndex)}
+ icon={}
+ />
+ }
+ />
+ }
+ >
+
+
+ ))}
+
+ } onClick={addRow}>
+ {t('Add additional disk')}
+
+
+ >
+ );
+};
+
+export const VMConfigurationStep = () => {
+ const { t } = useTranslation();
+ const { data: instanceTypes = [], isLoading: instanceTypesLoading } = useInstanceTypes();
+
+ return (
+
+
+ ({
+ value: instanceType.id,
+ label: formatInstanceTypeOptionLabel(instanceType),
+ }))}
+ isLoading={instanceTypesLoading}
+ />
+
+
+
+
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalogProvision/catalogFieldDefinition.ts b/libs/ui-components/src/components/catalogProvision/catalogFieldDefinition.ts
index 1dbb7524..ed9be2de 100644
--- a/libs/ui-components/src/components/catalogProvision/catalogFieldDefinition.ts
+++ b/libs/ui-components/src/components/catalogProvision/catalogFieldDefinition.ts
@@ -141,12 +141,9 @@ export const catalogItemFieldDefinitions = (item: unknown): CatalogFieldDefiniti
return coerceCatalogFieldDefinitions(readCatalogItemFieldDefinitions(item));
};
-/** Spec paths shown on catalog cards as compute resources (CPU, memory, boot disk). */
-export const CATALOG_ITEM_RESOURCE_FIELD_PATHS = [
- 'cores',
- 'memory_gib',
- 'boot_disk.size_gib',
-] as const;
+/** Spec paths shown on catalog cards as compute resources. CPU/memory are implied by the selected
+ * instance_type rather than being independently configurable field definitions. */
+export const CATALOG_ITEM_RESOURCE_FIELD_PATHS = ['boot_disk.size_gib'] as const;
export type CatalogItemResourceFieldPath = (typeof CATALOG_ITEM_RESOURCE_FIELD_PATHS)[number];
diff --git a/libs/ui-components/src/components/catalogProvision/protobuf-value.test.ts b/libs/ui-components/src/components/catalogProvision/protobuf-value.test.ts
new file mode 100644
index 00000000..0b89fab0
--- /dev/null
+++ b/libs/ui-components/src/components/catalogProvision/protobuf-value.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+
+import { plainToProtobufValue, protobufValueToPlain } from './protobuf-value';
+
+describe('plainToProtobufValue', () => {
+ it.each([
+ ['hello', 'stringValue'],
+ ['', 'stringValue'],
+ [42, 'numberValue'],
+ [0, 'numberValue'],
+ [true, 'boolValue'],
+ [false, 'boolValue'],
+ [null, 'nullValue'],
+ [undefined, 'nullValue'],
+ ])('encodes %j as kind.case %s', (value, expectedCase) => {
+ const encoded = plainToProtobufValue(value) as { kind: { case: string } };
+ expect(encoded.kind.case).toBe(expectedCase);
+ });
+
+ it('encodes arrays as a listValue', () => {
+ const encoded = plainToProtobufValue([1, 'two', true]) as {
+ kind: { case: string; value: { values: unknown[] } };
+ };
+ expect(encoded.kind.case).toBe('listValue');
+ expect(encoded.kind.value.values).toHaveLength(3);
+ });
+
+ it('encodes plain objects as a structValue', () => {
+ const encoded = plainToProtobufValue({ a: 1, b: 'two' }) as {
+ kind: { case: string; value: { fields: Record } };
+ };
+ expect(encoded.kind.case).toBe('structValue');
+ expect(Object.keys(encoded.kind.value.fields)).toEqual(['a', 'b']);
+ });
+});
+
+describe('plainToProtobufValue / protobufValueToPlain round trip', () => {
+ it.each([['hello'], [42], [0], [true], [false], [null]])('round-trips scalar %j', (value) => {
+ expect(protobufValueToPlain(plainToProtobufValue(value))).toEqual(value);
+ });
+
+ it('round-trips an array', () => {
+ const value = [1, 'two', true];
+ expect(protobufValueToPlain(plainToProtobufValue(value))).toEqual(value);
+ });
+
+ it('round-trips a plain object', () => {
+ const value = { cores: 4, name: 'default', enabled: true };
+ expect(protobufValueToPlain(plainToProtobufValue(value))).toEqual(value);
+ });
+
+ it('round-trips a nested structure (object containing an array of objects)', () => {
+ const value = { nodeSets: [{ hostType: 'small', size: 3 }] };
+ expect(protobufValueToPlain(plainToProtobufValue(value))).toEqual(value);
+ });
+});
diff --git a/libs/ui-components/src/components/catalogProvision/protobuf-value.ts b/libs/ui-components/src/components/catalogProvision/protobuf-value.ts
index 760246dd..6f42ee33 100644
--- a/libs/ui-components/src/components/catalogProvision/protobuf-value.ts
+++ b/libs/ui-components/src/components/catalogProvision/protobuf-value.ts
@@ -127,3 +127,35 @@ const parseProtobufValueToPlain = (value: unknown): unknown => {
};
export const protobufValueToPlain = (value: unknown): unknown => parseProtobufValueToPlain(value);
+
+/**
+ * Converts a plain JS scalar, array, or object into a decoded `google.protobuf.Value` init shape
+ * (the `{ kind: { case, value } }` oneof shape `@bufbuild/protobuf` accepts for message construction),
+ * for catalog field defaults at write time. Inverse of `protobufValueToPlain`.
+ */
+export const plainToProtobufValue = (value: unknown): unknown => {
+ if (value === undefined || value === null) {
+ // google.protobuf.NullValue.NULL_VALUE is the sole (proto3) enum value, numeric 0.
+ return { kind: { case: 'nullValue', value: 0 } };
+ }
+ if (typeof value === 'string') {
+ return { kind: { case: 'stringValue', value } };
+ }
+ if (typeof value === 'number') {
+ return { kind: { case: 'numberValue', value } };
+ }
+ if (typeof value === 'boolean') {
+ return { kind: { case: 'boolValue', value } };
+ }
+ if (Array.isArray(value)) {
+ return { kind: { case: 'listValue', value: { values: value.map(plainToProtobufValue) } } };
+ }
+ if (typeof value === 'object') {
+ const fields: Record = {};
+ for (const [key, entry] of Object.entries(value as Record)) {
+ fields[key] = plainToProtobufValue(entry);
+ }
+ return { kind: { case: 'structValue', value: { fields } } };
+ }
+ return { kind: { case: 'nullValue', value: 0 } };
+};
diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemCreatePage.test.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemCreatePage.test.tsx
new file mode 100644
index 00000000..0bb2e8b2
--- /dev/null
+++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemCreatePage.test.tsx
@@ -0,0 +1,121 @@
+import { createRouterTransport } from '@connectrpc/connect';
+import { screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { BareMetalInstanceCatalogItems, BareMetalInstanceTemplates } from '@osac/types';
+import {
+ BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems,
+ BareMetalInstanceTemplates as PrivateBareMetalInstanceTemplates,
+} from '@osac/types/private';
+
+import { BareMetalInstanceCatalogItemCreatePage } from './BareMetalInstanceCatalogItemCreatePage';
+import * as tenantApi from '../../../api/v1/private/tenant';
+import * as projectsApi from '../../../api/v1/projects';
+import { SessionProvider } from '../../../hooks/use-session';
+import { renderWithProviders } from '../../../test-utils/TestProviders';
+
+vi.mock('../../../api/v1/private/tenant', () => ({ usePrivateTenants: vi.fn() }));
+vi.mock('../../../api/v1/projects', () => ({ useProjects: vi.fn() }));
+
+const asQueryResult = (data: T) =>
+ ({ data, isLoading: false, error: null }) as unknown as ReturnType<
+ typeof tenantApi.usePrivateTenants
+ >;
+
+const mockSharedData = () => {
+ vi.mocked(tenantApi.usePrivateTenants).mockReturnValue(asQueryResult([]));
+ vi.mocked(projectsApi.useProjects).mockReturnValue(
+ asQueryResult([]) as unknown as ReturnType,
+ );
+};
+
+const selectTemplate = async (user: ReturnType['user']) => {
+ await user.click(screen.getByLabelText(/^Template/));
+ await user.click(screen.getByRole('option', { name: 'Template One' }));
+};
+
+const fillNames = async (
+ user: ReturnType['user'],
+ title: string,
+ resourceName: string,
+) => {
+ await user.type(screen.getByLabelText(/^Title/), title);
+ await user.type(screen.getByLabelText(/^Name/), resourceName);
+};
+
+const createFn = vi.fn(() => ({ object: { id: 'new-id', title: 'My Bare Metal' } }));
+
+const renderPage = () => {
+ const transport = createRouterTransport((router) => {
+ router.service(BareMetalInstanceCatalogItems, { create: createFn });
+ router.service(PrivateBareMetalInstanceCatalogItems, { create: createFn });
+ router.service(BareMetalInstanceTemplates, { list: () => ({ items: [] }) });
+ router.service(PrivateBareMetalInstanceTemplates, {
+ list: () => ({ items: [{ id: 'tmpl-1', metadata: { name: 'Template One' } }] }),
+ });
+ });
+ return renderWithProviders(
+
+
+ ,
+ { transport, routerEntries: ['/admin/catalog/baremetal-instance/create'] },
+ );
+};
+
+describe('BareMetalInstanceCatalogItemCreatePage', () => {
+ it('renders the General step by default with all three step nav items and no Networking step', () => {
+ mockSharedData();
+ renderPage();
+
+ expect(
+ screen.getByRole('heading', { name: 'Create bare metal catalog item' }),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Title/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
+ expect(screen.getAllByText('General').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Configuration').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Access').length).toBeGreaterThan(0);
+ expect(screen.queryByText('Networking')).not.toBeInTheDocument();
+ });
+
+ it('blocks advancing past General when no template is selected', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My Bare Metal', 'my-bare-metal');
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('This step has validation errors')).toBeInTheDocument();
+ });
+
+ it('blocks advancing past General when Organization scope is selected without an organization', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My Bare Metal', 'my-bare-metal');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('radio', { name: 'Organization' }));
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('This step has validation errors')).toBeInTheDocument();
+ });
+
+ it('submits with published: false', async () => {
+ mockSharedData();
+ createFn.mockClear();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My Bare Metal', 'my-bare-metal');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ await waitFor(() => expect(createFn).toHaveBeenCalled());
+ const request = (createFn.mock.calls[0] as unknown[])[0] as {
+ object: { published: boolean; title: string };
+ };
+ expect(request.object.published).toBe(false);
+ expect(request.object.title).toBe('My Bare Metal');
+ });
+});
diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemCreatePage.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemCreatePage.tsx
new file mode 100644
index 00000000..f09cc689
--- /dev/null
+++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemCreatePage.tsx
@@ -0,0 +1,275 @@
+import { useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { type MessageInitShape } from '@bufbuild/protobuf';
+import {
+ Alert,
+ Breadcrumb,
+ BreadcrumbItem,
+ Button,
+ Content,
+ PageSection,
+ PageSectionTypes,
+ Stack,
+ StackItem,
+ Title,
+ Wizard,
+ WizardFooterWrapper,
+ WizardStep,
+} from '@patternfly/react-core';
+import { Formik } from 'formik';
+import type { TFunction } from 'i18next';
+import * as Yup from 'yup';
+
+import { BareMetalInstanceCatalogItemSchema } from '@osac/types';
+
+import { useCreateBareMetalInstanceCatalogItem } from '../../../api/v1/baremetal-instance';
+import { useAdminBareMetalInstanceTemplates } from '../../../api/v1/baremetal-instance-templates';
+import { CatalogItemGeneralFields } from '../../../components/catalogManagement/CatalogItemGeneralFields';
+import { templateRequiredSchema } from '../../../components/catalogManagement/catalogItemGeneralSchema';
+import {
+ type ScopeValues,
+ buildScopePayloadFields,
+ initialScopeForRole,
+ scopeValidationSchema,
+} from '../../../components/catalogManagement/catalogItemScope';
+import { CatalogItemWizardFooter } from '../../../components/catalogManagement/CatalogItemWizardFooter';
+import {
+ type FieldDefinitionValue,
+ buildFieldDefinition,
+ fieldDefinitionValueSchema,
+} from '../../../components/catalogManagement/fieldDefinitions/fieldDefinitionValue';
+import { BMAccessStep } from '../../../components/catalogManagement/steps/baremetal-instance/BMAccessStep';
+import { BMConfigurationStep } from '../../../components/catalogManagement/steps/baremetal-instance/BMConfigurationStep';
+import { buildMetadataNameSchema } from '../../../components/catalogProvision/wizard/metadataNameSchema';
+import { FieldValidationProvider } from '../../../components/Form/FieldValidationContext';
+import {
+ EMPTY_LABELED_RESOURCE_REF,
+ type LabeledResourceRef,
+} from '../../../components/Form/labeledResourceRef';
+import { useSession } from '../../../hooks/use-session';
+import { useTranslation } from '../../../hooks/useTranslation';
+import { getErrorMessage } from '../../../utils/error';
+
+const STEP_IDS = ['general', 'configuration', 'access'] as const;
+type BareMetalStepId = (typeof STEP_IDS)[number];
+
+const STEP_LABEL_KEYS: Record = {
+ general: 'General',
+ configuration: 'Configuration',
+ access: 'Access',
+};
+
+interface BareMetalInstanceCatalogItemFormValues {
+ title: string;
+ resourceName: string;
+ description: string;
+ template: LabeledResourceRef;
+ scope: ScopeValues;
+ fieldDefinitions: {
+ run_strategy: FieldDefinitionValue;
+ user_data: FieldDefinitionValue;
+ ssh_public_key: FieldDefinitionValue;
+ };
+}
+
+const createInitialValues = (
+ role: ReturnType['role'],
+): BareMetalInstanceCatalogItemFormValues => ({
+ title: '',
+ resourceName: '',
+ description: '',
+ template: EMPTY_LABELED_RESOURCE_REF,
+ scope: initialScopeForRole(role),
+ fieldDefinitions: {
+ run_strategy: { editable: true, default: 'ALWAYS' },
+ user_data: { editable: true, default: '' },
+ ssh_public_key: { editable: true, default: '' },
+ },
+});
+
+const getStepValidationSchema = (
+ stepId: BareMetalStepId,
+ t: TFunction,
+ role: ReturnType['role'],
+) => {
+ switch (stepId) {
+ case 'general':
+ return Yup.object({
+ title: Yup.string(),
+ resourceName: buildMetadataNameSchema(t),
+ template: templateRequiredSchema(t),
+ scope: scopeValidationSchema(t, role),
+ });
+ case 'configuration':
+ return Yup.object({
+ fieldDefinitions: Yup.object({
+ run_strategy: fieldDefinitionValueSchema(t),
+ user_data: fieldDefinitionValueSchema(t),
+ }),
+ });
+ case 'access':
+ return Yup.object({
+ fieldDefinitions: Yup.object({ ssh_public_key: fieldDefinitionValueSchema(t) }),
+ });
+ }
+};
+
+// Validated once, in full, before the final submit — see CatalogItemWizardFooter.
+const getFullFormValidationSchema = (t: TFunction, role: ReturnType['role']) =>
+ Yup.object({
+ title: Yup.string(),
+ resourceName: buildMetadataNameSchema(t),
+ template: templateRequiredSchema(t),
+ scope: scopeValidationSchema(t, role),
+ fieldDefinitions: Yup.object({
+ run_strategy: fieldDefinitionValueSchema(t),
+ user_data: fieldDefinitionValueSchema(t),
+ ssh_public_key: fieldDefinitionValueSchema(t),
+ }),
+ });
+
+const buildFieldDefinitions = (values: BareMetalInstanceCatalogItemFormValues, t: TFunction) => [
+ buildFieldDefinition('run_strategy', t('Run strategy'), values.fieldDefinitions.run_strategy),
+ buildFieldDefinition('user_data', t('User data'), values.fieldDefinitions.user_data),
+ buildFieldDefinition(
+ 'ssh_public_key',
+ t('SSH public key'),
+ values.fieldDefinitions.ssh_public_key,
+ ),
+];
+
+export const BareMetalInstanceCatalogItemCreatePage = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { role } = useSession();
+ const { data: templates = [], isLoading: templatesLoading } =
+ useAdminBareMetalInstanceTemplates();
+ const { mutateAsync: createBareMetalInstanceCatalogItem, isPending } =
+ useCreateBareMetalInstanceCatalogItem();
+ const [activeStepId, setActiveStepId] = useState('general');
+ const [validationAlert, setValidationAlert] = useState(false);
+ const [submitError, setSubmitError] = useState();
+
+ const initialValues = useMemo(() => createInitialValues(role), [role]);
+ const validationSchema = useMemo(
+ () => getStepValidationSchema(activeStepId, t, role),
+ [activeStepId, t, role],
+ );
+ const fullFormSchema = useMemo(() => getFullFormValidationSchema(t, role), [t, role]);
+
+ const templateOptions = templates.map((template) => ({
+ value: template.id,
+ label: template.metadata?.name || template.id,
+ }));
+
+ return (
+ <>
+
+
+
+
+
+
+ {t('Create')}
+
+
+ {t('Create bare metal catalog item')}
+
+
+ {t('Define a curated bare metal offering for tenants to provision from.')}
+
+
+
+
+ initialValues={initialValues}
+ validationSchema={validationSchema}
+ validateOnBlur
+ validateOnChange={false}
+ onSubmit={async (values) => {
+ setSubmitError(undefined);
+ try {
+ const payload: MessageInitShape = {
+ title: values.title.trim(),
+ description: values.description.trim(),
+ template: values.template.value,
+ published: false,
+ ...buildScopePayloadFields(values.scope, role, values.resourceName),
+ // buildFieldDefinition()'s `default` is a decoded google.protobuf.Value init shape;
+ // MessageInitShape can't structurally verify it against the generated Value type, so
+ // this one property needs a cast (see buildFieldDefinition in fieldDefinitionValue.ts).
+ fieldDefinitions: buildFieldDefinitions(values, t) as MessageInitShape<
+ typeof BareMetalInstanceCatalogItemSchema
+ >['fieldDefinitions'],
+ };
+ await createBareMetalInstanceCatalogItem(payload);
+ navigate('/admin/catalog');
+ } catch (error) {
+ setSubmitError(getErrorMessage(error));
+ }
+ }}
+ >
+ {(formik) => (
+
+
+ setActiveStepId(id as BareMetalStepId)}
+ fullFormSchema={fullFormSchema}
+ setValidationAlert={setValidationAlert}
+ isPending={isPending}
+ />
+
+ }
+ >
+ {STEP_IDS.map((stepId) => (
+
+
+
+ {validationAlert ? (
+
+
+
+ ) : null}
+ {submitError ? (
+
+
+ {submitError}
+
+
+ ) : null}
+ {stepId === 'general' ? (
+
+ ) : null}
+ {stepId === 'configuration' ? : null}
+ {stepId === 'access' ? : null}
+
+
+
+ ))}
+
+
+ )}
+
+ >
+ );
+};
+
+export default BareMetalInstanceCatalogItemCreatePage;
diff --git a/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemCreatePage.test.tsx b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemCreatePage.test.tsx
new file mode 100644
index 00000000..5724c568
--- /dev/null
+++ b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemCreatePage.test.tsx
@@ -0,0 +1,209 @@
+import { createRouterTransport } from '@connectrpc/connect';
+import { screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ClusterCatalogItems, ClusterTemplates } from '@osac/types';
+import {
+ ClusterCatalogItems as PrivateClusterCatalogItems,
+ ClusterTemplates as PrivateClusterTemplates,
+} from '@osac/types/private';
+
+import { ClusterCatalogItemCreatePage } from './ClusterCatalogItemCreatePage';
+import * as hostTypesApi from '../../../api/v1/host-types';
+import * as tenantApi from '../../../api/v1/private/tenant';
+import * as projectsApi from '../../../api/v1/projects';
+import { SessionProvider } from '../../../hooks/use-session';
+import { renderWithProviders } from '../../../test-utils/TestProviders';
+
+vi.mock('../../../api/v1/host-types', () => ({
+ useHostTypes: vi.fn(),
+ hostTypeDisplayName: (hostType: { id: string; title?: string }) => hostType.title ?? hostType.id,
+}));
+vi.mock('../../../api/v1/private/tenant', () => ({ usePrivateTenants: vi.fn() }));
+vi.mock('../../../api/v1/projects', () => ({ useProjects: vi.fn() }));
+
+const asQueryResult = (data: T) =>
+ ({ data, isLoading: false, error: null }) as unknown as ReturnType<
+ typeof tenantApi.usePrivateTenants
+ >;
+
+const mockSharedData = () => {
+ vi.mocked(hostTypesApi.useHostTypes).mockReturnValue({
+ data: [{ id: 'small', title: 'Small' }],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ } as unknown as ReturnType);
+ vi.mocked(tenantApi.usePrivateTenants).mockReturnValue(asQueryResult([]));
+ vi.mocked(projectsApi.useProjects).mockReturnValue(
+ asQueryResult([]) as unknown as ReturnType,
+ );
+};
+
+const selectTemplate = async (user: ReturnType['user']) => {
+ await user.click(screen.getByLabelText(/^Template/));
+ await user.click(screen.getByRole('option', { name: 'Template One' }));
+};
+
+const fillNames = async (
+ user: ReturnType['user'],
+ title: string,
+ resourceName: string,
+) => {
+ await user.type(screen.getByLabelText(/^Title/), title);
+ await user.type(screen.getByLabelText(/^Name/), resourceName);
+};
+
+const fillFirstNodeSet = async (user: ReturnType['user']) => {
+ await user.type(screen.getByLabelText('Default nodes'), '3');
+};
+
+const createFn = vi.fn(() => ({ object: { id: 'new-id', title: 'My Cluster' } }));
+
+const renderPage = () => {
+ const transport = createRouterTransport((router) => {
+ router.service(ClusterCatalogItems, { create: createFn });
+ router.service(PrivateClusterCatalogItems, { create: createFn });
+ router.service(ClusterTemplates, { list: () => ({ items: [] }) });
+ router.service(PrivateClusterTemplates, {
+ list: () => ({
+ items: [
+ {
+ id: 'tmpl-1',
+ metadata: { name: 'Template One' },
+ nodeSets: { workers: { hostType: 'small', size: 3 } },
+ },
+ ],
+ }),
+ });
+ });
+ return renderWithProviders(
+
+
+ ,
+ { transport, routerEntries: ['/admin/catalog/cluster/create'] },
+ );
+};
+
+describe('ClusterCatalogItemCreatePage', () => {
+ it('renders the General step by default with all four step nav items', () => {
+ mockSharedData();
+ renderPage();
+
+ expect(
+ screen.getByRole('heading', { name: 'Create cluster catalog item' }),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Title/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
+ expect(screen.getAllByText('General').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Configuration').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Networking').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Access').length).toBeGreaterThan(0);
+ });
+
+ it('blocks advancing past General when required fields are empty', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('This step has validation errors')).toBeInTheDocument();
+ });
+
+ it('allows advancing past General with no Title, since only Name is required', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await user.type(screen.getByLabelText(/^Name/), 'my-cluster');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(screen.queryByText('This step has validation errors')).not.toBeInTheDocument();
+ });
+
+ it('blocks advancing past General when no template is selected', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My Cluster', 'my-cluster');
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('This step has validation errors')).toBeInTheDocument();
+ expect(screen.getAllByText('General').length).toBeGreaterThan(0);
+ });
+
+ it('blocks advancing past General when Organization scope is selected without an organization', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My Cluster', 'my-cluster');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('radio', { name: 'Organization' }));
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('This step has validation errors')).toBeInTheDocument();
+ });
+
+ it('scopes node sets to the selected template and blocks advancing until sizes are set', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My Cluster', 'my-cluster');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(screen.getByText('Small')).toBeInTheDocument();
+ expect(screen.queryByLabelText(/^Host type/)).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('This step has validation errors')).toBeInTheDocument();
+ expect(screen.getAllByText('Configuration').length).toBeGreaterThan(0);
+ });
+
+ it('submits with published: false and navigates to the list page on success', async () => {
+ mockSharedData();
+ createFn.mockClear();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My Cluster', 'my-cluster');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await fillFirstNodeSet(user);
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ await waitFor(() => expect(createFn).toHaveBeenCalled());
+ const request = (createFn.mock.calls[0] as unknown[])[0] as {
+ object: {
+ published: boolean;
+ title: string;
+ fieldDefinitions: { path: string; default: unknown }[];
+ };
+ };
+ expect(request.object.published).toBe(false);
+ expect(request.object.title).toBe('My Cluster');
+ const nodeSets = request.object.fieldDefinitions.find((fd) => fd.path === 'node_sets');
+ expect(nodeSets?.default).toMatchObject({
+ kind: {
+ case: 'structValue',
+ value: {
+ fields: {
+ workers: {
+ kind: {
+ case: 'structValue',
+ value: {
+ fields: {
+ hostType: { kind: { case: 'stringValue', value: 'small' } },
+ size: { kind: { case: 'numberValue', value: 3 } },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ });
+});
diff --git a/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemCreatePage.tsx b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemCreatePage.tsx
new file mode 100644
index 00000000..f71af87a
--- /dev/null
+++ b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemCreatePage.tsx
@@ -0,0 +1,470 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { type MessageInitShape } from '@bufbuild/protobuf';
+import {
+ Alert,
+ Breadcrumb,
+ BreadcrumbItem,
+ Button,
+ Content,
+ PageSection,
+ PageSectionTypes,
+ Stack,
+ StackItem,
+ Title,
+ Wizard,
+ WizardFooterWrapper,
+ WizardStep,
+} from '@patternfly/react-core';
+import { FormikProvider, useFormik } from 'formik';
+import type { TFunction } from 'i18next';
+import * as Yup from 'yup';
+
+import { ClusterCatalogItemSchema } from '@osac/types';
+
+import { useCreateClusterCatalogItem } from '../../../api/v1/cluster-catalog-item';
+import { useAdminClusterTemplates } from '../../../api/v1/cluster-templates';
+import { CatalogItemGeneralFields } from '../../../components/catalogManagement/CatalogItemGeneralFields';
+import { templateRequiredSchema } from '../../../components/catalogManagement/catalogItemGeneralSchema';
+import {
+ type ScopeValues,
+ buildScopePayloadFields,
+ initialScopeForRole,
+ scopeValidationSchema,
+} from '../../../components/catalogManagement/catalogItemScope';
+import { CatalogItemWizardFooter } from '../../../components/catalogManagement/CatalogItemWizardFooter';
+import {
+ type FieldDefinitionValue,
+ buildFieldDefinition,
+ fieldDefinitionValueSchema,
+} from '../../../components/catalogManagement/fieldDefinitions/fieldDefinitionValue';
+import type {
+ NodeSetsFieldValue,
+ NodeSetsTemplateLike,
+} from '../../../components/catalogManagement/fieldDefinitions/NodeSetsFieldEditor';
+import { ClusterAccessStep } from '../../../components/catalogManagement/steps/cluster/ClusterAccessStep';
+import { ClusterConfigurationStep } from '../../../components/catalogManagement/steps/cluster/ClusterConfigurationStep';
+import { ClusterNetworkingStep } from '../../../components/catalogManagement/steps/cluster/ClusterNetworkingStep';
+import { buildMetadataNameSchema } from '../../../components/catalogProvision/wizard/metadataNameSchema';
+import { FieldValidationProvider } from '../../../components/Form/FieldValidationContext';
+import {
+ EMPTY_LABELED_RESOURCE_REF,
+ type LabeledResourceRef,
+} from '../../../components/Form/labeledResourceRef';
+import { useSession } from '../../../hooks/use-session';
+import { useTranslation } from '../../../hooks/useTranslation';
+import { getErrorMessage } from '../../../utils/error';
+import { IPV4_CIDR_PATTERN, isValidCidr } from '../../../validation/cidr-validation';
+
+const STEP_IDS = ['general', 'configuration', 'networking', 'access'] as const;
+type ClusterStepId = (typeof STEP_IDS)[number];
+
+const STEP_LABEL_KEYS: Record = {
+ general: 'General',
+ configuration: 'Configuration',
+ networking: 'Networking',
+ access: 'Access',
+};
+
+interface ClusterCatalogItemFormValues {
+ title: string;
+ resourceName: string;
+ description: string;
+ template: LabeledResourceRef;
+ scope: ScopeValues;
+ fieldDefinitions: {
+ release_image: FieldDefinitionValue;
+ node_sets: NodeSetsFieldValue;
+ network: {
+ pod_cidr: FieldDefinitionValue;
+ service_cidr: FieldDefinitionValue;
+ };
+ ssh_public_key: FieldDefinitionValue;
+ pull_secret: FieldDefinitionValue;
+ };
+}
+
+const SSH_PUBLIC_KEY_PATTERN =
+ '^(ssh-rsa|ecdsa-sha2-nistp(256|384|521)|ssh-ed25519) AAAA[0-9A-Za-z+/]+[=]{0,3}( .*)?$';
+
+const createInitialValues = (
+ role: ReturnType['role'],
+): ClusterCatalogItemFormValues => ({
+ title: '',
+ resourceName: '',
+ description: '',
+ template: EMPTY_LABELED_RESOURCE_REF,
+ scope: initialScopeForRole(role),
+ fieldDefinitions: {
+ release_image: { editable: true, default: '' },
+ node_sets: {
+ entriesByKey: {},
+ editable: true,
+ },
+ network: {
+ pod_cidr: { editable: true, default: '', validation: { pattern: IPV4_CIDR_PATTERN } },
+ service_cidr: { editable: true, default: '', validation: { pattern: IPV4_CIDR_PATTERN } },
+ },
+ ssh_public_key: {
+ editable: true,
+ default: '',
+ validation: { pattern: SSH_PUBLIC_KEY_PATTERN },
+ },
+ pull_secret: { editable: true, default: '' },
+ },
+});
+
+const cidrFormatTest = (t: TFunction) => ({
+ name: 'valid-cidr',
+ message: t('Must be a valid IPv4 CIDR notation (for example 10.128.0.0/14)'),
+ test: (value: unknown) => typeof value === 'string' && isValidCidr(value, 'ipv4'),
+});
+
+// Node sets are entirely determined by the selected cluster template — fulfillment-service rejects
+// any node set whose key or host type doesn't match the template (see
+// `PrivateClustersServer.validateNodeSets`). An admin can only provide a default size and optional
+// min/max per template-defined node set, so the schema is built dynamically, one entry per current
+// template node-set key, rather than a fixed shape.
+const nodeSetEntrySchema = (t: TFunction) =>
+ Yup.object({
+ default: Yup.string().test(
+ 'positive-size',
+ t('Size must be a positive number'),
+ (value) => Number.isFinite(Number(value)) && Number(value) > 0,
+ ),
+ min: Yup.string().test(
+ 'numeric-size-min',
+ t('Must be a number'),
+ (value) => !value || Number.isFinite(Number(value)),
+ ),
+ max: Yup.string()
+ .test(
+ 'numeric-size-max',
+ t('Must be a number'),
+ (value) => !value || Number.isFinite(Number(value)),
+ )
+ .test(
+ 'size-max-not-less-than-min',
+ t('Maximum must be greater than or equal to minimum'),
+ function (value) {
+ const minimum = (this.parent as { min?: string }).min;
+ if (
+ !value ||
+ !minimum ||
+ !Number.isFinite(Number(value)) ||
+ !Number.isFinite(Number(minimum))
+ ) {
+ return true;
+ }
+ return Number(value) >= Number(minimum);
+ },
+ ),
+ });
+
+const nodeSetsSchema = (t: TFunction, templateNodeSetKeys: string[]) =>
+ Yup.object({
+ entriesByKey: Yup.object(
+ Object.fromEntries(templateNodeSetKeys.map((key) => [key, nodeSetEntrySchema(t)])),
+ ),
+ });
+
+const getStepValidationSchema = (
+ stepId: ClusterStepId,
+ t: TFunction,
+ templateNodeSetKeys: string[],
+ role: ReturnType['role'],
+) => {
+ switch (stepId) {
+ case 'general':
+ return Yup.object({
+ title: Yup.string(),
+ resourceName: buildMetadataNameSchema(t),
+ template: templateRequiredSchema(t),
+ scope: scopeValidationSchema(t, role),
+ });
+ case 'configuration':
+ return Yup.object({
+ fieldDefinitions: Yup.object({
+ release_image: fieldDefinitionValueSchema(t),
+ node_sets: nodeSetsSchema(t, templateNodeSetKeys),
+ }),
+ });
+ case 'networking':
+ return Yup.object({
+ fieldDefinitions: Yup.object({
+ network: Yup.object({
+ pod_cidr: fieldDefinitionValueSchema(t, cidrFormatTest(t)),
+ service_cidr: fieldDefinitionValueSchema(t, cidrFormatTest(t)),
+ }),
+ }),
+ });
+ case 'access':
+ return Yup.object({
+ fieldDefinitions: Yup.object({
+ ssh_public_key: fieldDefinitionValueSchema(t),
+ pull_secret: fieldDefinitionValueSchema(t),
+ }),
+ });
+ }
+};
+
+// Validated once, in full, before the final submit — the active step's own schema (above) only
+// covers its own fields, which would let a field cleared on an earlier, already-visited step
+// through undetected (see CatalogItemWizardFooter).
+const getFullFormValidationSchema = (
+ t: TFunction,
+ templateNodeSetKeys: string[],
+ role: ReturnType['role'],
+) =>
+ Yup.object({
+ title: Yup.string(),
+ resourceName: buildMetadataNameSchema(t),
+ template: templateRequiredSchema(t),
+ scope: scopeValidationSchema(t, role),
+ fieldDefinitions: Yup.object({
+ release_image: fieldDefinitionValueSchema(t),
+ node_sets: nodeSetsSchema(t, templateNodeSetKeys),
+ network: Yup.object({
+ pod_cidr: fieldDefinitionValueSchema(t, cidrFormatTest(t)),
+ service_cidr: fieldDefinitionValueSchema(t, cidrFormatTest(t)),
+ }),
+ ssh_public_key: fieldDefinitionValueSchema(t),
+ pull_secret: fieldDefinitionValueSchema(t),
+ }),
+ });
+
+const parseOptionalNumber = (value: string | undefined): number | undefined => {
+ const trimmed = value?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+ const parsed = Number(trimmed);
+ return Number.isFinite(parsed) ? parsed : undefined;
+};
+
+// Node sets are keyed and host-typed by the template (see nodeSetsSchema above) — only the size
+// the admin entered for each template key is ever taken from the form.
+const buildNodeSetsDefault = (
+ nodeSets: NodeSetsFieldValue,
+ template: NodeSetsTemplateLike | undefined,
+): Record => {
+ const result: Record = {};
+ for (const [key, templateNodeSet] of Object.entries(template?.nodeSets ?? {})) {
+ const size = Number(nodeSets.entriesByKey[key]?.default);
+ if (!Number.isFinite(size) || size <= 0) {
+ continue;
+ }
+ result[key] = { hostType: templateNodeSet.hostType, size };
+ }
+ return result;
+};
+
+// Each node set's min/max bounds its own default independently — the resulting JSON-Schema
+// fragment nests `size` bounds per key, not as a shared `additionalProperties` constraint.
+const buildNodeSetsValidation = (
+ nodeSets: NodeSetsFieldValue,
+): Record | undefined => {
+ const properties: Record = {};
+ for (const [key, entry] of Object.entries(nodeSets.entriesByKey)) {
+ const minimum = parseOptionalNumber(entry.min);
+ const maximum = parseOptionalNumber(entry.max);
+ if (minimum === undefined && maximum === undefined) {
+ continue;
+ }
+ properties[key] = {
+ type: 'object',
+ properties: {
+ size: {
+ ...(minimum !== undefined ? { minimum } : {}),
+ ...(maximum !== undefined ? { maximum } : {}),
+ },
+ },
+ };
+ }
+ if (Object.keys(properties).length === 0) {
+ return undefined;
+ }
+ return { type: 'object', properties };
+};
+
+const buildFieldDefinitions = (
+ values: ClusterCatalogItemFormValues,
+ t: TFunction,
+ template: NodeSetsTemplateLike | undefined,
+) => [
+ buildFieldDefinition('release_image', t('Release image'), values.fieldDefinitions.release_image),
+ buildFieldDefinition('network.pod_cidr', t('Pod CIDR'), values.fieldDefinitions.network.pod_cidr),
+ buildFieldDefinition(
+ 'network.service_cidr',
+ t('Service CIDR'),
+ values.fieldDefinitions.network.service_cidr,
+ ),
+ buildFieldDefinition(
+ 'ssh_public_key',
+ t('SSH public key'),
+ values.fieldDefinitions.ssh_public_key,
+ ),
+ buildFieldDefinition('pull_secret', t('Pull secret'), values.fieldDefinitions.pull_secret),
+ buildFieldDefinition('node_sets', t('Node sets'), {
+ editable: values.fieldDefinitions.node_sets.editable,
+ default: buildNodeSetsDefault(values.fieldDefinitions.node_sets, template),
+ validation: buildNodeSetsValidation(values.fieldDefinitions.node_sets),
+ }),
+];
+
+export const ClusterCatalogItemCreatePage = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { role } = useSession();
+ const { data: templates = [], isLoading: templatesLoading } = useAdminClusterTemplates();
+ const { mutateAsync: createClusterCatalogItem, isPending } = useCreateClusterCatalogItem();
+ const [activeStepId, setActiveStepId] = useState('general');
+ const [validationAlert, setValidationAlert] = useState(false);
+ const [submitError, setSubmitError] = useState();
+ // Mirrors formik.values.template.value so the node-set validation schema (below) can react to the
+ // template the admin picks — read directly from Formik state once `formik` exists (see effect).
+ const [selectedTemplateId, setSelectedTemplateId] = useState('');
+
+ const selectedTemplate = useMemo(
+ () => templates.find((template) => template.id === selectedTemplateId),
+ [templates, selectedTemplateId],
+ );
+ const templateNodeSetKeys = useMemo(
+ () => Object.keys(selectedTemplate?.nodeSets ?? {}).sort(),
+ [selectedTemplate],
+ );
+
+ const initialValues = useMemo(() => createInitialValues(role), [role]);
+ const validationSchema = useMemo(
+ () => getStepValidationSchema(activeStepId, t, templateNodeSetKeys, role),
+ [activeStepId, t, templateNodeSetKeys, role],
+ );
+ const fullFormSchema = useMemo(
+ () => getFullFormValidationSchema(t, templateNodeSetKeys, role),
+ [t, templateNodeSetKeys, role],
+ );
+
+ // Deliberately useFormik + FormikProvider rather than the component (used in the other
+ // two catalog-item wizards): validationSchema here depends on formik.values.template.value (via
+ // templateNodeSetKeys above), so it must be computed in this same scope, before Formik exists —
+ // 's render-prop only exposes `formik` after the component is already instantiated.
+ const formik = useFormik({
+ initialValues,
+ validationSchema,
+ validateOnBlur: true,
+ validateOnChange: false,
+ onSubmit: async (values) => {
+ setSubmitError(undefined);
+ try {
+ const template = templates.find((candidate) => candidate.id === values.template.value);
+ const payload: MessageInitShape = {
+ title: values.title.trim(),
+ description: values.description.trim(),
+ template: values.template.value,
+ published: false,
+ ...buildScopePayloadFields(values.scope, role, values.resourceName),
+ // buildFieldDefinition()'s `default` is a decoded google.protobuf.Value init shape;
+ // MessageInitShape can't structurally verify it against the generated Value type, so
+ // this one property needs a cast (see buildFieldDefinition in fieldDefinitionValue.ts).
+ fieldDefinitions: buildFieldDefinitions(values, t, template) as MessageInitShape<
+ typeof ClusterCatalogItemSchema
+ >['fieldDefinitions'],
+ };
+ await createClusterCatalogItem(payload);
+ navigate('/admin/catalog');
+ } catch (error) {
+ setSubmitError(getErrorMessage(error));
+ }
+ },
+ });
+
+ useEffect(() => {
+ setSelectedTemplateId(formik.values.template.value);
+ }, [formik.values.template.value]);
+
+ const templateOptions = templates.map((template) => ({
+ value: template.id,
+ label: template.metadata?.name || template.id,
+ }));
+
+ return (
+ <>
+
+
+
+
+
+
+ {t('Create')}
+
+
+ {t('Create cluster catalog item')}
+
+
+ {t('Define a curated cluster offering for tenants to provision from.')}
+
+
+
+
+
+
+ setActiveStepId(id as ClusterStepId)}
+ fullFormSchema={fullFormSchema}
+ setValidationAlert={setValidationAlert}
+ isPending={isPending}
+ />
+
+ }
+ >
+ {STEP_IDS.map((stepId) => (
+
+
+
+ {validationAlert ? (
+
+
+
+ ) : null}
+ {submitError ? (
+
+
+ {submitError}
+
+
+ ) : null}
+ {stepId === 'general' ? (
+
+ ) : null}
+ {stepId === 'configuration' ? (
+
+ ) : null}
+ {stepId === 'networking' ? : null}
+ {stepId === 'access' ? : null}
+
+
+
+ ))}
+
+
+
+ >
+ );
+};
+
+export default ClusterCatalogItemCreatePage;
diff --git a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemCreatePage.test.tsx b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemCreatePage.test.tsx
new file mode 100644
index 00000000..ad89a200
--- /dev/null
+++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemCreatePage.test.tsx
@@ -0,0 +1,160 @@
+import { createRouterTransport } from '@connectrpc/connect';
+import { screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ComputeInstanceCatalogItems, ComputeInstanceTemplates } from '@osac/types';
+import {
+ ComputeInstanceCatalogItems as PrivateComputeInstanceCatalogItems,
+ ComputeInstanceTemplates as PrivateComputeInstanceTemplates,
+} from '@osac/types/private';
+
+import { ComputeInstanceCatalogItemCreatePage } from './ComputeInstanceCatalogItemCreatePage';
+import * as instanceTypesApi from '../../../api/v1/instance-types';
+import * as tenantApi from '../../../api/v1/private/tenant';
+import * as projectsApi from '../../../api/v1/projects';
+import { SessionProvider } from '../../../hooks/use-session';
+import { renderWithProviders } from '../../../test-utils/TestProviders';
+
+vi.mock('../../../api/v1/instance-types', () => ({ useInstanceTypes: vi.fn() }));
+vi.mock('../../../api/v1/private/tenant', () => ({ usePrivateTenants: vi.fn() }));
+vi.mock('../../../api/v1/projects', () => ({ useProjects: vi.fn() }));
+
+const asQueryResult = (data: T) =>
+ ({ data, isLoading: false, error: null }) as unknown as ReturnType<
+ typeof tenantApi.usePrivateTenants
+ >;
+
+const mockSharedData = (
+ instanceTypes: {
+ id: string;
+ metadata?: { name?: string };
+ spec?: Record;
+ }[] = [],
+) => {
+ vi.mocked(instanceTypesApi.useInstanceTypes).mockReturnValue(
+ asQueryResult(instanceTypes) as unknown as ReturnType,
+ );
+ vi.mocked(tenantApi.usePrivateTenants).mockReturnValue(asQueryResult([]));
+ vi.mocked(projectsApi.useProjects).mockReturnValue(
+ asQueryResult([]) as unknown as ReturnType,
+ );
+};
+
+const selectTemplate = async (user: ReturnType['user']) => {
+ await user.click(screen.getByLabelText(/^Template/));
+ await user.click(screen.getByRole('option', { name: 'Template One' }));
+};
+
+const fillNames = async (
+ user: ReturnType['user'],
+ title: string,
+ resourceName: string,
+) => {
+ await user.type(screen.getByLabelText(/^Title/), title);
+ await user.type(screen.getByLabelText(/^Name/), resourceName);
+};
+
+const createFn = vi.fn(() => ({ object: { id: 'new-id', title: 'My VM' } }));
+
+const renderPage = () => {
+ const transport = createRouterTransport((router) => {
+ router.service(ComputeInstanceCatalogItems, { create: createFn });
+ router.service(PrivateComputeInstanceCatalogItems, { create: createFn });
+ router.service(ComputeInstanceTemplates, { list: () => ({ items: [] }) });
+ router.service(PrivateComputeInstanceTemplates, {
+ list: () => ({ items: [{ id: 'tmpl-1', metadata: { name: 'Template One' } }] }),
+ });
+ });
+ return renderWithProviders(
+
+
+ ,
+ { transport, routerEntries: ['/admin/catalog/compute-instance/create'] },
+ );
+};
+
+describe('ComputeInstanceCatalogItemCreatePage', () => {
+ it('renders the General step by default with all three step nav items', () => {
+ mockSharedData();
+ renderPage();
+
+ expect(
+ screen.getByRole('heading', { name: 'Create virtual machine catalog item' }),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Title/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
+ expect(screen.getAllByText('General').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Configuration').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Access').length).toBeGreaterThan(0);
+ expect(screen.queryByText('Networking')).not.toBeInTheDocument();
+ });
+
+ it('blocks advancing past General when no template is selected', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My VM', 'my-vm');
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('This step has validation errors')).toBeInTheDocument();
+ });
+
+ it('blocks advancing past General when Organization scope is selected without an organization', async () => {
+ mockSharedData();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My VM', 'my-vm');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('radio', { name: 'Organization' }));
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(await screen.findByText('This step has validation errors')).toBeInTheDocument();
+ });
+
+ it('submits with published: false and auto-includes network_attachments', async () => {
+ mockSharedData();
+ createFn.mockClear();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My VM', 'my-vm');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ await waitFor(() => expect(createFn).toHaveBeenCalled());
+ const request = (createFn.mock.calls[0] as unknown[])[0] as {
+ object: {
+ published: boolean;
+ title: string;
+ fieldDefinitions: { path: string }[];
+ };
+ };
+ expect(request.object.published).toBe(false);
+ expect(request.object.title).toBe('My VM');
+ expect(request.object.fieldDefinitions.map((fd) => fd.path)).toContain('network_attachments');
+ });
+
+ it('flattens the selected instance type to its id, not the whole {value, label} ref, when serializing the default', async () => {
+ mockSharedData([
+ { id: 'small', metadata: { name: 'Small' }, spec: { cores: 2, memoryGib: 4 } },
+ ]);
+ createFn.mockClear();
+ const { user } = renderPage();
+
+ await fillNames(user, 'My VM', 'my-vm');
+ await selectTemplate(user);
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Default value' }));
+ await user.click(screen.getByRole('option', { name: /Small/ }));
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ await waitFor(() => expect(createFn).toHaveBeenCalled());
+ const request = (createFn.mock.calls[0] as unknown[])[0] as {
+ object: { fieldDefinitions: { path: string; default: unknown }[] };
+ };
+ const instanceType = request.object.fieldDefinitions.find((fd) => fd.path === 'instance_type');
+ expect(instanceType?.default).toMatchObject({ kind: { case: 'stringValue', value: 'small' } });
+ });
+});
diff --git a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemCreatePage.tsx b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemCreatePage.tsx
new file mode 100644
index 00000000..2d278640
--- /dev/null
+++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemCreatePage.tsx
@@ -0,0 +1,330 @@
+import { useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { type MessageInitShape } from '@bufbuild/protobuf';
+import {
+ Alert,
+ Breadcrumb,
+ BreadcrumbItem,
+ Button,
+ Content,
+ PageSection,
+ PageSectionTypes,
+ Stack,
+ StackItem,
+ Title,
+ Wizard,
+ WizardFooterWrapper,
+ WizardStep,
+} from '@patternfly/react-core';
+import { Formik } from 'formik';
+import type { TFunction } from 'i18next';
+import * as Yup from 'yup';
+
+import { ComputeInstanceCatalogItemSchema } from '@osac/types';
+
+import { useCreateComputeInstanceCatalogItem } from '../../../api/v1/compute-instance-catalog-item';
+import { useAdminComputeInstanceTemplates } from '../../../api/v1/compute-instance-templates';
+import { CatalogItemGeneralFields } from '../../../components/catalogManagement/CatalogItemGeneralFields';
+import {
+ resourceRefRequiredSchema,
+ templateRequiredSchema,
+} from '../../../components/catalogManagement/catalogItemGeneralSchema';
+import {
+ type ScopeValues,
+ buildScopePayloadFields,
+ initialScopeForRole,
+ scopeValidationSchema,
+} from '../../../components/catalogManagement/catalogItemScope';
+import { CatalogItemWizardFooter } from '../../../components/catalogManagement/CatalogItemWizardFooter';
+import {
+ type FieldDefinitionValue,
+ buildFieldDefinition,
+ fieldDefinitionValueSchema,
+} from '../../../components/catalogManagement/fieldDefinitions/fieldDefinitionValue';
+import { VMAccessStep } from '../../../components/catalogManagement/steps/compute-instance/VMAccessStep';
+import { VMConfigurationStep } from '../../../components/catalogManagement/steps/compute-instance/VMConfigurationStep';
+import { buildMetadataNameSchema } from '../../../components/catalogProvision/wizard/metadataNameSchema';
+import { FieldValidationProvider } from '../../../components/Form/FieldValidationContext';
+import {
+ EMPTY_LABELED_RESOURCE_REF,
+ type LabeledResourceRef,
+} from '../../../components/Form/labeledResourceRef';
+import { useSession } from '../../../hooks/use-session';
+import { useTranslation } from '../../../hooks/useTranslation';
+import { getErrorMessage } from '../../../utils/error';
+
+const STEP_IDS = ['general', 'configuration', 'access'] as const;
+type VmStepId = (typeof STEP_IDS)[number];
+
+const STEP_LABEL_KEYS: Record = {
+ general: 'General',
+ configuration: 'Configuration',
+ access: 'Access',
+};
+
+interface AdditionalDiskEntry {
+ rowId: string;
+ size_gib: FieldDefinitionValue;
+}
+
+interface ComputeInstanceCatalogItemFormValues {
+ title: string;
+ resourceName: string;
+ description: string;
+ template: LabeledResourceRef;
+ scope: ScopeValues;
+ fieldDefinitions: {
+ instance_type: FieldDefinitionValue;
+ image: { source_ref: FieldDefinitionValue };
+ boot_disk: { size_gib: FieldDefinitionValue };
+ additional_disks: AdditionalDiskEntry[];
+ run_strategy: FieldDefinitionValue;
+ user_data: FieldDefinitionValue;
+ ssh_key: FieldDefinitionValue;
+ };
+}
+
+const createInitialValues = (
+ role: ReturnType['role'],
+): ComputeInstanceCatalogItemFormValues => ({
+ title: '',
+ resourceName: '',
+ description: '',
+ template: EMPTY_LABELED_RESOURCE_REF,
+ scope: initialScopeForRole(role),
+ fieldDefinitions: {
+ instance_type: { editable: true, default: EMPTY_LABELED_RESOURCE_REF },
+ image: { source_ref: { editable: true, default: '' } },
+ boot_disk: { size_gib: { editable: true, default: '' } },
+ additional_disks: [],
+ run_strategy: { editable: true, default: 'Always' },
+ user_data: { editable: true, default: '' },
+ ssh_key: { editable: true, default: '' },
+ },
+});
+
+// instance_type's default is a LabeledResourceRef ({value, label}), not a scalar, so the generic
+// fieldDefinitionValueSchema's required-default check (which only tests for `!== ''`) would pass
+// trivially on an empty ref object. Require `.value` specifically, only when non-editable.
+const instanceTypeFieldDefinitionSchema = (t: TFunction) =>
+ Yup.object({
+ editable: Yup.boolean().required(),
+ default: Yup.object({ value: Yup.string() }).when('editable', {
+ is: false,
+ then: () => resourceRefRequiredSchema(t('Default value is required for non-editable fields')),
+ }),
+ });
+
+const getStepValidationSchema = (
+ stepId: VmStepId,
+ t: TFunction,
+ role: ReturnType['role'],
+) => {
+ switch (stepId) {
+ case 'general':
+ return Yup.object({
+ title: Yup.string(),
+ resourceName: buildMetadataNameSchema(t),
+ template: templateRequiredSchema(t),
+ scope: scopeValidationSchema(t, role),
+ });
+ case 'configuration':
+ return Yup.object({
+ fieldDefinitions: Yup.object({
+ instance_type: instanceTypeFieldDefinitionSchema(t),
+ boot_disk: Yup.object({ size_gib: fieldDefinitionValueSchema(t) }),
+ additional_disks: Yup.array().of(Yup.object({ size_gib: fieldDefinitionValueSchema(t) })),
+ }),
+ });
+ case 'access':
+ return Yup.object({
+ fieldDefinitions: Yup.object({ ssh_key: fieldDefinitionValueSchema(t) }),
+ });
+ }
+};
+
+// Validated once, in full, before the final submit — see CatalogItemWizardFooter.
+const getFullFormValidationSchema = (t: TFunction, role: ReturnType['role']) =>
+ Yup.object({
+ title: Yup.string(),
+ resourceName: buildMetadataNameSchema(t),
+ template: templateRequiredSchema(t),
+ scope: scopeValidationSchema(t, role),
+ fieldDefinitions: Yup.object({
+ instance_type: instanceTypeFieldDefinitionSchema(t),
+ additional_disks: Yup.array().of(Yup.object({ size_gib: fieldDefinitionValueSchema(t) })),
+ boot_disk: Yup.object({ size_gib: fieldDefinitionValueSchema(t) }),
+ ssh_key: fieldDefinitionValueSchema(t),
+ }),
+ });
+
+const buildFieldDefinitions = (values: ComputeInstanceCatalogItemFormValues, t: TFunction) => [
+ buildFieldDefinition(
+ 'image.source_ref',
+ t('Source Ref'),
+ values.fieldDefinitions.image.source_ref,
+ ),
+ // instance_type's default is a LabeledResourceRef ({value, label}); flatten to the id before
+ // serializing, the same way `template` is flattened below — otherwise the display label leaks
+ // into the wire payload as a struct instead of a plain string id.
+ buildFieldDefinition('instance_type', t('Instance type'), {
+ editable: values.fieldDefinitions.instance_type.editable,
+ default: values.fieldDefinitions.instance_type.default.value,
+ }),
+ buildFieldDefinition(
+ 'boot_disk.size_gib',
+ t('Boot disk size (GiB)'),
+ values.fieldDefinitions.boot_disk.size_gib,
+ ),
+ ...values.fieldDefinitions.additional_disks.map((disk, index) =>
+ buildFieldDefinition(
+ `additional_disks.${index}.size_gib`,
+ t('Additional disk size (GiB)'),
+ disk.size_gib,
+ ),
+ ),
+ buildFieldDefinition('run_strategy', t('Run strategy'), values.fieldDefinitions.run_strategy),
+ buildFieldDefinition('user_data', t('User data'), values.fieldDefinitions.user_data),
+ buildFieldDefinition('ssh_key', t('SSH public key'), values.fieldDefinitions.ssh_key),
+ // Not shown in any wizard step — VM catalog items always allow tenants to configure network
+ // attachments at provisioning time.
+ buildFieldDefinition('network_attachments', t('Network attachments'), {
+ editable: true,
+ default: [],
+ }),
+];
+
+export const ComputeInstanceCatalogItemCreatePage = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { role } = useSession();
+ const { data: templates = [], isLoading: templatesLoading } = useAdminComputeInstanceTemplates();
+ const { mutateAsync: createComputeInstanceCatalogItem, isPending } =
+ useCreateComputeInstanceCatalogItem();
+ const [activeStepId, setActiveStepId] = useState('general');
+ const [validationAlert, setValidationAlert] = useState(false);
+ const [submitError, setSubmitError] = useState();
+
+ const initialValues = useMemo(() => createInitialValues(role), [role]);
+ const validationSchema = useMemo(
+ () => getStepValidationSchema(activeStepId, t, role),
+ [activeStepId, t, role],
+ );
+ const fullFormSchema = useMemo(() => getFullFormValidationSchema(t, role), [t, role]);
+
+ const templateOptions = templates.map((template) => ({
+ value: template.id,
+ label: template.metadata?.name || template.id,
+ }));
+
+ return (
+ <>
+
+
+
+
+
+
+ {t('Create')}
+
+
+ {t('Create virtual machine catalog item')}
+
+
+ {t('Define a curated virtual machine offering for tenants to provision from.')}
+
+
+
+
+ initialValues={initialValues}
+ validationSchema={validationSchema}
+ validateOnBlur
+ validateOnChange={false}
+ onSubmit={async (values) => {
+ setSubmitError(undefined);
+ try {
+ const payload: MessageInitShape = {
+ title: values.title.trim(),
+ description: values.description.trim(),
+ template: values.template.value,
+ published: false,
+ ...buildScopePayloadFields(values.scope, role, values.resourceName),
+ // buildFieldDefinition()'s `default` is a decoded google.protobuf.Value init shape;
+ // MessageInitShape can't structurally verify it against the generated Value type, so
+ // this one property needs a cast (see buildFieldDefinition in fieldDefinitionValue.ts).
+ fieldDefinitions: buildFieldDefinitions(values, t) as MessageInitShape<
+ typeof ComputeInstanceCatalogItemSchema
+ >['fieldDefinitions'],
+ };
+ await createComputeInstanceCatalogItem(payload);
+ navigate('/admin/catalog');
+ } catch (error) {
+ setSubmitError(getErrorMessage(error));
+ }
+ }}
+ >
+ {(formik) => (
+
+
+ setActiveStepId(id as VmStepId)}
+ fullFormSchema={fullFormSchema}
+ setValidationAlert={setValidationAlert}
+ isPending={isPending}
+ />
+
+ }
+ >
+ {STEP_IDS.map((stepId) => (
+
+
+
+ {validationAlert ? (
+
+
+
+ ) : null}
+ {submitError ? (
+
+
+ {submitError}
+
+
+ ) : null}
+ {stepId === 'general' ? (
+
+ ) : null}
+ {stepId === 'configuration' ? : null}
+ {stepId === 'access' ? : null}
+
+
+
+ ))}
+
+
+ )}
+
+ >
+ );
+};
+
+export default ComputeInstanceCatalogItemCreatePage;
diff --git a/libs/ui-components/src/test-utils/renderHookWithTransport.tsx b/libs/ui-components/src/test-utils/renderHookWithTransport.tsx
new file mode 100644
index 00000000..774fc676
--- /dev/null
+++ b/libs/ui-components/src/test-utils/renderHookWithTransport.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from 'react';
+import type { Transport } from '@connectrpc/connect';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react';
+
+import { ApiProvider } from '../api/api-context';
+import { SessionProvider } from '../hooks/use-session';
+import type { DemoShellRole } from '../shellTypes';
+
+/** Shared `renderHook` wrapper for API hook tests: ApiProvider + a query/mutation-safe QueryClient, with an optional SessionProvider when the hook under test is role-aware. */
+export const renderHookWithTransport = (
+ hook: () => TResult,
+ transport: Transport,
+ role?: DemoShellRole,
+) => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => {
+ const withApi = (
+
+ {children}
+
+ );
+ return role ? (
+
+ {withApi}
+
+ ) : (
+ withApi
+ );
+ };
+ return renderHook(hook, { wrapper });
+};
diff --git a/libs/ui-components/src/validation/cidr-validation.test.ts b/libs/ui-components/src/validation/cidr-validation.test.ts
index 9d17675e..1b19e237 100644
--- a/libs/ui-components/src/validation/cidr-validation.test.ts
+++ b/libs/ui-components/src/validation/cidr-validation.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
+ IPV4_CIDR_PATTERN,
buildCidrSchema,
cidrsOverlap,
hasSubnetOverlap,
@@ -28,6 +29,28 @@ describe('isValidCidr (ipv4)', () => {
});
});
+describe('IPV4_CIDR_PATTERN', () => {
+ // Every fixture here must agree with isValidCidr(value, 'ipv4') — this is the regression the
+ // wire-facing pattern and the admin-side Address4 parser must never diverge on again (the
+ // pattern used to accept leading-zero octets like "010.0.0.0/8" that Address4 rejects).
+ const pattern = new RegExp(IPV4_CIDR_PATTERN);
+
+ it.each([
+ ['10.128.0.0/14', true],
+ ['0.0.0.0/0', true],
+ ['255.255.255.255/32', true],
+ [' 10.128.0.0/14 ', true],
+ ['010.0.0.0/8', false],
+ ['192.168.01.1/24', false],
+ ['999.999.999.999/99', false],
+ ['256.0.0.0/8', false],
+ ['10.0.0.0/33', false],
+ ])('matches %j as %s, agreeing with isValidCidr', (value, expected) => {
+ expect(pattern.test(value)).toBe(expected);
+ expect(isValidCidr(value, 'ipv4')).toBe(expected);
+ });
+});
+
describe('buildCidrSchema (ipv4)', () => {
const schema = buildCidrSchema(tIdentity, 'ipv4');
diff --git a/libs/ui-components/src/validation/cidr-validation.ts b/libs/ui-components/src/validation/cidr-validation.ts
index 2ffcf756..325266c5 100644
--- a/libs/ui-components/src/validation/cidr-validation.ts
+++ b/libs/ui-components/src/validation/cidr-validation.ts
@@ -4,6 +4,15 @@ import * as Yup from 'yup';
export type CidrIpFamily = 'ipv4' | 'ipv6';
+// Octet-range-aware (0-255, no leading zeros) and prefix-range-aware (0-32) IPv4 CIDR regex, kept
+// as the single source for any wire-facing `validationSchema` pattern so it can never drift from
+// isValidCidr()'s Address4 parser below (which rejects "999.999.999.999/99" as out of range and
+// "010.0.0.0/8" as a leading-zero octet). Tolerates surrounding whitespace to agree with
+// isValidCidr(), which trims before parsing — otherwise a value the admin's own wizard accepts
+// could still fail this pattern when shipped as a tenant-facing JSON-Schema `pattern` constraint.
+const IPV4_CIDR_OCTET = '(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])';
+export const IPV4_CIDR_PATTERN = `^\\s*${IPV4_CIDR_OCTET}(\\.${IPV4_CIDR_OCTET}){3}/([0-9]|[12][0-9]|3[0-2])\\s*$`;
+
/**
* Returns true when value is empty or a valid CIDR for the requested IP family.
*/