From 4ee948004ef22c6b0af7feb7b69c5f2fc34d42d9 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Thu, 23 Jul 2026 16:08:36 +0300 Subject: [PATCH 01/12] OSAC-2932: widen CatalogItem type and add scope derivation for admin views Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../catalog/catalogItemDisplay.test.ts | 116 ++++++++++++++++++ .../components/catalog/catalogItemDisplay.ts | 46 ++++++- 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts index f877f9cd..fa87665f 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from 'vitest'; import { ClusterCatalogItem } from '@osac/types'; +import type { ClusterCatalogItem as PrivateClusterCatalogItem } from '@osac/types/private'; import { + SHARED_TENANT, catalogItemResourceLine, catalogItemResourceParts, + catalogItemScope, + catalogItemSubtitle, filterCatalogItemsBySearch, } from './catalogItemDisplay'; import { @@ -220,3 +224,115 @@ describe('filterCatalogItemsBySearch', () => { expect(filterCatalogItemsBySearch(items, 'PRODUCTION')).toEqual([items[1]]); }); }); + +const privateClusterItem = ( + overrides: Partial = {}, +): PrivateClusterCatalogItem => ({ + $typeName: 'osac.private.v1.ClusterCatalogItem', + id: 'catalog-cluster-1', + metadata: { + $typeName: 'osac.private.v1.Metadata', + finalizers: [], + creator: 'admin', + tenant: '', + name: 'catalog-item', + labels: {}, + annotations: {}, + version: 1, + project: '', + }, + title: 'OpenShift 4 cluster', + description: 'Standard OpenShift cluster offering', + template: 'tpl-openshift-4', + published: true, + tenant: '', + fieldDefinitions: [], + ...overrides, +}); + +const publicVmItemWithMetadata = (tenant: string, project = '') => ({ + $typeName: 'osac.public.v1.ComputeInstanceCatalogItem' as const, + id: 'catalog-rhel-9', + metadata: { + $typeName: 'osac.public.v1.Metadata' as const, + name: 'catalog-rhel-9', + annotations: {}, + creator: 'foo', + labels: {}, + project, + tenant, + version: 1, + }, + title: 'RHEL 9 catalog', + description: 'RHEL 9 base image', + template: 'tpl-rhel-9', + published: true, + fieldDefinitions: [], +}); + +describe('catalogItemScope', () => { + it('returns general for a CSP Admin item with no private tenant', () => { + const item = privateClusterItem({ tenant: '' }); + expect(catalogItemScope(item, 'providerAdmin')).toEqual({ level: 'general' }); + }); + + it('returns general for the providerAdmin role given a public-shaped item lacking a tenant field', () => { + const item = publicVmItemWithMetadata('acme-corp'); + expect(catalogItemScope(item, 'providerAdmin')).toEqual({ level: 'general' }); + }); + + it('returns organization with the tenant name for a CSP Admin item scoped to a tenant', () => { + const item = privateClusterItem({ tenant: 'acme-corp' }); + expect(catalogItemScope(item, 'providerAdmin')).toEqual({ + level: 'organization', + name: 'acme-corp', + }); + }); + + it('returns project for a CSP Admin item even when the private tenant is also set', () => { + const base = privateClusterItem({ tenant: 'acme-corp' }); + const item = { ...base, metadata: { ...base.metadata, project: 'frontend' } }; + expect(catalogItemScope(item, 'providerAdmin')).toEqual({ + level: 'project', + name: 'frontend', + }); + }); + + it('returns general for a Tenant Admin item whose metadata.tenant is the shared sentinel', () => { + const item = publicVmItemWithMetadata(SHARED_TENANT); + expect(catalogItemScope(item, 'tenantAdmin')).toEqual({ level: 'general' }); + }); + + it('returns general for a Tenant Admin item whose metadata.tenant is empty', () => { + const item = publicVmItemWithMetadata(''); + expect(catalogItemScope(item, 'tenantAdmin')).toEqual({ level: 'general' }); + }); + + it('returns organization for a Tenant Admin item whose metadata.tenant is not the shared sentinel', () => { + const item = publicVmItemWithMetadata('acme-corp'); + expect(catalogItemScope(item, 'tenantAdmin')).toEqual({ level: 'organization' }); + }); + + it('returns project for a Tenant Admin item with a project set, regardless of metadata.tenant', () => { + const item = publicVmItemWithMetadata(SHARED_TENANT, 'frontend'); + expect(catalogItemScope(item, 'tenantAdmin')).toEqual({ level: 'project', name: 'frontend' }); + }); +}); + +describe('existing display helpers with private-v1 items', () => { + it('catalogItemSubtitle falls back to metadata.name when description is empty', () => { + const item = privateClusterItem({ description: '' }); + expect(catalogItemSubtitle(item)).toBe('catalog-item'); + }); + + it('catalogItemSubtitle uses the description when present', () => { + const item = privateClusterItem(); + expect(catalogItemSubtitle(item)).toBe('Standard OpenShift cluster offering'); + }); + + it('filterCatalogItemsBySearch matches a private-v1 item by title', () => { + const item = privateClusterItem(); + expect(filterCatalogItemsBySearch([item], 'openshift')).toEqual([item]); + expect(filterCatalogItemsBySearch([item], 'no-such-term')).toEqual([]); + }); +}); diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts index ece6cacc..cb225d35 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts @@ -3,7 +3,13 @@ import type { ClusterCatalogItem, ComputeInstanceCatalogItem, } from '@osac/types'; +import type { + BareMetalInstanceCatalogItem as PrivateBareMetalInstanceCatalogItem, + ClusterCatalogItem as PrivateClusterCatalogItem, + ComputeInstanceCatalogItem as PrivateComputeInstanceCatalogItem, +} from '@osac/types/private'; +import type { DemoShellRole } from '../../shellTypes'; import { CATALOG_ITEM_RESOURCE_FIELD_PATHS, type CatalogFieldDefinition, @@ -19,7 +25,15 @@ import { export type CatalogItem = | ClusterCatalogItem | BareMetalInstanceCatalogItem - | ComputeInstanceCatalogItem; + | ComputeInstanceCatalogItem + | PrivateClusterCatalogItem + | PrivateBareMetalInstanceCatalogItem + | PrivateComputeInstanceCatalogItem; + +type PrivateCatalogItem = + | PrivateClusterCatalogItem + | PrivateBareMetalInstanceCatalogItem + | PrivateComputeInstanceCatalogItem; export type CatalogItemKind = 'vm' | 'cluster' | 'bm'; @@ -148,3 +162,33 @@ export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string = } return fieldDefinitionDefaultToInputString(defaultValue) || '—'; }; + +/** + * fulfillment-service's built-in global tenant. Every object without an explicit tenant is + * auto-assigned this value server-side, and it round-trips unmasked through the public API's + * `metadata.tenant` field even though the business `tenant` field is stripped from public catalog + * item responses entirely. + */ +export const SHARED_TENANT = 'shared'; + +export type CatalogItemScope = + | { level: 'general' } + | { level: 'organization'; name?: string } + | { level: 'project'; name: string }; + +const isPrivateCatalogItem = (item: CatalogItem): item is PrivateCatalogItem => 'tenant' in item; + +export const catalogItemScope = (item: CatalogItem, role: DemoShellRole): CatalogItemScope => { + const project = item.metadata?.project ?? ''; + if (project) { + return { level: 'project', name: project }; + } + if (role === 'providerAdmin') { + const tenant = isPrivateCatalogItem(item) ? item.tenant : ''; + return tenant ? { level: 'organization', name: tenant } : { level: 'general' }; + } + const metadataTenant = item.metadata?.tenant ?? ''; + return metadataTenant === SHARED_TENANT || !metadataTenant + ? { level: 'general' } + : { level: 'organization' }; +}; From ed413746557208c1f52401c0ea39a830c3e0c959 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Thu, 23 Jul 2026 16:16:16 +0300 Subject: [PATCH 02/12] OSAC-2932: support private-v1 catalog item kinds in CatalogItemIcon Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../catalog/catalogItemDisplay.test.ts | 30 +++++++++++-------- libs/ui-components/src/icons.test.tsx | 25 ++++++++++++++++ libs/ui-components/src/icons.tsx | 7 ++++- 3 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 libs/ui-components/src/icons.test.tsx diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts index fa87665f..a9a45dfa 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts @@ -225,22 +225,24 @@ describe('filterCatalogItemsBySearch', () => { }); }); +const basePrivateMetadata = (): NonNullable => ({ + $typeName: 'osac.private.v1.Metadata', + finalizers: [], + creator: 'admin', + tenant: '', + name: 'catalog-item', + labels: {}, + annotations: {}, + version: 1, + project: '', +}); + const privateClusterItem = ( overrides: Partial = {}, ): PrivateClusterCatalogItem => ({ $typeName: 'osac.private.v1.ClusterCatalogItem', id: 'catalog-cluster-1', - metadata: { - $typeName: 'osac.private.v1.Metadata', - finalizers: [], - creator: 'admin', - tenant: '', - name: 'catalog-item', - labels: {}, - annotations: {}, - version: 1, - project: '', - }, + metadata: basePrivateMetadata(), title: 'OpenShift 4 cluster', description: 'Standard OpenShift cluster offering', template: 'tpl-openshift-4', @@ -290,8 +292,10 @@ describe('catalogItemScope', () => { }); it('returns project for a CSP Admin item even when the private tenant is also set', () => { - const base = privateClusterItem({ tenant: 'acme-corp' }); - const item = { ...base, metadata: { ...base.metadata, project: 'frontend' } }; + const item = privateClusterItem({ + tenant: 'acme-corp', + metadata: { ...basePrivateMetadata(), project: 'frontend' }, + }); expect(catalogItemScope(item, 'providerAdmin')).toEqual({ level: 'project', name: 'frontend', diff --git a/libs/ui-components/src/icons.test.tsx b/libs/ui-components/src/icons.test.tsx new file mode 100644 index 00000000..ce535518 --- /dev/null +++ b/libs/ui-components/src/icons.test.tsx @@ -0,0 +1,25 @@ +import CloudIcon from '@patternfly/react-icons/dist/esm/icons/cloud-icon'; +import ServerIcon from '@patternfly/react-icons/dist/esm/icons/server-icon'; +import VirtualMachineIcon from '@patternfly/react-icons/dist/esm/icons/virtual-machine-icon'; +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { CatalogItemIcon } from './icons'; + +const renderedIconPath = (ui: React.ReactElement) => + render(ui).container.querySelector('svg path')?.getAttribute('d'); + +describe('CatalogItemIcon', () => { + it.each([ + ['osac.public.v1.ClusterCatalogItem', CloudIcon], + ['osac.private.v1.ClusterCatalogItem', CloudIcon], + ['osac.public.v1.BareMetalInstanceCatalogItem', ServerIcon], + ['osac.private.v1.BareMetalInstanceCatalogItem', ServerIcon], + ['osac.public.v1.ComputeInstanceCatalogItem', VirtualMachineIcon], + ['osac.private.v1.ComputeInstanceCatalogItem', VirtualMachineIcon], + ] as const)('renders the expected icon for kind %s', (kind, ExpectedIcon) => { + expect(renderedIconPath()).toBe( + renderedIconPath(), + ); + }); +}); diff --git a/libs/ui-components/src/icons.tsx b/libs/ui-components/src/icons.tsx index 7341104d..2f52364b 100644 --- a/libs/ui-components/src/icons.tsx +++ b/libs/ui-components/src/icons.tsx @@ -25,16 +25,21 @@ interface CatalogItemIconProps { kind: | 'osac.public.v1.ClusterCatalogItem' | 'osac.public.v1.BareMetalInstanceCatalogItem' - | 'osac.public.v1.ComputeInstanceCatalogItem'; + | 'osac.public.v1.ComputeInstanceCatalogItem' + | 'osac.private.v1.ClusterCatalogItem' + | 'osac.private.v1.BareMetalInstanceCatalogItem' + | 'osac.private.v1.ComputeInstanceCatalogItem'; } export const CatalogItemIcon = ({ kind }: CatalogItemIconProps) => { let Icon = VirtualMachineIcon; switch (kind) { case 'osac.public.v1.ClusterCatalogItem': + case 'osac.private.v1.ClusterCatalogItem': Icon = CloudIcon; break; case 'osac.public.v1.BareMetalInstanceCatalogItem': + case 'osac.private.v1.BareMetalInstanceCatalogItem': Icon = ServerIcon; break; default: From 1c34988743bbc85397ea74816bdbcd030ff17452 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Thu, 23 Jul 2026 16:31:12 +0300 Subject: [PATCH 03/12] OSAC-2932: add role-aware admin catalog item hooks (list + publish toggle) Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/ui-components/src/api/types.ts | 5 +- .../src/api/v1/baremetal-instance.test.ts | 219 ++++++++++++++++++ .../src/api/v1/baremetal-instance.ts | 52 ++++- .../src/api/v1/cluster-catalog-item.test.ts | 215 +++++++++++++++++ .../src/api/v1/cluster-catalog-item.ts | 46 +++- .../v1/compute-instance-catalog-item.test.ts | 219 ++++++++++++++++++ .../api/v1/compute-instance-catalog-item.ts | 48 +++- 7 files changed, 800 insertions(+), 4 deletions(-) create mode 100644 libs/ui-components/src/api/v1/baremetal-instance.test.ts create mode 100644 libs/ui-components/src/api/v1/cluster-catalog-item.test.ts create mode 100644 libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts diff --git a/libs/ui-components/src/api/types.ts b/libs/ui-components/src/api/types.ts index f3019ecc..f123b142 100644 --- a/libs/ui-components/src/api/types.ts +++ b/libs/ui-components/src/api/types.ts @@ -24,7 +24,10 @@ export type ApiRoute = | 'v1/baremetal_instance_catalog_items' | 'v1/baremetal_instances' | 'v1/public_ips' - | 'v1/public_ip_attachments'; + | 'v1/public_ip_attachments' + | 'v1/compute_instance_catalog_items_private' + | 'v1/cluster_catalog_items_private' + | 'v1/baremetal_instance_catalog_items_private'; /** * Strict 3-part tuple that encodes an API address. diff --git a/libs/ui-components/src/api/v1/baremetal-instance.test.ts b/libs/ui-components/src/api/v1/baremetal-instance.test.ts new file mode 100644 index 00000000..d2e77bc3 --- /dev/null +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -0,0 +1,219 @@ +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 type { BareMetalInstanceCatalogItem } from '@osac/types'; +import { BareMetalInstanceCatalogItems } from '@osac/types'; +import type { BareMetalInstanceCatalogItem as PrivateBareMetalInstanceCatalogItem } from '@osac/types/private'; +import { BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems } from '@osac/types/private'; + +import { + useAdminBareMetalInstanceCatalogItems, + useAdminSetBareMetalInstanceCatalogItemPublished, +} from './baremetal-instance'; +import { SessionProvider } from '../../hooks/use-session'; +import { ApiProvider } from '../api-context'; + +const publicItem: BareMetalInstanceCatalogItem = { + $typeName: 'osac.public.v1.BareMetalInstanceCatalogItem', + id: 'public-1', + title: 'Public bare metal item', + description: '', + template: '', + published: true, + fieldDefinitions: [], +}; + +const privateItem: PrivateBareMetalInstanceCatalogItem = { + $typeName: 'osac.private.v1.BareMetalInstanceCatalogItem', + id: 'private-1', + title: 'Private bare metal item', + description: '', + template: '', + published: true, + tenant: '', + fieldDefinitions: [], +}; + +const createTestTransport = (options: { + onPublicList?: () => void; + onPrivateList?: () => void; + onPublicUpdate?: (req: unknown) => void; + onPrivateUpdate?: (req: unknown) => void; +}) => + createRouterTransport((router) => { + router.service(BareMetalInstanceCatalogItems, { + list: () => { + options.onPublicList?.(); + return { items: [publicItem] }; + }, + update: (req) => { + options.onPublicUpdate?.(req); + return { object: publicItem }; + }, + }); + + router.service(PrivateBareMetalInstanceCatalogItems, { + list: () => { + options.onPrivateList?.(); + return { items: [privateItem] }; + }, + update: (req) => { + options.onPrivateUpdate?.(req); + return { object: privateItem }; + }, + }); + }); + +const renderWithSession = ( + hook: () => T, + role: 'providerAdmin' | 'tenantAdmin', + transport: ReturnType, +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => + createElement( + SessionProvider, + { role, username: 'test-user' } as React.ComponentProps, + createElement( + ApiProvider, + { transport } as React.ComponentProps, + createElement(QueryClientProvider, { client: queryClient }, children), + ), + ); + return { ...renderHook(hook, { wrapper }), queryClient }; +}; + +describe('useAdminBareMetalInstanceCatalogItems', () => { + it('calls the private List endpoint for providerAdmin', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminBareMetalInstanceCatalogItems(), + 'providerAdmin', + transport, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([privateItem]); + expect(privateListCalled).toBe(true); + expect(publicListCalled).toBe(false); + }); + + it('calls the public List endpoint for tenantAdmin', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminBareMetalInstanceCatalogItems(), + 'tenantAdmin', + transport, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([publicItem]); + expect(publicListCalled).toBe(true); + expect(privateListCalled).toBe(false); + }); + + it('does not call either endpoint when disabled', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + renderWithSession( + () => useAdminBareMetalInstanceCatalogItems({}, false), + 'providerAdmin', + transport, + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(privateListCalled).toBe(false); + expect(publicListCalled).toBe(false); + }); +}); + +describe('useAdminSetBareMetalInstanceCatalogItemPublished', () => { + it('sends the update with a published field mask to the private client for providerAdmin', async () => { + let lastReq: unknown; + const transport = createTestTransport({ + onPrivateUpdate: (req) => { + lastReq = req; + }, + }); + + const { result } = renderWithSession( + () => useAdminSetBareMetalInstanceCatalogItemPublished(), + 'providerAdmin', + transport, + ); + + act(() => { + result.current.mutate({ id: 'private-1', published: false }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lastReq).toMatchObject({ + object: { id: 'private-1', published: false }, + updateMask: { paths: ['published'] }, + }); + }); + + it('sends the update to the public client for tenantAdmin', async () => { + let lastReq: unknown; + let privateCalled = false; + const transport = createTestTransport({ + onPublicUpdate: (req) => { + lastReq = req; + }, + onPrivateUpdate: () => { + privateCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminSetBareMetalInstanceCatalogItemPublished(), + 'tenantAdmin', + transport, + ); + + act(() => { + result.current.mutate({ id: 'public-1', published: true }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lastReq).toMatchObject({ + object: { id: 'public-1', published: true }, + updateMask: { paths: ['published'] }, + }); + expect(privateCalled).toBe(false); + }); +}); diff --git a/libs/ui-components/src/api/v1/baremetal-instance.ts b/libs/ui-components/src/api/v1/baremetal-instance.ts index 104b134f..94493b31 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.ts @@ -7,9 +7,11 @@ import { BareMetalInstanceSchema, BareMetalInstances, } from '@osac/types'; +import { BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems } from '@osac/types/private'; +import { useSession } from '../../hooks/use-session'; import { useApiFetch } from '../api-context'; -import { apiQueryKey } from '../types'; +import { type ListParams, apiQueryKey } from '../types'; import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query'; export const useBareMetalInstances = () => { @@ -41,6 +43,54 @@ export const useBareMetalInstanceCatalogItems = (enabled = true) => { }); }; +/** + * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via + * the private API (including unpublished); Tenant Admin sees their tenant's items via the public API, + * which the server already scopes to the caller's tenant regardless of publication status. + */ +export const useAdminBareMetalInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + const publicClient = useApiFetch(BareMetalInstanceCatalogItems); + const publicResult = useApiQuery({ + queryKey: apiQueryKey('v1/baremetal_instance_catalog_items', undefined, params), + queryFn: () => publicClient.list(params), + select: (data) => data.items, + enabled: enabled && !isProviderAdmin, + }); + const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems); + const privateResult = useApiQuery({ + queryKey: apiQueryKey('v1/baremetal_instance_catalog_items_private', undefined, params), + queryFn: () => privateClient.list(params), + select: (data) => data.items, + enabled: enabled && isProviderAdmin, + }); + return isProviderAdmin ? privateResult : publicResult; +}; + +export const useAdminSetBareMetalInstanceCatalogItemPublished = () => { + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + const publicClient = useApiFetch(BareMetalInstanceCatalogItems); + const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: ({ id, published }: { id: string; published: boolean }): Promise => + (isProviderAdmin + ? privateClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) + : publicClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) + ).then(() => undefined), + onSuccess: () => + qc.invalidateQueries({ + queryKey: apiQueryKey( + isProviderAdmin + ? 'v1/baremetal_instance_catalog_items_private' + : 'v1/baremetal_instance_catalog_items', + ), + }), + }); +}; + export const invalidateBareMetalInstancesQueries = async (qc: ApiQueryClient) => { await qc.invalidateQueries({ queryKey: apiQueryKey('v1/baremetal_instances') }); }; 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 new file mode 100644 index 00000000..c69369f2 --- /dev/null +++ b/libs/ui-components/src/api/v1/cluster-catalog-item.test.ts @@ -0,0 +1,215 @@ +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 type { ClusterCatalogItem } from '@osac/types'; +import { ClusterCatalogItems } from '@osac/types'; +import type { ClusterCatalogItem as PrivateClusterCatalogItem } from '@osac/types/private'; +import { ClusterCatalogItems as PrivateClusterCatalogItems } from '@osac/types/private'; + +import { + useAdminClusterCatalogItems, + useAdminSetClusterCatalogItemPublished, +} from './cluster-catalog-item'; +import { SessionProvider } from '../../hooks/use-session'; +import { ApiProvider } from '../api-context'; + +const publicItem: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'public-1', + title: 'Public cluster item', + description: '', + template: '', + published: true, + fieldDefinitions: [], +}; + +const privateItem: PrivateClusterCatalogItem = { + $typeName: 'osac.private.v1.ClusterCatalogItem', + id: 'private-1', + title: 'Private cluster item', + description: '', + template: '', + published: true, + tenant: '', + fieldDefinitions: [], +}; + +const createTestTransport = (options: { + onPublicList?: () => void; + onPrivateList?: () => void; + onPublicUpdate?: (req: unknown) => void; + onPrivateUpdate?: (req: unknown) => void; +}) => + createRouterTransport((router) => { + router.service(ClusterCatalogItems, { + list: () => { + options.onPublicList?.(); + return { items: [publicItem] }; + }, + update: (req) => { + options.onPublicUpdate?.(req); + return { object: publicItem }; + }, + }); + + router.service(PrivateClusterCatalogItems, { + list: () => { + options.onPrivateList?.(); + return { items: [privateItem] }; + }, + update: (req) => { + options.onPrivateUpdate?.(req); + return { object: privateItem }; + }, + }); + }); + +const renderWithSession = ( + hook: () => T, + role: 'providerAdmin' | 'tenantAdmin', + transport: ReturnType, +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => + createElement( + SessionProvider, + { role, username: 'test-user' } as React.ComponentProps, + createElement( + ApiProvider, + { transport } as React.ComponentProps, + createElement(QueryClientProvider, { client: queryClient }, children), + ), + ); + return { ...renderHook(hook, { wrapper }), queryClient }; +}; + +describe('useAdminClusterCatalogItems', () => { + it('calls the private List endpoint for providerAdmin', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminClusterCatalogItems(), + 'providerAdmin', + transport, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([privateItem]); + expect(privateListCalled).toBe(true); + expect(publicListCalled).toBe(false); + }); + + it('calls the public List endpoint for tenantAdmin', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminClusterCatalogItems(), + 'tenantAdmin', + transport, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([publicItem]); + expect(publicListCalled).toBe(true); + expect(privateListCalled).toBe(false); + }); + + it('does not call either endpoint when disabled', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + renderWithSession(() => useAdminClusterCatalogItems({}, false), 'providerAdmin', transport); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(privateListCalled).toBe(false); + expect(publicListCalled).toBe(false); + }); +}); + +describe('useAdminSetClusterCatalogItemPublished', () => { + it('sends the update with a published field mask to the private client for providerAdmin', async () => { + let lastReq: unknown; + const transport = createTestTransport({ + onPrivateUpdate: (req) => { + lastReq = req; + }, + }); + + const { result } = renderWithSession( + () => useAdminSetClusterCatalogItemPublished(), + 'providerAdmin', + transport, + ); + + act(() => { + result.current.mutate({ id: 'private-1', published: false }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lastReq).toMatchObject({ + object: { id: 'private-1', published: false }, + updateMask: { paths: ['published'] }, + }); + }); + + it('sends the update to the public client for tenantAdmin', async () => { + let lastReq: unknown; + let privateCalled = false; + const transport = createTestTransport({ + onPublicUpdate: (req) => { + lastReq = req; + }, + onPrivateUpdate: () => { + privateCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminSetClusterCatalogItemPublished(), + 'tenantAdmin', + transport, + ); + + act(() => { + result.current.mutate({ id: 'public-1', published: true }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lastReq).toMatchObject({ + object: { id: 'public-1', published: true }, + updateMask: { paths: ['published'] }, + }); + expect(privateCalled).toBe(false); + }); +}); 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..e0344929 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,12 @@ +import { useMutation } from '@tanstack/react-query'; + import { ClusterCatalogItems } from '@osac/types'; +import { 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 +27,43 @@ export const useClusterCatalogItem = (id: string | undefined) => { enabled: Boolean(id), }); }; + +/** + * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via + * the private API (including unpublished); Tenant Admin sees their tenant's items via the public API, + * which the server already scopes to the caller's tenant regardless of publication status. + */ +export const useAdminClusterCatalogItems = (params: ListParams = {}, enabled = true) => { + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = useClusterCatalogItems(params, enabled && !isProviderAdmin); + const privateClient = useApiFetch(PrivateClusterCatalogItems); + const privateResult = useApiQuery({ + queryKey: apiQueryKey('v1/cluster_catalog_items_private', undefined, params), + queryFn: () => privateClient.list(params), + select: (data) => data.items, + enabled: enabled && isProviderAdmin, + }); + return isProviderAdmin ? privateResult : publicResult; +}; + +export const useAdminSetClusterCatalogItemPublished = () => { + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + const publicClient = useApiFetch(ClusterCatalogItems); + const privateClient = useApiFetch(PrivateClusterCatalogItems); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: ({ id, published }: { id: string; published: boolean }): Promise => + (isProviderAdmin + ? privateClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) + : publicClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) + ).then(() => undefined), + onSuccess: () => + qc.invalidateQueries({ + queryKey: apiQueryKey( + isProviderAdmin ? 'v1/cluster_catalog_items_private' : 'v1/cluster_catalog_items', + ), + }), + }); +}; 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 new file mode 100644 index 00000000..9da07469 --- /dev/null +++ b/libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts @@ -0,0 +1,219 @@ +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 type { ComputeInstanceCatalogItem } from '@osac/types'; +import { ComputeInstanceCatalogItems } from '@osac/types'; +import type { ComputeInstanceCatalogItem as PrivateComputeInstanceCatalogItem } from '@osac/types/private'; +import { ComputeInstanceCatalogItems as PrivateComputeInstanceCatalogItems } from '@osac/types/private'; + +import { + useAdminComputeInstanceCatalogItems, + useAdminSetComputeInstanceCatalogItemPublished, +} from './compute-instance-catalog-item'; +import { SessionProvider } from '../../hooks/use-session'; +import { ApiProvider } from '../api-context'; + +const publicItem: ComputeInstanceCatalogItem = { + $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', + id: 'public-1', + title: 'Public VM item', + description: '', + template: '', + published: true, + fieldDefinitions: [], +}; + +const privateItem: PrivateComputeInstanceCatalogItem = { + $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', + id: 'private-1', + title: 'Private VM item', + description: '', + template: '', + published: true, + tenant: '', + fieldDefinitions: [], +}; + +const createTestTransport = (options: { + onPublicList?: () => void; + onPrivateList?: () => void; + onPublicUpdate?: (req: unknown) => void; + onPrivateUpdate?: (req: unknown) => void; +}) => + createRouterTransport((router) => { + router.service(ComputeInstanceCatalogItems, { + list: () => { + options.onPublicList?.(); + return { items: [publicItem] }; + }, + update: (req) => { + options.onPublicUpdate?.(req); + return { object: publicItem }; + }, + }); + + router.service(PrivateComputeInstanceCatalogItems, { + list: () => { + options.onPrivateList?.(); + return { items: [privateItem] }; + }, + update: (req) => { + options.onPrivateUpdate?.(req); + return { object: privateItem }; + }, + }); + }); + +const renderWithSession = ( + hook: () => T, + role: 'providerAdmin' | 'tenantAdmin', + transport: ReturnType, +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => + createElement( + SessionProvider, + { role, username: 'test-user' } as React.ComponentProps, + createElement( + ApiProvider, + { transport } as React.ComponentProps, + createElement(QueryClientProvider, { client: queryClient }, children), + ), + ); + return { ...renderHook(hook, { wrapper }), queryClient }; +}; + +describe('useAdminComputeInstanceCatalogItems', () => { + it('calls the private List endpoint for providerAdmin', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminComputeInstanceCatalogItems(), + 'providerAdmin', + transport, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([privateItem]); + expect(privateListCalled).toBe(true); + expect(publicListCalled).toBe(false); + }); + + it('calls the public List endpoint for tenantAdmin', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminComputeInstanceCatalogItems(), + 'tenantAdmin', + transport, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([publicItem]); + expect(publicListCalled).toBe(true); + expect(privateListCalled).toBe(false); + }); + + it('does not call either endpoint when disabled', async () => { + let privateListCalled = false; + let publicListCalled = false; + const transport = createTestTransport({ + onPrivateList: () => { + privateListCalled = true; + }, + onPublicList: () => { + publicListCalled = true; + }, + }); + + renderWithSession( + () => useAdminComputeInstanceCatalogItems({}, false), + 'providerAdmin', + transport, + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(privateListCalled).toBe(false); + expect(publicListCalled).toBe(false); + }); +}); + +describe('useAdminSetComputeInstanceCatalogItemPublished', () => { + it('sends the update with a published field mask to the private client for providerAdmin', async () => { + let lastReq: unknown; + const transport = createTestTransport({ + onPrivateUpdate: (req) => { + lastReq = req; + }, + }); + + const { result } = renderWithSession( + () => useAdminSetComputeInstanceCatalogItemPublished(), + 'providerAdmin', + transport, + ); + + act(() => { + result.current.mutate({ id: 'private-1', published: false }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lastReq).toMatchObject({ + object: { id: 'private-1', published: false }, + updateMask: { paths: ['published'] }, + }); + }); + + it('sends the update to the public client for tenantAdmin', async () => { + let lastReq: unknown; + let privateCalled = false; + const transport = createTestTransport({ + onPublicUpdate: (req) => { + lastReq = req; + }, + onPrivateUpdate: () => { + privateCalled = true; + }, + }); + + const { result } = renderWithSession( + () => useAdminSetComputeInstanceCatalogItemPublished(), + 'tenantAdmin', + transport, + ); + + act(() => { + result.current.mutate({ id: 'public-1', published: true }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lastReq).toMatchObject({ + object: { id: 'public-1', published: true }, + updateMask: { paths: ['published'] }, + }); + expect(privateCalled).toBe(false); + }); +}); 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..293444cb 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,12 @@ +import { useMutation } from '@tanstack/react-query'; + import { ComputeInstanceCatalogItems } from '@osac/types'; +import { 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 +28,45 @@ export const useComputeInstanceCatalogItem = (id: string | undefined) => { enabled: Boolean(trimmedId), }); }; + +/** + * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via + * the private API (including unpublished); Tenant Admin sees their tenant's items via the public API, + * which the server already scopes to the caller's tenant regardless of publication status. + */ +export const useAdminComputeInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = useComputeInstanceCatalogItems(params, enabled && !isProviderAdmin); + const privateClient = useApiFetch(PrivateComputeInstanceCatalogItems); + const privateResult = useApiQuery({ + queryKey: apiQueryKey('v1/compute_instance_catalog_items_private', undefined, params), + queryFn: () => privateClient.list(params), + select: (data) => data.items, + enabled: enabled && isProviderAdmin, + }); + return isProviderAdmin ? privateResult : publicResult; +}; + +export const useAdminSetComputeInstanceCatalogItemPublished = () => { + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + const publicClient = useApiFetch(ComputeInstanceCatalogItems); + const privateClient = useApiFetch(PrivateComputeInstanceCatalogItems); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: ({ id, published }: { id: string; published: boolean }): Promise => + (isProviderAdmin + ? privateClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) + : publicClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) + ).then(() => undefined), + onSuccess: () => + qc.invalidateQueries({ + queryKey: apiQueryKey( + isProviderAdmin + ? 'v1/compute_instance_catalog_items_private' + : 'v1/compute_instance_catalog_items', + ), + }), + }); +}; From 192cb3f9e6a29d9fd08ab388db244b67ba6b8f67 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Thu, 23 Jul 2026 16:42:53 +0300 Subject: [PATCH 04/12] OSAC-2932: add CatalogItemScopeBadge, CatalogItemStatusLabel, CatalogItemPublishToggle components Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 5 ++ .../CatalogItemPublishToggle.test.tsx | 54 +++++++++++++++++++ .../CatalogItemPublishToggle.tsx | 31 +++++++++++ .../CatalogItemScopeBadge.test.tsx | 33 ++++++++++++ .../CatalogItemScopeBadge.tsx | 31 +++++++++++ .../CatalogItemStatusLabel.test.tsx | 19 +++++++ .../CatalogItemStatusLabel.tsx | 19 +++++++ 7 files changed, 192 insertions(+) create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.tsx diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 62a3bd47..00755a26 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -202,6 +202,8 @@ "Nodes": "Nodes", "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: {{name}}": "Organization: {{name}}", "Outbound Rules": "Outbound Rules", "Overview": "Overview", "Parent virtual network": "Parent virtual network", @@ -218,6 +220,7 @@ "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: {{name}}": "Project: {{name}}", "Protocol": "Protocol", "Protocol is required": "Protocol is required", "Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.", @@ -226,6 +229,7 @@ "Provisioning failed": "Provisioning failed", "Public IP": "Public IP", "Public SSH key is required": "Public SSH key is required", + "Published": "Published", "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).", @@ -270,6 +274,7 @@ "UDP": "UDP", "Unauthorized": "Unauthorized", "Unknown": "Unknown", + "Unpublished": "Unpublished", "Use IPv4 CIDR notation (for example 10.128.0.0/14).": "Use IPv4 CIDR notation (for example 10.128.0.0/14).", "Use IPv4 CIDR notation (for example 172.30.0.0/16).": "Use IPv4 CIDR notation (for example 172.30.0.0/16).", "User data": "User data", diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx new file mode 100644 index 00000000..661a5ef7 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx @@ -0,0 +1,54 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import CatalogItemPublishToggle from './CatalogItemPublishToggle'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +describe('CatalogItemPublishToggle', () => { + it('renders as checked when published', () => { + renderWithProviders( {}} />); + expect(screen.getByRole('switch')).toBeChecked(); + }); + + it('renders as unchecked when not published', () => { + renderWithProviders( {}} />); + expect(screen.getByRole('switch')).not.toBeChecked(); + }); + + it('calls onChange with the flipped value when toggled', async () => { + const onChange = vi.fn(); + const { user } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('switch')); + + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('does not call onChange when disabled', async () => { + const onChange = vi.fn(); + const { user } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('switch')); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not propagate its click event to an ancestor element', async () => { + const onAncestorClick = vi.fn(); + const onChange = vi.fn(); + const { user } = renderWithProviders( +
+ +
, + ); + + await user.click(screen.getByRole('switch')); + + expect(onChange).toHaveBeenCalledWith(false); + expect(onAncestorClick).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx new file mode 100644 index 00000000..5101bba5 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx @@ -0,0 +1,31 @@ +import { Switch } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +interface CatalogItemPublishToggleProps { + published: boolean; + isDisabled?: boolean; + onChange: (published: boolean) => void; +} + +const CatalogItemPublishToggle = ({ + published, + isDisabled, + onChange, +}: CatalogItemPublishToggleProps) => { + const { t } = useTranslation(); + + return ( + // Stops the toggle's click from bubbling to an ancestor card's click-to-navigate handler. + event.stopPropagation()}> + onChange(checked)} + /> + + ); +}; + +export default CatalogItemPublishToggle; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.test.tsx new file mode 100644 index 00000000..e0f9d2a0 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.test.tsx @@ -0,0 +1,33 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import CatalogItemScopeBadge from './CatalogItemScopeBadge'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +describe('CatalogItemScopeBadge', () => { + it('renders "General" in blue for general scope', () => { + renderWithProviders(); + const label = screen.getByText('General'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-blue'); + }); + + it('renders "Organization: {name}" in purple when a tenant name is known', () => { + renderWithProviders( + , + ); + const label = screen.getByText('Organization: acme-corp'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-purple'); + }); + + it('renders plain "Organization" in purple when no tenant name is known', () => { + renderWithProviders(); + const label = screen.getByText('Organization'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-purple'); + }); + + it('renders "Project: {name}" in teal for project scope', () => { + renderWithProviders(); + const label = screen.getByText('Project: frontend'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-teal'); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx new file mode 100644 index 00000000..99194ec0 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx @@ -0,0 +1,31 @@ +import { Label } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; +import type { CatalogItemScope } from '../catalog/catalogItemDisplay'; + +interface CatalogItemScopeBadgeProps { + scope: CatalogItemScope; +} + +const CatalogItemScopeBadge = ({ scope }: CatalogItemScopeBadgeProps) => { + const { t } = useTranslation(); + + switch (scope.level) { + case 'general': + return ; + case 'organization': + return ( + + ); + case 'project': + return ; + default: { + const exhaustiveCheck: never = scope; + return exhaustiveCheck; + } + } +}; + +export default CatalogItemScopeBadge; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.test.tsx new file mode 100644 index 00000000..bc4b622c --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.test.tsx @@ -0,0 +1,19 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import CatalogItemStatusLabel from './CatalogItemStatusLabel'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +describe('CatalogItemStatusLabel', () => { + it('renders "Published" in green when published', () => { + renderWithProviders(); + const label = screen.getByText('Published'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-green'); + }); + + it('renders "Unpublished" in grey when not published', () => { + renderWithProviders(); + const label = screen.getByText('Unpublished'); + expect(label.closest('.pf-v6-c-label')).not.toHaveClass('pf-m-green'); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.tsx new file mode 100644 index 00000000..d721561e --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.tsx @@ -0,0 +1,19 @@ +import { Label } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +interface CatalogItemStatusLabelProps { + published: boolean; +} + +const CatalogItemStatusLabel = ({ published }: CatalogItemStatusLabelProps) => { + const { t } = useTranslation(); + + return published ? ( + + ) : ( + + ); +}; + +export default CatalogItemStatusLabel; From 2218b091a67d7fc61c4637c0d0dcc59342f38f7e Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Thu, 23 Jul 2026 16:57:54 +0300 Subject: [PATCH 05/12] OSAC-2932: add scope badge, status label, and publish toggle slots to CatalogItemCard Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../components/catalog/CatalogItemCard.css | 9 +++ .../catalog/CatalogItemCard.test.tsx | 79 +++++++++++++++++++ .../components/catalog/CatalogItemCard.tsx | 37 +++++++-- .../catalog/CatalogItemListSection.test.tsx | 46 +++++++++++ .../catalog/CatalogItemListSection.tsx | 32 +++++--- 5 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 libs/ui-components/src/components/catalog/CatalogItemCard.css create mode 100644 libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx create mode 100644 libs/ui-components/src/components/catalog/CatalogItemListSection.test.tsx diff --git a/libs/ui-components/src/components/catalog/CatalogItemCard.css b/libs/ui-components/src/components/catalog/CatalogItemCard.css new file mode 100644 index 00000000..45f38f66 --- /dev/null +++ b/libs/ui-components/src/components/catalog/CatalogItemCard.css @@ -0,0 +1,9 @@ +/* PatternFly's clickable Card (.pf-m-clickable) sets isolation: isolate, scoping z-index + comparisons to the card. Its full-card clickable overlay is position: absolute with + z-index: auto, which paints above non-positioned content regardless of DOM order — so without + this, the toggle would be visually present but unclickable (clicks would hit the overlay and + navigate instead of toggling). An explicit z-index here outranks the overlay's z-index: auto. */ +.catalog-item-card__publish-toggle { + position: relative; + z-index: 1; +} diff --git a/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx b/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx new file mode 100644 index 00000000..e801f7a5 --- /dev/null +++ b/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx @@ -0,0 +1,79 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; + +import CatalogItemCard from './CatalogItemCard'; +import { renderWithProviders } from '../../test-utils/TestProviders'; +import CatalogItemPublishToggle from '../catalogManagement/CatalogItemPublishToggle'; +import CatalogItemScopeBadge from '../catalogManagement/CatalogItemScopeBadge'; +import CatalogItemStatusLabel from '../catalogManagement/CatalogItemStatusLabel'; + +const item: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: 'Standard OpenShift cluster offering', + template: '', + published: true, + fieldDefinitions: [], +}; + +describe('CatalogItemCard', () => { + it('omits scope badge, status label, and publish toggle by default (tenant mode)', () => { + renderWithProviders( {}} />); + expect(screen.queryByText('General')).not.toBeInTheDocument(); + expect(screen.queryByText('Published')).not.toBeInTheDocument(); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + }); + + it('renders scope badge, status label, and publish toggle when provided (admin mode)', () => { + renderWithProviders( + {}} + scopeBadge={} + statusLabel={} + publishToggle={ {}} />} + />, + ); + expect(screen.getByText('General')).toBeInTheDocument(); + // "Published" appears twice: once from the status label, once as the switch's own accessible label. + expect(screen.getAllByText('Published')).toHaveLength(2); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('still navigates to details when the card is clicked (regression)', async () => { + const onOpenDetails = vi.fn(); + const { user } = renderWithProviders( + {}} />} + />, + ); + + await user.click( + screen.getByRole('button', { name: `Open catalog item details for ${item.title}` }), + ); + + expect(onOpenDetails).toHaveBeenCalled(); + }); + + it('does not navigate to details when the publish toggle is clicked', async () => { + const onOpenDetails = vi.fn(); + const onTogglePublished = vi.fn(); + const { user } = renderWithProviders( + } + />, + ); + + await user.click(screen.getByRole('switch')); + + expect(onTogglePublished).toHaveBeenCalledWith(false); + expect(onOpenDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui-components/src/components/catalog/CatalogItemCard.tsx b/libs/ui-components/src/components/catalog/CatalogItemCard.tsx index 147cb3a2..9d932b0c 100644 --- a/libs/ui-components/src/components/catalog/CatalogItemCard.tsx +++ b/libs/ui-components/src/components/catalog/CatalogItemCard.tsx @@ -21,6 +21,8 @@ import { import { useTranslation } from '../../hooks/useTranslation'; import { CatalogItemIcon } from '../../icons'; +import './CatalogItemCard.css'; + export interface CatalogItemCardSelection { selected: boolean; radioName: string; @@ -33,6 +35,9 @@ interface CatalogItemCardProps { selection?: CatalogItemCardSelection; onOpenDetails?: () => void; isSelected?: boolean; + scopeBadge?: React.ReactNode; + statusLabel?: React.ReactNode; + publishToggle?: React.ReactNode; } const CatalogItemCard = ({ @@ -41,6 +46,9 @@ const CatalogItemCard = ({ selection, onOpenDetails, isSelected, + scopeBadge, + statusLabel, + publishToggle, }: CatalogItemCardProps) => { const { t } = useTranslation(); const resources = catalogItemResourceParts(item); @@ -86,13 +94,24 @@ const CatalogItemCard = ({ : undefined } > - + - - - - {item.title} + + + + + + {item.title} + + + {publishToggle ? ( + {publishToggle} + ) : null} @@ -103,6 +122,14 @@ const CatalogItemCard = ({ {subtitle} + {scopeBadge || statusLabel ? ( + + + {scopeBadge ? {scopeBadge} : null} + {statusLabel ? {statusLabel} : null} + + + ) : null} {resources.length > 0 ? ( diff --git a/libs/ui-components/src/components/catalog/CatalogItemListSection.test.tsx b/libs/ui-components/src/components/catalog/CatalogItemListSection.test.tsx new file mode 100644 index 00000000..58babb42 --- /dev/null +++ b/libs/ui-components/src/components/catalog/CatalogItemListSection.test.tsx @@ -0,0 +1,46 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; + +import { CatalogItemListSection } from './CatalogItemListSection'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const items: ClusterCatalogItem[] = [ + { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: '', + template: '', + published: true, + fieldDefinitions: [], + }, +]; + +describe('CatalogItemListSection', () => { + it('renders no addons when renderCardAddons is omitted (tenant mode)', () => { + renderWithProviders( + {}} />, + ); + expect(screen.queryByText('addon-marker')).not.toBeInTheDocument(); + }); + + it('threads renderCardAddons output through to each card', () => { + renderWithProviders( + {}} + renderCardAddons={() => ({ + scopeBadge: scope-marker, + statusLabel: status-marker, + publishToggle: toggle-marker, + })} + />, + ); + expect(screen.getByText('scope-marker')).toBeInTheDocument(); + expect(screen.getByText('status-marker')).toBeInTheDocument(); + expect(screen.getByText('toggle-marker')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx b/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx index 1ca46a11..406e2196 100644 --- a/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx +++ b/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx @@ -13,6 +13,12 @@ import type { CatalogItem } from './catalogItemDisplay'; import { getErrorMessage } from '../../utils/error'; import QueryErrorState from '../Resource/QueryErrorState'; +interface CatalogItemCardAddons { + scopeBadge?: React.ReactNode; + statusLabel?: React.ReactNode; + publishToggle?: React.ReactNode; +} + interface CatalogItemListSectionProps { title: string; items: CatalogItem[]; @@ -20,6 +26,7 @@ interface CatalogItemListSectionProps { onSelectItem: (item: CatalogItem) => void; isLoading?: boolean; error?: unknown; + renderCardAddons?: (item: CatalogItem) => CatalogItemCardAddons; } export const CatalogItemListSection = ({ @@ -29,6 +36,7 @@ export const CatalogItemListSection = ({ onSelectItem, isLoading = false, error = null, + renderCardAddons, }: CatalogItemListSectionProps) => { if (!isLoading && !error && items.length === 0) { return null; @@ -57,15 +65,21 @@ export const CatalogItemListSection = ({ {items.length > 0 ? ( - {items.map((item) => ( - - onSelectItem(item)} - /> - - ))} + {items.map((item) => { + const addons = renderCardAddons?.(item); + return ( + + onSelectItem(item)} + scopeBadge={addons?.scopeBadge} + statusLabel={addons?.statusLabel} + publishToggle={addons?.publishToggle} + /> + + ); + })} ) : null} From 3d28c4c4fc67764a779a44222ed0a3c4f4df1fc1 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Thu, 23 Jul 2026 17:29:19 +0300 Subject: [PATCH 06/12] OSAC-2932: add CatalogManagementListPage with resource type tabs Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/shell/AdminCatalogRoutes.tsx | 14 +- libs/i18n/locales/en/translation.json | 4 + .../admin/CatalogManagementListPage.test.tsx | 283 ++++++++++++++++++ .../pages/admin/CatalogManagementListPage.tsx | 90 ++++++ .../pages/admin/CatalogManagementTabPanel.tsx | 169 +++++++++++ 5 files changed, 548 insertions(+), 12 deletions(-) create mode 100644 libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx create mode 100644 libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx create mode 100644 libs/ui-components/src/pages/admin/CatalogManagementTabPanel.tsx diff --git a/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx b/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx index 3ab8d232..fad49d9f 100644 --- a/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx +++ b/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx @@ -1,21 +1,11 @@ import { Route, Routes } from 'react-router-dom'; -import ListPage from '@osac/ui-components/components/Page/ListPage'; -import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import CatalogManagementListPage from '@osac/ui-components/pages/admin/CatalogManagementListPage'; export const AdminCatalogRoutes = () => { - const { t } = useTranslation(); - return ( - -
- - } - /> + } /> } /> } /> } /> diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 00755a26..ef5b39df 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -23,6 +23,7 @@ "Catalog configuration is unavailable for this virtual machine.": "Catalog configuration is unavailable for this virtual machine.", "Catalog item": "Catalog item", "Catalog management": "Catalog management", + "Catalog management resource type tabs": "Catalog management resource type tabs", "catalogProvision.actions.back": "Back", "catalogProvision.actions.cancel": "Cancel", "catalogProvision.actions.create": "Create", @@ -146,6 +147,7 @@ "Failed to load security groups": "Failed to load security groups", "Failed to load subnets": "Failed to load subnets", "Filter bare metal instances by name": "Filter bare metal instances by name", + "Filter by publication status": "Filter by publication status", "Filter catalog by keyword": "Filter catalog by keyword", "Filter catalog by resource type": "Filter catalog by resource type", "Fixed": "Fixed", @@ -184,6 +186,8 @@ "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", + "No catalog items have been created yet.": "No catalog items have been created yet.", + "No catalog items match your search or filter.": "No catalog items match your search or filter.", "No catalog items match your search.": "No catalog items match your search.", "No inbound rules yet. Add one to allow incoming traffic.": "No inbound rules yet. Add one to allow incoming traffic.", "No node sets added yet.": "No node sets added yet.", diff --git a/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx b/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx new file mode 100644 index 00000000..8817e8a9 --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx @@ -0,0 +1,283 @@ +import { Route, Routes } from 'react-router-dom'; +import { createRouterTransport } from '@connectrpc/connect'; +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ClusterCatalogItem, ComputeInstanceCatalogItem } from '@osac/types'; +import { + BareMetalInstanceCatalogItems, + ClusterCatalogItems, + ComputeInstanceCatalogItems, +} from '@osac/types'; +import type { + ClusterCatalogItem as PrivateClusterCatalogItem, + ComputeInstanceCatalogItem as PrivateComputeInstanceCatalogItem, +} from '@osac/types/private'; +import { + BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems, + ClusterCatalogItems as PrivateClusterCatalogItems, + ComputeInstanceCatalogItems as PrivateComputeInstanceCatalogItems, +} from '@osac/types/private'; + +import CatalogManagementListPage from './CatalogManagementListPage'; +import { SessionProvider } from '../../hooks/use-session'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const privateClusterItem: PrivateClusterCatalogItem = { + $typeName: 'osac.private.v1.ClusterCatalogItem', + id: 'cluster-private-1', + title: 'OpenShift 4 cluster', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +const publicClusterItem: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'cluster-public-1', + title: 'Shared OpenShift cluster', + description: '', + template: '', + published: true, + fieldDefinitions: [], + metadata: { + $typeName: 'osac.public.v1.Metadata', + name: 'shared-cluster', + annotations: {}, + creator: 'admin', + labels: {}, + project: '', + tenant: 'shared', + version: 1, + }, +}; + +const publicUnpublishedVmItem: ComputeInstanceCatalogItem = { + $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', + id: 'vm-public-1', + title: 'Fedora workstation', + description: '', + template: '', + published: false, + fieldDefinitions: [], + metadata: { + $typeName: 'osac.public.v1.Metadata', + name: 'fedora', + annotations: {}, + creator: 'tenant-admin', + labels: {}, + project: '', + tenant: 'acme-corp', + version: 1, + }, +}; + +const privateVmItem: PrivateComputeInstanceCatalogItem = { + $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', + id: 'vm-private-1', + title: 'RHEL 9 workstation', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +const emptyList = () => ({ items: [] }); + +const createTestTransport = (options: { onUpdate?: (req: unknown) => void } = {}) => + createRouterTransport((router) => { + router.service(PrivateClusterCatalogItems, { + list: () => ({ items: [privateClusterItem] }), + update: (req) => { + options.onUpdate?.(req); + return { object: privateClusterItem }; + }, + }); + router.service(ClusterCatalogItems, { + list: () => ({ items: [publicClusterItem] }), + update: (req) => { + options.onUpdate?.(req); + return { object: publicClusterItem }; + }, + }); + router.service(PrivateComputeInstanceCatalogItems, { + list: () => ({ items: [privateVmItem] }), + }); + router.service(ComputeInstanceCatalogItems, { + list: () => ({ items: [publicUnpublishedVmItem] }), + update: (req) => { + options.onUpdate?.(req); + return { object: publicUnpublishedVmItem }; + }, + }); + router.service(PrivateBareMetalInstanceCatalogItems, { list: emptyList }); + router.service(BareMetalInstanceCatalogItems, { list: emptyList }); + }); + +const renderPage = (role: 'providerAdmin' | 'tenantAdmin', transport = createTestTransport()) => + renderWithProviders( + + + } /> + create-page
} /> + detail-page} /> +
+ , + { transport, routerEntries: ['/admin/catalog'] }, + ); + +describe('CatalogManagementListPage', () => { + it('renders the three resource type tabs', () => { + renderPage('providerAdmin'); + expect(screen.getByRole('tab', { name: 'Clusters' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Virtual Machines' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Bare Metal' })).toBeInTheDocument(); + }); + + it('shows the CSP Admin (private API) items with an organization scope badge on the default tab', async () => { + renderPage('providerAdmin'); + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + expect(screen.getByText('Organization: acme-corp')).toBeInTheDocument(); + }); + + it('shows the Tenant Admin (public API) items on the default tab', async () => { + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText(publicClusterItem.title)).toBeInTheDocument(); + }); + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('switches tabs and shows the newly active tab items', async () => { + const { user } = renderPage('tenantAdmin'); + + await waitFor(() => { + expect(screen.getByText(publicClusterItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('tab', { name: 'Virtual Machines' })); + + await waitFor(() => { + expect(screen.getByText(publicUnpublishedVmItem.title)).toBeInTheDocument(); + }); + }); + + it('shows an empty state on a tab with no catalog items', async () => { + const { user } = renderPage('providerAdmin'); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('tab', { name: 'Bare Metal' })); + + await waitFor(() => { + expect( + screen.getByRole('heading', { name: 'No catalog items found', level: 2 }), + ).toBeInTheDocument(); + }); + expect(screen.getByText('No catalog items have been created yet.')).toBeInTheDocument(); + }); + + it('filters items by search keyword', async () => { + const { user } = renderPage('providerAdmin'); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.type( + screen.getByRole('textbox', { name: 'Filter catalog by keyword' }), + 'no-such-item', + ); + + await waitFor(() => { + expect(screen.queryByText(privateClusterItem.title)).not.toBeInTheDocument(); + }); + }); + + it('filters items by publication status', async () => { + const { user } = renderPage('tenantAdmin'); + + await user.click(screen.getByRole('tab', { name: 'Virtual Machines' })); + await waitFor(() => { + expect(screen.getByText(publicUnpublishedVmItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Published' })); + + await waitFor(() => { + expect(screen.queryByText(publicUnpublishedVmItem.title)).not.toBeInTheDocument(); + }); + }); + + it('navigates to the kind-specific create route when Create is clicked', async () => { + const { user } = renderPage('providerAdmin'); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('create-page')).toBeInTheDocument(); + }); + }); + + it('navigates to the detail route when a card is clicked', async () => { + const { user } = renderPage('providerAdmin'); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole('button', { + name: `Open catalog item details for ${privateClusterItem.title}`, + }), + ); + + await waitFor(() => { + expect(screen.getByText('detail-page')).toBeInTheDocument(); + }); + }); + + it('disables the publish toggle for a Tenant Admin viewing a general item', async () => { + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText(publicClusterItem.title)).toBeInTheDocument(); + }); + expect(screen.getByRole('switch')).toBeDisabled(); + }); + + it('sends an update when the publish toggle is used by a CSP Admin', async () => { + let lastReq: unknown; + const { user } = renderPage( + 'providerAdmin', + createTestTransport({ + onUpdate: (req) => { + lastReq = req; + }, + }), + ); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(lastReq).toMatchObject({ + object: { id: privateClusterItem.id, published: false }, + updateMask: { paths: ['published'] }, + }); + }); + }); +}); diff --git a/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx b/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx new file mode 100644 index 00000000..05245e23 --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Tab, TabTitleText, Tabs } from '@patternfly/react-core'; + +import { + useAdminBareMetalInstanceCatalogItems, + useAdminSetBareMetalInstanceCatalogItemPublished, +} from '@osac/ui-components/api/v1/baremetal-instance'; +import { + useAdminClusterCatalogItems, + useAdminSetClusterCatalogItemPublished, +} from '@osac/ui-components/api/v1/cluster-catalog-item'; +import { + useAdminComputeInstanceCatalogItems, + useAdminSetComputeInstanceCatalogItemPublished, +} from '@osac/ui-components/api/v1/compute-instance-catalog-item'; +import ListPage from '@osac/ui-components/components/Page/ListPage'; +import { useSession } from '@osac/ui-components/hooks/use-session'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; + +import CatalogManagementTabPanel, { + type CatalogManagementTabKey, + type PublicationFilter, +} from './CatalogManagementTabPanel'; + +const CatalogManagementListPage = () => { + const { t } = useTranslation(); + const { role } = useSession(); + const [activeTab, setActiveTab] = useState('cluster'); + const [search, setSearch] = useState(''); + const [publicationFilter, setPublicationFilter] = useState('all'); + + const clusterItems = useAdminClusterCatalogItems(undefined, activeTab === 'cluster'); + const computeInstanceItems = useAdminComputeInstanceCatalogItems( + undefined, + activeTab === 'compute-instance', + ); + const bareMetalItems = useAdminBareMetalInstanceCatalogItems( + undefined, + activeTab === 'baremetal-instance', + ); + + const setClusterPublished = useAdminSetClusterCatalogItemPublished(); + const setComputeInstancePublished = useAdminSetComputeInstanceCatalogItemPublished(); + const setBareMetalPublished = useAdminSetBareMetalInstanceCatalogItemPublished(); + + const sharedPanelProps = { search, setSearch, publicationFilter, setPublicationFilter, role }; + + return ( + + setActiveTab(eventKey as CatalogManagementTabKey)} + aria-label={t('Catalog management resource type tabs')} + > + {t('Clusters')}}> + + + {t('Virtual Machines')}} + > + + + {t('Bare Metal')}}> + + + + + ); +}; + +export default CatalogManagementListPage; diff --git a/libs/ui-components/src/pages/admin/CatalogManagementTabPanel.tsx b/libs/ui-components/src/pages/admin/CatalogManagementTabPanel.tsx new file mode 100644 index 00000000..88bd17e5 --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogManagementTabPanel.tsx @@ -0,0 +1,169 @@ +import { useNavigate } from 'react-router-dom'; +import { + Button, + EmptyState, + EmptyStateBody, + Flex, + FlexItem, + SearchInput, + Stack, + StackItem, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core'; +import type { UseQueryResult } from '@tanstack/react-query'; + +import type { CatalogItem } from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import { + catalogItemScope, + filterCatalogItemsBySearch, +} from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import { CatalogItemListSection } from '@osac/ui-components/components/catalog/CatalogItemListSection'; +import CatalogItemPublishToggle from '@osac/ui-components/components/catalogManagement/CatalogItemPublishToggle'; +import CatalogItemScopeBadge from '@osac/ui-components/components/catalogManagement/CatalogItemScopeBadge'; +import CatalogItemStatusLabel from '@osac/ui-components/components/catalogManagement/CatalogItemStatusLabel'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import type { DemoShellRole } from '@osac/ui-components/shellTypes'; + +export type CatalogManagementTabKey = 'cluster' | 'compute-instance' | 'baremetal-instance'; +export type PublicationFilter = 'all' | 'published' | 'unpublished'; + +const matchesPublicationFilter = (item: CatalogItem, filter: PublicationFilter): boolean => { + if (filter === 'published') { + return item.published; + } + if (filter === 'unpublished') { + return !item.published; + } + return true; +}; + +interface CatalogManagementTabPanelProps { + tabKey: CatalogManagementTabKey; + title: string; + result: UseQueryResult; + setPublished: (input: { id: string; published: boolean }) => void; + search: string; + setSearch: (value: string) => void; + publicationFilter: PublicationFilter; + setPublicationFilter: (value: PublicationFilter) => void; + role: DemoShellRole; +} + +const CatalogManagementTabPanel = ({ + tabKey, + title, + result, + setPublished, + search, + setSearch, + publicationFilter, + setPublicationFilter, + role, +}: CatalogManagementTabPanelProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { data = [], isLoading, error } = result; + + const filteredItems = filterCatalogItemsBySearch(data, search).filter((item) => + matchesPublicationFilter(item, publicationFilter), + ); + + const publicationFilters: ReadonlyArray<{ value: PublicationFilter; label: string }> = [ + { value: 'all', label: t('All') }, + { value: 'published', label: t('Published') }, + { value: 'unpublished', label: t('Unpublished') }, + ]; + + const isFiltered = search.trim().length > 0 || publicationFilter !== 'all'; + // `result.isSuccess` (not just `!isLoading`) guards against a disabled, not-yet-fetched query on + // an inactive tab — those report `isLoading: false` with no data, which would otherwise show this + // tab as empty before it has ever actually fetched. + const showEmptyState = result.isSuccess && !error && filteredItems.length === 0; + + return ( + + + + + + + setSearch(value)} + onClear={() => setSearch('')} + aria-label={t('Filter catalog by keyword')} + isDisabled={isLoading || !!error} + /> + + + + {publicationFilters.map((option) => ( + setPublicationFilter(option.value)} + /> + ))} + + + + + + + + + + {showEmptyState ? ( + + + + {isFiltered + ? t('No catalog items match your search or filter.') + : t('No catalog items have been created yet.')} + + + + ) : ( + navigate(`/admin/catalog/${tabKey}/${item.id}`)} + renderCardAddons={(item) => { + const scope = catalogItemScope(item, role); + const isToggleDisabled = role === 'tenantAdmin' && scope.level === 'general'; + return { + scopeBadge: , + statusLabel: , + publishToggle: ( + setPublished({ id: item.id, published })} + /> + ), + }; + }} + /> + )} + + ); +}; + +export default CatalogManagementTabPanel; From c71dc9591df57dea1c0867e17cdec86c0d25ffcc Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Thu, 23 Jul 2026 17:35:41 +0300 Subject: [PATCH 07/12] OSAC-2932: fix prettier formatting in admin catalog hook tests Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/ui-components/src/api/v1/baremetal-instance.test.ts | 2 +- libs/ui-components/src/api/v1/cluster-catalog-item.test.ts | 2 +- .../src/api/v1/compute-instance-catalog-item.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 d2e77bc3..ecc8bbac 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -67,7 +67,7 @@ const createTestTransport = (options: { }); }); -const renderWithSession = ( +const renderWithSession = ( hook: () => T, role: 'providerAdmin' | 'tenantAdmin', transport: ReturnType, 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 c69369f2..d6cafe62 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 @@ -67,7 +67,7 @@ const createTestTransport = (options: { }); }); -const renderWithSession = ( +const renderWithSession = ( hook: () => T, role: 'providerAdmin' | 'tenantAdmin', transport: ReturnType, 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 9da07469..c851d3dd 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 @@ -67,7 +67,7 @@ const createTestTransport = (options: { }); }); -const renderWithSession = ( +const renderWithSession = ( hook: () => T, role: 'providerAdmin' | 'tenantAdmin', transport: ReturnType, From 9402235c931f428b9944723ff518ce566c00d648 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Sun, 26 Jul 2026 15:54:47 +0300 Subject: [PATCH 08/12] OSAC-2932: address cross-cutting review findings Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../ui-components/src/api/v1/baremetal-instance.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/libs/ui-components/src/api/v1/baremetal-instance.ts b/libs/ui-components/src/api/v1/baremetal-instance.ts index 94493b31..c75e40be 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.ts @@ -33,11 +33,11 @@ export const useBareMetalInstance = (id: string) => { }); }; -export const useBareMetalInstanceCatalogItems = (enabled = true) => { +export const useBareMetalInstanceCatalogItems = (enabled = true, params: ListParams = {}) => { const client = useApiFetch(BareMetalInstanceCatalogItems); return useApiQuery({ - queryKey: apiQueryKey('v1/baremetal_instance_catalog_items'), - queryFn: () => client.list({}), + queryKey: apiQueryKey('v1/baremetal_instance_catalog_items', undefined, params), + queryFn: () => client.list(params), select: (data) => data.items, enabled, }); @@ -51,13 +51,7 @@ export const useBareMetalInstanceCatalogItems = (enabled = true) => { export const useAdminBareMetalInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { const { role } = useSession(); const isProviderAdmin = role === 'providerAdmin'; - const publicClient = useApiFetch(BareMetalInstanceCatalogItems); - const publicResult = useApiQuery({ - queryKey: apiQueryKey('v1/baremetal_instance_catalog_items', undefined, params), - queryFn: () => publicClient.list(params), - select: (data) => data.items, - enabled: enabled && !isProviderAdmin, - }); + const publicResult = useBareMetalInstanceCatalogItems(enabled && !isProviderAdmin, params); const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems); const privateResult = useApiQuery({ queryKey: apiQueryKey('v1/baremetal_instance_catalog_items_private', undefined, params), From 9fb3e1e4cff71fb2c0f6618ffffdf927040b6442 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Sun, 26 Jul 2026 18:19:55 +0300 Subject: [PATCH 09/12] =?UTF-8?q?OSAC-2932:=20Address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20shared=20test=20harness,=20param=20order,=20doc?= =?UTF-8?q?=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/api/v1/baremetal-instance.test.ts | 28 ++++-------------- .../src/api/v1/baremetal-instance.ts | 10 ++++--- .../src/api/v1/cluster-catalog-item.test.ts | 28 ++++-------------- .../src/api/v1/cluster-catalog-item.ts | 6 ++-- .../v1/compute-instance-catalog-item.test.ts | 28 ++++-------------- .../api/v1/compute-instance-catalog-item.ts | 6 ++-- .../src/pages/tenant/CatalogPage.tsx | 2 +- .../src/test-utils/TestProviders.tsx | 29 ++++++++++++++++++- 8 files changed, 58 insertions(+), 79 deletions(-) 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 ecc8bbac..798ce083 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -1,7 +1,5 @@ -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 { act, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { BareMetalInstanceCatalogItem } from '@osac/types'; @@ -13,8 +11,7 @@ import { useAdminBareMetalInstanceCatalogItems, useAdminSetBareMetalInstanceCatalogItemPublished, } from './baremetal-instance'; -import { SessionProvider } from '../../hooks/use-session'; -import { ApiProvider } from '../api-context'; +import { renderHookWithProviders } from '../../test-utils/TestProviders'; const publicItem: BareMetalInstanceCatalogItem = { $typeName: 'osac.public.v1.BareMetalInstanceCatalogItem', @@ -67,26 +64,11 @@ const createTestTransport = (options: { }); }); -const renderWithSession = ( - hook: () => T, +const renderWithSession = ( + hook: () => TResult, role: 'providerAdmin' | 'tenantAdmin', transport: ReturnType, -) => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - const wrapper = ({ children }: { children: ReactNode }) => - createElement( - SessionProvider, - { role, username: 'test-user' } as React.ComponentProps, - createElement( - ApiProvider, - { transport } as React.ComponentProps, - createElement(QueryClientProvider, { client: queryClient }, children), - ), - ); - return { ...renderHook(hook, { wrapper }), queryClient }; -}; +) => renderHookWithProviders(hook, { role, transport }); describe('useAdminBareMetalInstanceCatalogItems', () => { it('calls the private List endpoint for providerAdmin', async () => { diff --git a/libs/ui-components/src/api/v1/baremetal-instance.ts b/libs/ui-components/src/api/v1/baremetal-instance.ts index c75e40be..780292d9 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.ts @@ -33,7 +33,7 @@ export const useBareMetalInstance = (id: string) => { }); }; -export const useBareMetalInstanceCatalogItems = (enabled = true, params: ListParams = {}) => { +export const useBareMetalInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { const client = useApiFetch(BareMetalInstanceCatalogItems); return useApiQuery({ queryKey: apiQueryKey('v1/baremetal_instance_catalog_items', undefined, params), @@ -45,13 +45,15 @@ export const useBareMetalInstanceCatalogItems = (enabled = true, params: ListPar /** * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via - * the private API (including unpublished); Tenant Admin sees their tenant's items via the public API, - * which the server already scopes to the caller's tenant regardless of publication status. + * the private API (including unpublished). Tenant Admin sees their tenant's items via the public API — + * this currently returns only published items regardless of caller role; unpublished items scoped to + * the Tenant Admin's own tenant are not visible through this hook (tracked as a backend limitation in + * OSAC-3121). */ export const useAdminBareMetalInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { const { role } = useSession(); const isProviderAdmin = role === 'providerAdmin'; - const publicResult = useBareMetalInstanceCatalogItems(enabled && !isProviderAdmin, params); + const publicResult = useBareMetalInstanceCatalogItems(params, enabled && !isProviderAdmin); const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems); const privateResult = useApiQuery({ queryKey: apiQueryKey('v1/baremetal_instance_catalog_items_private', undefined, params), 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 d6cafe62..0d77c5e5 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,7 +1,5 @@ -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 { act, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { ClusterCatalogItem } from '@osac/types'; @@ -13,8 +11,7 @@ import { useAdminClusterCatalogItems, useAdminSetClusterCatalogItemPublished, } from './cluster-catalog-item'; -import { SessionProvider } from '../../hooks/use-session'; -import { ApiProvider } from '../api-context'; +import { renderHookWithProviders } from '../../test-utils/TestProviders'; const publicItem: ClusterCatalogItem = { $typeName: 'osac.public.v1.ClusterCatalogItem', @@ -67,26 +64,11 @@ const createTestTransport = (options: { }); }); -const renderWithSession = ( - hook: () => T, +const renderWithSession = ( + hook: () => TResult, role: 'providerAdmin' | 'tenantAdmin', transport: ReturnType, -) => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - const wrapper = ({ children }: { children: ReactNode }) => - createElement( - SessionProvider, - { role, username: 'test-user' } as React.ComponentProps, - createElement( - ApiProvider, - { transport } as React.ComponentProps, - createElement(QueryClientProvider, { client: queryClient }, children), - ), - ); - return { ...renderHook(hook, { wrapper }), queryClient }; -}; +) => renderHookWithProviders(hook, { role, transport }); describe('useAdminClusterCatalogItems', () => { it('calls the private List endpoint for providerAdmin', async () => { 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 e0344929..8e609144 100644 --- a/libs/ui-components/src/api/v1/cluster-catalog-item.ts +++ b/libs/ui-components/src/api/v1/cluster-catalog-item.ts @@ -30,8 +30,10 @@ export const useClusterCatalogItem = (id: string | undefined) => { /** * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via - * the private API (including unpublished); Tenant Admin sees their tenant's items via the public API, - * which the server already scopes to the caller's tenant regardless of publication status. + * the private API (including unpublished). Tenant Admin sees their tenant's items via the public API — + * this currently returns only published items regardless of caller role; unpublished items scoped to + * the Tenant Admin's own tenant are not visible through this hook (tracked as a backend limitation in + * OSAC-3121). */ export const useAdminClusterCatalogItems = (params: ListParams = {}, enabled = true) => { const { role } = useSession(); 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 c851d3dd..8fbb261c 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,7 +1,5 @@ -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 { act, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { ComputeInstanceCatalogItem } from '@osac/types'; @@ -13,8 +11,7 @@ import { useAdminComputeInstanceCatalogItems, useAdminSetComputeInstanceCatalogItemPublished, } from './compute-instance-catalog-item'; -import { SessionProvider } from '../../hooks/use-session'; -import { ApiProvider } from '../api-context'; +import { renderHookWithProviders } from '../../test-utils/TestProviders'; const publicItem: ComputeInstanceCatalogItem = { $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', @@ -67,26 +64,11 @@ const createTestTransport = (options: { }); }); -const renderWithSession = ( - hook: () => T, +const renderWithSession = ( + hook: () => TResult, role: 'providerAdmin' | 'tenantAdmin', transport: ReturnType, -) => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - const wrapper = ({ children }: { children: ReactNode }) => - createElement( - SessionProvider, - { role, username: 'test-user' } as React.ComponentProps, - createElement( - ApiProvider, - { transport } as React.ComponentProps, - createElement(QueryClientProvider, { client: queryClient }, children), - ), - ); - return { ...renderHook(hook, { wrapper }), queryClient }; -}; +) => renderHookWithProviders(hook, { role, transport }); describe('useAdminComputeInstanceCatalogItems', () => { it('calls the private List endpoint for providerAdmin', async () => { 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 293444cb..1444d214 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 @@ -31,8 +31,10 @@ export const useComputeInstanceCatalogItem = (id: string | undefined) => { /** * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via - * the private API (including unpublished); Tenant Admin sees their tenant's items via the public API, - * which the server already scopes to the caller's tenant regardless of publication status. + * the private API (including unpublished). Tenant Admin sees their tenant's items via the public API — + * this currently returns only published items regardless of caller role; unpublished items scoped to + * the Tenant Admin's own tenant are not visible through this hook (tracked as a backend limitation in + * OSAC-3121). */ export const useAdminComputeInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { const { role } = useSession(); diff --git a/libs/ui-components/src/pages/tenant/CatalogPage.tsx b/libs/ui-components/src/pages/tenant/CatalogPage.tsx index a817fa7e..de610e71 100644 --- a/libs/ui-components/src/pages/tenant/CatalogPage.tsx +++ b/libs/ui-components/src/pages/tenant/CatalogPage.tsx @@ -48,7 +48,7 @@ const getTypeLabel = (typeFilter: CatalogTypeFilter, t: TFunction) => { const useCatalogItems = (typeFilter: CatalogTypeFilter) => { const vms = useComputeInstanceCatalogItems(undefined, typeFilter === 'vm'); const clusters = useClusterCatalogItems(undefined, typeFilter === 'cluster'); - const bms = useBareMetalInstanceCatalogItems(typeFilter === 'bm'); + const bms = useBareMetalInstanceCatalogItems(undefined, typeFilter === 'bm'); switch (typeFilter) { case 'vm': diff --git a/libs/ui-components/src/test-utils/TestProviders.tsx b/libs/ui-components/src/test-utils/TestProviders.tsx index 404d4338..3163a3dd 100644 --- a/libs/ui-components/src/test-utils/TestProviders.tsx +++ b/libs/ui-components/src/test-utils/TestProviders.tsx @@ -3,7 +3,7 @@ import { I18nextProvider } from 'react-i18next'; import { MemoryRouter } from 'react-router-dom'; import type { Transport } from '@connectrpc/connect'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { type RenderOptions, type RenderResult, render } from '@testing-library/react'; +import { type RenderOptions, type RenderResult, render, renderHook } from '@testing-library/react'; import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; import i18n from 'i18next'; @@ -15,6 +15,8 @@ import { } from './createMockConnectTransport'; import en from '../../../i18n/locales/en/translation.json'; import { ApiProvider } from '../api/api-context'; +import { SessionProvider } from '../hooks/use-session'; +import type { DemoShellRole } from '../shellTypes'; const createTestI18n = () => { const instance = i18n.createInstance(); @@ -96,3 +98,28 @@ export const renderWithProviders = ( return { ...view, user: userEvent.setup() }; }; + +export type RenderHookWithProvidersOptions = { + role: DemoShellRole; + transport: Transport; + username?: string; +}; + +/** Renders a hook wrapped in `SessionProvider` + `ApiProvider` + a fresh `QueryClient` — for testing + * role-aware hooks (e.g. admin catalog-item hooks) against a mock Connect transport. */ +export const renderHookWithProviders = ( + hook: () => TResult, + { role, transport, username = 'test-user' }: RenderHookWithProvidersOptions, +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + return { ...renderHook(hook, { wrapper }), queryClient }; +}; From 390f0cc0d5a1e9b050819f96d443e4178c2aa64c Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Mon, 27 Jul 2026 16:51:11 +0300 Subject: [PATCH 10/12] =?UTF-8?q?OSAC-2932:=20address=20rawagner=20PR=20re?= =?UTF-8?q?view=20=E2=80=94=20remove=20publish=20toggle,=20split=20public/?= =?UTF-8?q?private=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the card-level publish/unpublish toggle from this story's scope per reviewer suggestion; it moves to a detail-page action in OSAC-2933 instead, avoiding a nested interactive control inside an already-clickable card. Splits each admin catalog-item hook into a plain public hook (api/v1/) and a plain private hook (api/v1/private/), with role-based selection now living in the consuming panel component rather than inside the hooks themselves. Replaces the single generic CatalogManagementTabPanel with three concrete per-kind panels that own their own data fetching and compose ListPageBody + Gallery + CatalogItemCard directly. Also fixes the fragile 'tenant' in item type guard to use $typeName, gives the scope badge's exhaustiveness fallback a safe rendered default instead of a type-only never-check, and drops the low-value per-icon-kind test file. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/ui-components/src/api/types.ts | 6 +- .../src/api/v1/baremetal-instance.test.ts | 192 +++--------------- .../src/api/v1/baremetal-instance.ts | 46 ----- .../src/api/v1/cluster-catalog-item.test.ts | 188 +++-------------- .../src/api/v1/cluster-catalog-item.ts | 48 +---- .../v1/compute-instance-catalog-item.test.ts | 192 +++--------------- .../api/v1/compute-instance-catalog-item.ts | 50 +---- .../baremetal-instance-catalog-item.test.ts | 56 +++++ .../baremetal-instance-catalog-item.ts | 18 ++ .../v1/private/cluster-catalog-item.test.ts | 56 +++++ .../api/v1/private/cluster-catalog-item.ts | 15 ++ .../compute-instance-catalog-item.test.ts | 56 +++++ .../private/compute-instance-catalog-item.ts | 15 ++ .../components/catalog/CatalogItemCard.css | 9 - .../catalog/CatalogItemCard.test.tsx | 36 +--- .../components/catalog/CatalogItemCard.tsx | 25 +-- .../catalog/CatalogItemListSection.test.tsx | 46 ----- .../catalog/CatalogItemListSection.tsx | 32 +-- .../components/catalog/catalogItemDisplay.ts | 15 +- .../CatalogItemPublishToggle.test.tsx | 54 ----- .../CatalogItemPublishToggle.tsx | 31 --- .../CatalogItemScopeBadge.tsx | 5 +- libs/ui-components/src/icons.test.tsx | 25 --- ...areMetalInstanceCatalogManagementPanel.tsx | 162 +++++++++++++++ .../admin/CatalogManagementListPage.test.tsx | 55 +---- .../pages/admin/CatalogManagementListPage.tsx | 58 +----- ....tsx => ClusterCatalogManagementPanel.tsx} | 102 +++++----- .../ComputeInstanceCatalogManagementPanel.tsx | 162 +++++++++++++++ 28 files changed, 711 insertions(+), 1044 deletions(-) create mode 100644 libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts create mode 100644 libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.ts create mode 100644 libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts create mode 100644 libs/ui-components/src/api/v1/private/cluster-catalog-item.ts create mode 100644 libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts create mode 100644 libs/ui-components/src/api/v1/private/compute-instance-catalog-item.ts delete mode 100644 libs/ui-components/src/components/catalog/CatalogItemCard.css delete mode 100644 libs/ui-components/src/components/catalog/CatalogItemListSection.test.tsx delete mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx delete mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx delete mode 100644 libs/ui-components/src/icons.test.tsx create mode 100644 libs/ui-components/src/pages/admin/BareMetalInstanceCatalogManagementPanel.tsx rename libs/ui-components/src/pages/admin/{CatalogManagementTabPanel.tsx => ClusterCatalogManagementPanel.tsx} (59%) create mode 100644 libs/ui-components/src/pages/admin/ComputeInstanceCatalogManagementPanel.tsx diff --git a/libs/ui-components/src/api/types.ts b/libs/ui-components/src/api/types.ts index f123b142..4adfe97d 100644 --- a/libs/ui-components/src/api/types.ts +++ b/libs/ui-components/src/api/types.ts @@ -25,9 +25,9 @@ export type ApiRoute = | 'v1/baremetal_instances' | 'v1/public_ips' | 'v1/public_ip_attachments' - | 'v1/compute_instance_catalog_items_private' - | 'v1/cluster_catalog_items_private' - | 'v1/baremetal_instance_catalog_items_private'; + | 'v1/private/compute_instance_catalog_items' + | 'v1/private/cluster_catalog_items' + | 'v1/private/baremetal_instance_catalog_items'; /** * Strict 3-part tuple that encodes an API address. 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 798ce083..3a921ec5 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -1,19 +1,14 @@ import { createRouterTransport } from '@connectrpc/connect'; -import { act, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { BareMetalInstanceCatalogItem } from '@osac/types'; import { BareMetalInstanceCatalogItems } from '@osac/types'; -import type { BareMetalInstanceCatalogItem as PrivateBareMetalInstanceCatalogItem } from '@osac/types/private'; -import { BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems } from '@osac/types/private'; -import { - useAdminBareMetalInstanceCatalogItems, - useAdminSetBareMetalInstanceCatalogItemPublished, -} from './baremetal-instance'; +import { useBareMetalInstanceCatalogItems } from './baremetal-instance'; import { renderHookWithProviders } from '../../test-utils/TestProviders'; -const publicItem: BareMetalInstanceCatalogItem = { +const item: BareMetalInstanceCatalogItem = { $typeName: 'osac.public.v1.BareMetalInstanceCatalogItem', id: 'public-1', title: 'Public bare metal item', @@ -23,179 +18,38 @@ const publicItem: BareMetalInstanceCatalogItem = { fieldDefinitions: [], }; -const privateItem: PrivateBareMetalInstanceCatalogItem = { - $typeName: 'osac.private.v1.BareMetalInstanceCatalogItem', - id: 'private-1', - title: 'Private bare metal item', - description: '', - template: '', - published: true, - tenant: '', - fieldDefinitions: [], -}; - -const createTestTransport = (options: { - onPublicList?: () => void; - onPrivateList?: () => void; - onPublicUpdate?: (req: unknown) => void; - onPrivateUpdate?: (req: unknown) => void; -}) => - createRouterTransport((router) => { - router.service(BareMetalInstanceCatalogItems, { - list: () => { - options.onPublicList?.(); - return { items: [publicItem] }; - }, - update: (req) => { - options.onPublicUpdate?.(req); - return { object: publicItem }; - }, - }); - - router.service(PrivateBareMetalInstanceCatalogItems, { - list: () => { - options.onPrivateList?.(); - return { items: [privateItem] }; - }, - update: (req) => { - options.onPrivateUpdate?.(req); - return { object: privateItem }; - }, - }); - }); - -const renderWithSession = ( - hook: () => TResult, - role: 'providerAdmin' | 'tenantAdmin', - transport: ReturnType, -) => renderHookWithProviders(hook, { role, transport }); - -describe('useAdminBareMetalInstanceCatalogItems', () => { - it('calls the private List endpoint for providerAdmin', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, - }); - - const { result } = renderWithSession( - () => useAdminBareMetalInstanceCatalogItems(), - 'providerAdmin', - transport, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([privateItem]); - expect(privateListCalled).toBe(true); - expect(publicListCalled).toBe(false); - }); - - it('calls the public List endpoint for tenantAdmin', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, - }); - - const { result } = renderWithSession( - () => useAdminBareMetalInstanceCatalogItems(), - 'tenantAdmin', - transport, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([publicItem]); - expect(publicListCalled).toBe(true); - expect(privateListCalled).toBe(false); - }); - - it('does not call either endpoint when disabled', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, - }); - - renderWithSession( - () => useAdminBareMetalInstanceCatalogItems({}, false), - 'providerAdmin', - transport, - ); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(privateListCalled).toBe(false); - expect(publicListCalled).toBe(false); - }); -}); - -describe('useAdminSetBareMetalInstanceCatalogItemPublished', () => { - it('sends the update with a published field mask to the private client for providerAdmin', async () => { - let lastReq: unknown; - const transport = createTestTransport({ - onPrivateUpdate: (req) => { - lastReq = req; - }, +describe('useBareMetalInstanceCatalogItems', () => { + it('fetches items from the public BareMetalInstanceCatalogItems List endpoint', async () => { + const transport = createRouterTransport((router) => { + router.service(BareMetalInstanceCatalogItems, { list: () => ({ items: [item] }) }); }); - const { result } = renderWithSession( - () => useAdminSetBareMetalInstanceCatalogItemPublished(), - 'providerAdmin', + const { result } = renderHookWithProviders(() => useBareMetalInstanceCatalogItems(), { + role: 'tenantAdmin', transport, - ); - - act(() => { - result.current.mutate({ id: 'private-1', published: false }); }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(lastReq).toMatchObject({ - object: { id: 'private-1', published: false }, - updateMask: { paths: ['published'] }, - }); + expect(result.current.data).toEqual([item]); }); - it('sends the update to the public client for tenantAdmin', async () => { - let lastReq: unknown; - let privateCalled = false; - const transport = createTestTransport({ - onPublicUpdate: (req) => { - lastReq = req; - }, - onPrivateUpdate: () => { - privateCalled = true; - }, + it('does not fetch when disabled', async () => { + let listCalled = false; + const transport = createRouterTransport((router) => { + router.service(BareMetalInstanceCatalogItems, { + list: () => { + listCalled = true; + return { items: [item] }; + }, + }); }); - const { result } = renderWithSession( - () => useAdminSetBareMetalInstanceCatalogItemPublished(), - 'tenantAdmin', + renderHookWithProviders(() => useBareMetalInstanceCatalogItems({}, false), { + role: 'tenantAdmin', transport, - ); - - act(() => { - result.current.mutate({ id: 'public-1', published: true }); }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(lastReq).toMatchObject({ - object: { id: 'public-1', published: true }, - updateMask: { paths: ['published'] }, - }); - expect(privateCalled).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); }); }); diff --git a/libs/ui-components/src/api/v1/baremetal-instance.ts b/libs/ui-components/src/api/v1/baremetal-instance.ts index 780292d9..5b0c4359 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.ts @@ -7,9 +7,7 @@ import { BareMetalInstanceSchema, BareMetalInstances, } from '@osac/types'; -import { 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'; @@ -43,50 +41,6 @@ export const useBareMetalInstanceCatalogItems = (params: ListParams = {}, enable }); }; -/** - * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via - * the private API (including unpublished). Tenant Admin sees their tenant's items via the public API — - * this currently returns only published items regardless of caller role; unpublished items scoped to - * the Tenant Admin's own tenant are not visible through this hook (tracked as a backend limitation in - * OSAC-3121). - */ -export const useAdminBareMetalInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { - const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicResult = useBareMetalInstanceCatalogItems(params, enabled && !isProviderAdmin); - const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems); - const privateResult = useApiQuery({ - queryKey: apiQueryKey('v1/baremetal_instance_catalog_items_private', undefined, params), - queryFn: () => privateClient.list(params), - select: (data) => data.items, - enabled: enabled && isProviderAdmin, - }); - return isProviderAdmin ? privateResult : publicResult; -}; - -export const useAdminSetBareMetalInstanceCatalogItemPublished = () => { - const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicClient = useApiFetch(BareMetalInstanceCatalogItems); - const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems); - const qc = useApiQueryClient(); - return useMutation({ - mutationFn: ({ id, published }: { id: string; published: boolean }): Promise => - (isProviderAdmin - ? privateClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) - : publicClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) - ).then(() => undefined), - onSuccess: () => - qc.invalidateQueries({ - queryKey: apiQueryKey( - isProviderAdmin - ? 'v1/baremetal_instance_catalog_items_private' - : 'v1/baremetal_instance_catalog_items', - ), - }), - }); -}; - export const invalidateBareMetalInstancesQueries = async (qc: ApiQueryClient) => { await qc.invalidateQueries({ queryKey: apiQueryKey('v1/baremetal_instances') }); }; 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 0d77c5e5..30b19b45 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,19 +1,14 @@ import { createRouterTransport } from '@connectrpc/connect'; -import { act, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { ClusterCatalogItem } from '@osac/types'; import { ClusterCatalogItems } from '@osac/types'; -import type { ClusterCatalogItem as PrivateClusterCatalogItem } from '@osac/types/private'; -import { ClusterCatalogItems as PrivateClusterCatalogItems } from '@osac/types/private'; -import { - useAdminClusterCatalogItems, - useAdminSetClusterCatalogItemPublished, -} from './cluster-catalog-item'; +import { useClusterCatalogItems } from './cluster-catalog-item'; import { renderHookWithProviders } from '../../test-utils/TestProviders'; -const publicItem: ClusterCatalogItem = { +const item: ClusterCatalogItem = { $typeName: 'osac.public.v1.ClusterCatalogItem', id: 'public-1', title: 'Public cluster item', @@ -23,175 +18,38 @@ const publicItem: ClusterCatalogItem = { fieldDefinitions: [], }; -const privateItem: PrivateClusterCatalogItem = { - $typeName: 'osac.private.v1.ClusterCatalogItem', - id: 'private-1', - title: 'Private cluster item', - description: '', - template: '', - published: true, - tenant: '', - fieldDefinitions: [], -}; - -const createTestTransport = (options: { - onPublicList?: () => void; - onPrivateList?: () => void; - onPublicUpdate?: (req: unknown) => void; - onPrivateUpdate?: (req: unknown) => void; -}) => - createRouterTransport((router) => { - router.service(ClusterCatalogItems, { - list: () => { - options.onPublicList?.(); - return { items: [publicItem] }; - }, - update: (req) => { - options.onPublicUpdate?.(req); - return { object: publicItem }; - }, - }); - - router.service(PrivateClusterCatalogItems, { - list: () => { - options.onPrivateList?.(); - return { items: [privateItem] }; - }, - update: (req) => { - options.onPrivateUpdate?.(req); - return { object: privateItem }; - }, - }); - }); - -const renderWithSession = ( - hook: () => TResult, - role: 'providerAdmin' | 'tenantAdmin', - transport: ReturnType, -) => renderHookWithProviders(hook, { role, transport }); - -describe('useAdminClusterCatalogItems', () => { - it('calls the private List endpoint for providerAdmin', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, +describe('useClusterCatalogItems', () => { + it('fetches items from the public ClusterCatalogItems List endpoint', async () => { + const transport = createRouterTransport((router) => { + router.service(ClusterCatalogItems, { list: () => ({ items: [item] }) }); }); - const { result } = renderWithSession( - () => useAdminClusterCatalogItems(), - 'providerAdmin', + const { result } = renderHookWithProviders(() => useClusterCatalogItems(), { + role: 'tenantAdmin', transport, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([privateItem]); - expect(privateListCalled).toBe(true); - expect(publicListCalled).toBe(false); - }); - - it('calls the public List endpoint for tenantAdmin', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, }); - const { result } = renderWithSession( - () => useAdminClusterCatalogItems(), - 'tenantAdmin', - transport, - ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([publicItem]); - expect(publicListCalled).toBe(true); - expect(privateListCalled).toBe(false); + expect(result.current.data).toEqual([item]); }); - it('does not call either endpoint when disabled', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, + it('does not fetch when disabled', async () => { + let listCalled = false; + const transport = createRouterTransport((router) => { + router.service(ClusterCatalogItems, { + list: () => { + listCalled = true; + return { items: [item] }; + }, + }); }); - renderWithSession(() => useAdminClusterCatalogItems({}, false), 'providerAdmin', transport); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(privateListCalled).toBe(false); - expect(publicListCalled).toBe(false); - }); -}); - -describe('useAdminSetClusterCatalogItemPublished', () => { - it('sends the update with a published field mask to the private client for providerAdmin', async () => { - let lastReq: unknown; - const transport = createTestTransport({ - onPrivateUpdate: (req) => { - lastReq = req; - }, - }); - - const { result } = renderWithSession( - () => useAdminSetClusterCatalogItemPublished(), - 'providerAdmin', + renderHookWithProviders(() => useClusterCatalogItems({}, false), { + role: 'tenantAdmin', transport, - ); - - act(() => { - result.current.mutate({ id: 'private-1', published: false }); }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(lastReq).toMatchObject({ - object: { id: 'private-1', published: false }, - updateMask: { paths: ['published'] }, - }); - }); - - it('sends the update to the public client for tenantAdmin', async () => { - let lastReq: unknown; - let privateCalled = false; - const transport = createTestTransport({ - onPublicUpdate: (req) => { - lastReq = req; - }, - onPrivateUpdate: () => { - privateCalled = true; - }, - }); - - const { result } = renderWithSession( - () => useAdminSetClusterCatalogItemPublished(), - 'tenantAdmin', - transport, - ); - - act(() => { - result.current.mutate({ id: 'public-1', published: true }); - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(lastReq).toMatchObject({ - object: { id: 'public-1', published: true }, - updateMask: { paths: ['published'] }, - }); - expect(privateCalled).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); }); }); 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 8e609144..b1156a24 100644 --- a/libs/ui-components/src/api/v1/cluster-catalog-item.ts +++ b/libs/ui-components/src/api/v1/cluster-catalog-item.ts @@ -1,12 +1,8 @@ -import { useMutation } from '@tanstack/react-query'; - import { ClusterCatalogItems } from '@osac/types'; -import { 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, useApiQueryClient } from '../use-api-query'; +import { useApiQuery } from '../use-api-query'; export const useClusterCatalogItems = (params: ListParams = {}, enabled = true) => { const client = useApiFetch(ClusterCatalogItems); @@ -27,45 +23,3 @@ export const useClusterCatalogItem = (id: string | undefined) => { enabled: Boolean(id), }); }; - -/** - * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via - * the private API (including unpublished). Tenant Admin sees their tenant's items via the public API — - * this currently returns only published items regardless of caller role; unpublished items scoped to - * the Tenant Admin's own tenant are not visible through this hook (tracked as a backend limitation in - * OSAC-3121). - */ -export const useAdminClusterCatalogItems = (params: ListParams = {}, enabled = true) => { - const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicResult = useClusterCatalogItems(params, enabled && !isProviderAdmin); - const privateClient = useApiFetch(PrivateClusterCatalogItems); - const privateResult = useApiQuery({ - queryKey: apiQueryKey('v1/cluster_catalog_items_private', undefined, params), - queryFn: () => privateClient.list(params), - select: (data) => data.items, - enabled: enabled && isProviderAdmin, - }); - return isProviderAdmin ? privateResult : publicResult; -}; - -export const useAdminSetClusterCatalogItemPublished = () => { - const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicClient = useApiFetch(ClusterCatalogItems); - const privateClient = useApiFetch(PrivateClusterCatalogItems); - const qc = useApiQueryClient(); - return useMutation({ - mutationFn: ({ id, published }: { id: string; published: boolean }): Promise => - (isProviderAdmin - ? privateClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) - : publicClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) - ).then(() => undefined), - onSuccess: () => - qc.invalidateQueries({ - queryKey: apiQueryKey( - isProviderAdmin ? 'v1/cluster_catalog_items_private' : 'v1/cluster_catalog_items', - ), - }), - }); -}; 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 8fbb261c..6cb52f1d 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,19 +1,14 @@ import { createRouterTransport } from '@connectrpc/connect'; -import { act, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { ComputeInstanceCatalogItem } from '@osac/types'; import { ComputeInstanceCatalogItems } from '@osac/types'; -import type { ComputeInstanceCatalogItem as PrivateComputeInstanceCatalogItem } from '@osac/types/private'; -import { ComputeInstanceCatalogItems as PrivateComputeInstanceCatalogItems } from '@osac/types/private'; -import { - useAdminComputeInstanceCatalogItems, - useAdminSetComputeInstanceCatalogItemPublished, -} from './compute-instance-catalog-item'; +import { useComputeInstanceCatalogItems } from './compute-instance-catalog-item'; import { renderHookWithProviders } from '../../test-utils/TestProviders'; -const publicItem: ComputeInstanceCatalogItem = { +const item: ComputeInstanceCatalogItem = { $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', id: 'public-1', title: 'Public VM item', @@ -23,179 +18,38 @@ const publicItem: ComputeInstanceCatalogItem = { fieldDefinitions: [], }; -const privateItem: PrivateComputeInstanceCatalogItem = { - $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', - id: 'private-1', - title: 'Private VM item', - description: '', - template: '', - published: true, - tenant: '', - fieldDefinitions: [], -}; - -const createTestTransport = (options: { - onPublicList?: () => void; - onPrivateList?: () => void; - onPublicUpdate?: (req: unknown) => void; - onPrivateUpdate?: (req: unknown) => void; -}) => - createRouterTransport((router) => { - router.service(ComputeInstanceCatalogItems, { - list: () => { - options.onPublicList?.(); - return { items: [publicItem] }; - }, - update: (req) => { - options.onPublicUpdate?.(req); - return { object: publicItem }; - }, - }); - - router.service(PrivateComputeInstanceCatalogItems, { - list: () => { - options.onPrivateList?.(); - return { items: [privateItem] }; - }, - update: (req) => { - options.onPrivateUpdate?.(req); - return { object: privateItem }; - }, - }); - }); - -const renderWithSession = ( - hook: () => TResult, - role: 'providerAdmin' | 'tenantAdmin', - transport: ReturnType, -) => renderHookWithProviders(hook, { role, transport }); - -describe('useAdminComputeInstanceCatalogItems', () => { - it('calls the private List endpoint for providerAdmin', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, - }); - - const { result } = renderWithSession( - () => useAdminComputeInstanceCatalogItems(), - 'providerAdmin', - transport, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([privateItem]); - expect(privateListCalled).toBe(true); - expect(publicListCalled).toBe(false); - }); - - it('calls the public List endpoint for tenantAdmin', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, - }); - - const { result } = renderWithSession( - () => useAdminComputeInstanceCatalogItems(), - 'tenantAdmin', - transport, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([publicItem]); - expect(publicListCalled).toBe(true); - expect(privateListCalled).toBe(false); - }); - - it('does not call either endpoint when disabled', async () => { - let privateListCalled = false; - let publicListCalled = false; - const transport = createTestTransport({ - onPrivateList: () => { - privateListCalled = true; - }, - onPublicList: () => { - publicListCalled = true; - }, - }); - - renderWithSession( - () => useAdminComputeInstanceCatalogItems({}, false), - 'providerAdmin', - transport, - ); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(privateListCalled).toBe(false); - expect(publicListCalled).toBe(false); - }); -}); - -describe('useAdminSetComputeInstanceCatalogItemPublished', () => { - it('sends the update with a published field mask to the private client for providerAdmin', async () => { - let lastReq: unknown; - const transport = createTestTransport({ - onPrivateUpdate: (req) => { - lastReq = req; - }, +describe('useComputeInstanceCatalogItems', () => { + it('fetches items from the public ComputeInstanceCatalogItems List endpoint', async () => { + const transport = createRouterTransport((router) => { + router.service(ComputeInstanceCatalogItems, { list: () => ({ items: [item] }) }); }); - const { result } = renderWithSession( - () => useAdminSetComputeInstanceCatalogItemPublished(), - 'providerAdmin', + const { result } = renderHookWithProviders(() => useComputeInstanceCatalogItems(), { + role: 'tenantAdmin', transport, - ); - - act(() => { - result.current.mutate({ id: 'private-1', published: false }); }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(lastReq).toMatchObject({ - object: { id: 'private-1', published: false }, - updateMask: { paths: ['published'] }, - }); + expect(result.current.data).toEqual([item]); }); - it('sends the update to the public client for tenantAdmin', async () => { - let lastReq: unknown; - let privateCalled = false; - const transport = createTestTransport({ - onPublicUpdate: (req) => { - lastReq = req; - }, - onPrivateUpdate: () => { - privateCalled = true; - }, + it('does not fetch when disabled', async () => { + let listCalled = false; + const transport = createRouterTransport((router) => { + router.service(ComputeInstanceCatalogItems, { + list: () => { + listCalled = true; + return { items: [item] }; + }, + }); }); - const { result } = renderWithSession( - () => useAdminSetComputeInstanceCatalogItemPublished(), - 'tenantAdmin', + renderHookWithProviders(() => useComputeInstanceCatalogItems({}, false), { + role: 'tenantAdmin', transport, - ); - - act(() => { - result.current.mutate({ id: 'public-1', published: true }); }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(lastReq).toMatchObject({ - object: { id: 'public-1', published: true }, - updateMask: { paths: ['published'] }, - }); - expect(privateCalled).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); }); }); 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 1444d214..f0341a2e 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,12 +1,8 @@ -import { useMutation } from '@tanstack/react-query'; - import { ComputeInstanceCatalogItems } from '@osac/types'; -import { 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, useApiQueryClient } from '../use-api-query'; +import { useApiQuery } from '../use-api-query'; export const useComputeInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { const client = useApiFetch(ComputeInstanceCatalogItems); @@ -28,47 +24,3 @@ export const useComputeInstanceCatalogItem = (id: string | undefined) => { enabled: Boolean(trimmedId), }); }; - -/** - * Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via - * the private API (including unpublished). Tenant Admin sees their tenant's items via the public API — - * this currently returns only published items regardless of caller role; unpublished items scoped to - * the Tenant Admin's own tenant are not visible through this hook (tracked as a backend limitation in - * OSAC-3121). - */ -export const useAdminComputeInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { - const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicResult = useComputeInstanceCatalogItems(params, enabled && !isProviderAdmin); - const privateClient = useApiFetch(PrivateComputeInstanceCatalogItems); - const privateResult = useApiQuery({ - queryKey: apiQueryKey('v1/compute_instance_catalog_items_private', undefined, params), - queryFn: () => privateClient.list(params), - select: (data) => data.items, - enabled: enabled && isProviderAdmin, - }); - return isProviderAdmin ? privateResult : publicResult; -}; - -export const useAdminSetComputeInstanceCatalogItemPublished = () => { - const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicClient = useApiFetch(ComputeInstanceCatalogItems); - const privateClient = useApiFetch(PrivateComputeInstanceCatalogItems); - const qc = useApiQueryClient(); - return useMutation({ - mutationFn: ({ id, published }: { id: string; published: boolean }): Promise => - (isProviderAdmin - ? privateClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) - : publicClient.update({ object: { id, published }, updateMask: { paths: ['published'] } }) - ).then(() => undefined), - onSuccess: () => - qc.invalidateQueries({ - queryKey: apiQueryKey( - isProviderAdmin - ? 'v1/compute_instance_catalog_items_private' - : 'v1/compute_instance_catalog_items', - ), - }), - }); -}; diff --git a/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts new file mode 100644 index 00000000..a81d4f30 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts @@ -0,0 +1,56 @@ +import { createRouterTransport } from '@connectrpc/connect'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { BareMetalInstanceCatalogItem } from '@osac/types/private'; +import { BareMetalInstanceCatalogItems } from '@osac/types/private'; + +import { usePrivateBareMetalInstanceCatalogItems } from './baremetal-instance-catalog-item'; +import { renderHookWithProviders } from '../../../test-utils/TestProviders'; + +const item: BareMetalInstanceCatalogItem = { + $typeName: 'osac.private.v1.BareMetalInstanceCatalogItem', + id: 'private-1', + title: 'Private bare metal item', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +describe('usePrivateBareMetalInstanceCatalogItems', () => { + it('fetches items from the private BareMetalInstanceCatalogItems List endpoint', async () => { + const transport = createRouterTransport((router) => { + router.service(BareMetalInstanceCatalogItems, { list: () => ({ items: [item] }) }); + }); + + const { result } = renderHookWithProviders(() => usePrivateBareMetalInstanceCatalogItems(), { + role: 'providerAdmin', + transport, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([item]); + }); + + it('does not fetch when disabled', async () => { + let listCalled = false; + const transport = createRouterTransport((router) => { + router.service(BareMetalInstanceCatalogItems, { + list: () => { + listCalled = true; + return { items: [item] }; + }, + }); + }); + + renderHookWithProviders(() => usePrivateBareMetalInstanceCatalogItems({}, false), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); +}); diff --git a/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.ts b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.ts new file mode 100644 index 00000000..fcb209c2 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.ts @@ -0,0 +1,18 @@ +import { BareMetalInstanceCatalogItems } from '@osac/types/private'; + +import { useApiFetch } from '../../api-context'; +import { type ListParams, apiQueryKey } from '../../types'; +import { useApiQuery } from '../../use-api-query'; + +export const usePrivateBareMetalInstanceCatalogItems = ( + params: ListParams = {}, + enabled = true, +) => { + const client = useApiFetch(BareMetalInstanceCatalogItems); + return useApiQuery({ + queryKey: apiQueryKey('v1/private/baremetal_instance_catalog_items', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + enabled, + }); +}; diff --git a/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts new file mode 100644 index 00000000..afdf7931 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts @@ -0,0 +1,56 @@ +import { createRouterTransport } from '@connectrpc/connect'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types/private'; +import { ClusterCatalogItems } from '@osac/types/private'; + +import { usePrivateClusterCatalogItems } from './cluster-catalog-item'; +import { renderHookWithProviders } from '../../../test-utils/TestProviders'; + +const item: ClusterCatalogItem = { + $typeName: 'osac.private.v1.ClusterCatalogItem', + id: 'private-1', + title: 'Private cluster item', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +describe('usePrivateClusterCatalogItems', () => { + it('fetches items from the private ClusterCatalogItems List endpoint', async () => { + const transport = createRouterTransport((router) => { + router.service(ClusterCatalogItems, { list: () => ({ items: [item] }) }); + }); + + const { result } = renderHookWithProviders(() => usePrivateClusterCatalogItems(), { + role: 'providerAdmin', + transport, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([item]); + }); + + it('does not fetch when disabled', async () => { + let listCalled = false; + const transport = createRouterTransport((router) => { + router.service(ClusterCatalogItems, { + list: () => { + listCalled = true; + return { items: [item] }; + }, + }); + }); + + renderHookWithProviders(() => usePrivateClusterCatalogItems({}, false), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); +}); diff --git a/libs/ui-components/src/api/v1/private/cluster-catalog-item.ts b/libs/ui-components/src/api/v1/private/cluster-catalog-item.ts new file mode 100644 index 00000000..b02f2035 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/cluster-catalog-item.ts @@ -0,0 +1,15 @@ +import { ClusterCatalogItems } from '@osac/types/private'; + +import { useApiFetch } from '../../api-context'; +import { type ListParams, apiQueryKey } from '../../types'; +import { useApiQuery } from '../../use-api-query'; + +export const usePrivateClusterCatalogItems = (params: ListParams = {}, enabled = true) => { + const client = useApiFetch(ClusterCatalogItems); + return useApiQuery({ + queryKey: apiQueryKey('v1/private/cluster_catalog_items', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + enabled, + }); +}; diff --git a/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts new file mode 100644 index 00000000..105f98b6 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts @@ -0,0 +1,56 @@ +import { createRouterTransport } from '@connectrpc/connect'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ComputeInstanceCatalogItem } from '@osac/types/private'; +import { ComputeInstanceCatalogItems } from '@osac/types/private'; + +import { usePrivateComputeInstanceCatalogItems } from './compute-instance-catalog-item'; +import { renderHookWithProviders } from '../../../test-utils/TestProviders'; + +const item: ComputeInstanceCatalogItem = { + $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', + id: 'private-1', + title: 'Private VM item', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +describe('usePrivateComputeInstanceCatalogItems', () => { + it('fetches items from the private ComputeInstanceCatalogItems List endpoint', async () => { + const transport = createRouterTransport((router) => { + router.service(ComputeInstanceCatalogItems, { list: () => ({ items: [item] }) }); + }); + + const { result } = renderHookWithProviders(() => usePrivateComputeInstanceCatalogItems(), { + role: 'providerAdmin', + transport, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([item]); + }); + + it('does not fetch when disabled', async () => { + let listCalled = false; + const transport = createRouterTransport((router) => { + router.service(ComputeInstanceCatalogItems, { + list: () => { + listCalled = true; + return { items: [item] }; + }, + }); + }); + + renderHookWithProviders(() => usePrivateComputeInstanceCatalogItems({}, false), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); +}); diff --git a/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.ts b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.ts new file mode 100644 index 00000000..70741ca2 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.ts @@ -0,0 +1,15 @@ +import { ComputeInstanceCatalogItems } from '@osac/types/private'; + +import { useApiFetch } from '../../api-context'; +import { type ListParams, apiQueryKey } from '../../types'; +import { useApiQuery } from '../../use-api-query'; + +export const usePrivateComputeInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { + const client = useApiFetch(ComputeInstanceCatalogItems); + return useApiQuery({ + queryKey: apiQueryKey('v1/private/compute_instance_catalog_items', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + enabled, + }); +}; diff --git a/libs/ui-components/src/components/catalog/CatalogItemCard.css b/libs/ui-components/src/components/catalog/CatalogItemCard.css deleted file mode 100644 index 45f38f66..00000000 --- a/libs/ui-components/src/components/catalog/CatalogItemCard.css +++ /dev/null @@ -1,9 +0,0 @@ -/* PatternFly's clickable Card (.pf-m-clickable) sets isolation: isolate, scoping z-index - comparisons to the card. Its full-card clickable overlay is position: absolute with - z-index: auto, which paints above non-positioned content regardless of DOM order — so without - this, the toggle would be visually present but unclickable (clicks would hit the overlay and - navigate instead of toggling). An explicit z-index here outranks the overlay's z-index: auto. */ -.catalog-item-card__publish-toggle { - position: relative; - z-index: 1; -} diff --git a/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx b/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx index e801f7a5..14c2d2e5 100644 --- a/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx +++ b/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx @@ -5,7 +5,6 @@ import type { ClusterCatalogItem } from '@osac/types'; import CatalogItemCard from './CatalogItemCard'; import { renderWithProviders } from '../../test-utils/TestProviders'; -import CatalogItemPublishToggle from '../catalogManagement/CatalogItemPublishToggle'; import CatalogItemScopeBadge from '../catalogManagement/CatalogItemScopeBadge'; import CatalogItemStatusLabel from '../catalogManagement/CatalogItemStatusLabel'; @@ -20,37 +19,29 @@ const item: ClusterCatalogItem = { }; describe('CatalogItemCard', () => { - it('omits scope badge, status label, and publish toggle by default (tenant mode)', () => { + it('omits scope badge and status label by default (tenant mode)', () => { renderWithProviders( {}} />); expect(screen.queryByText('General')).not.toBeInTheDocument(); expect(screen.queryByText('Published')).not.toBeInTheDocument(); - expect(screen.queryByRole('switch')).not.toBeInTheDocument(); }); - it('renders scope badge, status label, and publish toggle when provided (admin mode)', () => { + it('renders scope badge and status label when provided (admin mode)', () => { renderWithProviders( {}} scopeBadge={} statusLabel={} - publishToggle={ {}} />} />, ); expect(screen.getByText('General')).toBeInTheDocument(); - // "Published" appears twice: once from the status label, once as the switch's own accessible label. - expect(screen.getAllByText('Published')).toHaveLength(2); - expect(screen.getByRole('switch')).toBeInTheDocument(); + expect(screen.getByText('Published')).toBeInTheDocument(); }); - it('still navigates to details when the card is clicked (regression)', async () => { + it('navigates to details when the card is clicked', async () => { const onOpenDetails = vi.fn(); const { user } = renderWithProviders( - {}} />} - />, + , ); await user.click( @@ -59,21 +50,4 @@ describe('CatalogItemCard', () => { expect(onOpenDetails).toHaveBeenCalled(); }); - - it('does not navigate to details when the publish toggle is clicked', async () => { - const onOpenDetails = vi.fn(); - const onTogglePublished = vi.fn(); - const { user } = renderWithProviders( - } - />, - ); - - await user.click(screen.getByRole('switch')); - - expect(onTogglePublished).toHaveBeenCalledWith(false); - expect(onOpenDetails).not.toHaveBeenCalled(); - }); }); diff --git a/libs/ui-components/src/components/catalog/CatalogItemCard.tsx b/libs/ui-components/src/components/catalog/CatalogItemCard.tsx index 9d932b0c..8357146e 100644 --- a/libs/ui-components/src/components/catalog/CatalogItemCard.tsx +++ b/libs/ui-components/src/components/catalog/CatalogItemCard.tsx @@ -21,8 +21,6 @@ import { import { useTranslation } from '../../hooks/useTranslation'; import { CatalogItemIcon } from '../../icons'; -import './CatalogItemCard.css'; - export interface CatalogItemCardSelection { selected: boolean; radioName: string; @@ -37,7 +35,6 @@ interface CatalogItemCardProps { isSelected?: boolean; scopeBadge?: React.ReactNode; statusLabel?: React.ReactNode; - publishToggle?: React.ReactNode; } const CatalogItemCard = ({ @@ -48,7 +45,6 @@ const CatalogItemCard = ({ isSelected, scopeBadge, statusLabel, - publishToggle, }: CatalogItemCardProps) => { const { t } = useTranslation(); const resources = catalogItemResourceParts(item); @@ -94,24 +90,13 @@ const CatalogItemCard = ({ : undefined } > - + - - - - - - {item.title} - - + + + + {item.title} - {publishToggle ? ( - {publishToggle} - ) : null} diff --git a/libs/ui-components/src/components/catalog/CatalogItemListSection.test.tsx b/libs/ui-components/src/components/catalog/CatalogItemListSection.test.tsx deleted file mode 100644 index 58babb42..00000000 --- a/libs/ui-components/src/components/catalog/CatalogItemListSection.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import type { ClusterCatalogItem } from '@osac/types'; - -import { CatalogItemListSection } from './CatalogItemListSection'; -import { renderWithProviders } from '../../test-utils/TestProviders'; - -const items: ClusterCatalogItem[] = [ - { - $typeName: 'osac.public.v1.ClusterCatalogItem', - id: 'catalog-1', - title: 'OpenShift 4 cluster', - description: '', - template: '', - published: true, - fieldDefinitions: [], - }, -]; - -describe('CatalogItemListSection', () => { - it('renders no addons when renderCardAddons is omitted (tenant mode)', () => { - renderWithProviders( - {}} />, - ); - expect(screen.queryByText('addon-marker')).not.toBeInTheDocument(); - }); - - it('threads renderCardAddons output through to each card', () => { - renderWithProviders( - {}} - renderCardAddons={() => ({ - scopeBadge: scope-marker, - statusLabel: status-marker, - publishToggle: toggle-marker, - })} - />, - ); - expect(screen.getByText('scope-marker')).toBeInTheDocument(); - expect(screen.getByText('status-marker')).toBeInTheDocument(); - expect(screen.getByText('toggle-marker')).toBeInTheDocument(); - }); -}); diff --git a/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx b/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx index 406e2196..1ca46a11 100644 --- a/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx +++ b/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx @@ -13,12 +13,6 @@ import type { CatalogItem } from './catalogItemDisplay'; import { getErrorMessage } from '../../utils/error'; import QueryErrorState from '../Resource/QueryErrorState'; -interface CatalogItemCardAddons { - scopeBadge?: React.ReactNode; - statusLabel?: React.ReactNode; - publishToggle?: React.ReactNode; -} - interface CatalogItemListSectionProps { title: string; items: CatalogItem[]; @@ -26,7 +20,6 @@ interface CatalogItemListSectionProps { onSelectItem: (item: CatalogItem) => void; isLoading?: boolean; error?: unknown; - renderCardAddons?: (item: CatalogItem) => CatalogItemCardAddons; } export const CatalogItemListSection = ({ @@ -36,7 +29,6 @@ export const CatalogItemListSection = ({ onSelectItem, isLoading = false, error = null, - renderCardAddons, }: CatalogItemListSectionProps) => { if (!isLoading && !error && items.length === 0) { return null; @@ -65,21 +57,15 @@ export const CatalogItemListSection = ({ {items.length > 0 ? ( - {items.map((item) => { - const addons = renderCardAddons?.(item); - return ( - - onSelectItem(item)} - scopeBadge={addons?.scopeBadge} - statusLabel={addons?.statusLabel} - publishToggle={addons?.publishToggle} - /> - - ); - })} + {items.map((item) => ( + + onSelectItem(item)} + /> + + ))} ) : null} diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts index cb225d35..cdedf9ee 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts @@ -155,6 +155,18 @@ export const filterCatalogItemsBySearch = (items: CatalogItem[], search: string) return items.filter((item) => searchableCatalogItemText(item).includes(searchTerm)); }; +export type PublicationFilter = 'all' | 'published' | 'unpublished'; + +export const matchesPublicationFilter = (item: CatalogItem, filter: PublicationFilter): boolean => { + if (filter === 'published') { + return item.published; + } + if (filter === 'unpublished') { + return !item.published; + } + return true; +}; + export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string => { const defaultValue = resolvedFieldDefault(def); if (defaultValue === undefined) { @@ -176,7 +188,8 @@ export type CatalogItemScope = | { level: 'organization'; name?: string } | { level: 'project'; name: string }; -const isPrivateCatalogItem = (item: CatalogItem): item is PrivateCatalogItem => 'tenant' in item; +const isPrivateCatalogItem = (item: CatalogItem): item is PrivateCatalogItem => + item.$typeName.startsWith('osac.private.'); export const catalogItemScope = (item: CatalogItem, role: DemoShellRole): CatalogItemScope => { const project = item.metadata?.project ?? ''; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx deleted file mode 100644 index 661a5ef7..00000000 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import CatalogItemPublishToggle from './CatalogItemPublishToggle'; -import { renderWithProviders } from '../../test-utils/TestProviders'; - -describe('CatalogItemPublishToggle', () => { - it('renders as checked when published', () => { - renderWithProviders( {}} />); - expect(screen.getByRole('switch')).toBeChecked(); - }); - - it('renders as unchecked when not published', () => { - renderWithProviders( {}} />); - expect(screen.getByRole('switch')).not.toBeChecked(); - }); - - it('calls onChange with the flipped value when toggled', async () => { - const onChange = vi.fn(); - const { user } = renderWithProviders( - , - ); - - await user.click(screen.getByRole('switch')); - - expect(onChange).toHaveBeenCalledWith(false); - }); - - it('does not call onChange when disabled', async () => { - const onChange = vi.fn(); - const { user } = renderWithProviders( - , - ); - - await user.click(screen.getByRole('switch')); - - expect(onChange).not.toHaveBeenCalled(); - }); - - it('does not propagate its click event to an ancestor element', async () => { - const onAncestorClick = vi.fn(); - const onChange = vi.fn(); - const { user } = renderWithProviders( -
- -
, - ); - - await user.click(screen.getByRole('switch')); - - expect(onChange).toHaveBeenCalledWith(false); - expect(onAncestorClick).not.toHaveBeenCalled(); - }); -}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx deleted file mode 100644 index 5101bba5..00000000 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Switch } from '@patternfly/react-core'; - -import { useTranslation } from '../../hooks/useTranslation'; - -interface CatalogItemPublishToggleProps { - published: boolean; - isDisabled?: boolean; - onChange: (published: boolean) => void; -} - -const CatalogItemPublishToggle = ({ - published, - isDisabled, - onChange, -}: CatalogItemPublishToggleProps) => { - const { t } = useTranslation(); - - return ( - // Stops the toggle's click from bubbling to an ancestor card's click-to-navigate handler. - event.stopPropagation()}> - onChange(checked)} - /> - - ); -}; - -export default CatalogItemPublishToggle; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx index 99194ec0..2ad04e81 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx @@ -22,8 +22,11 @@ const CatalogItemScopeBadge = ({ scope }: CatalogItemScopeBadgeProps) => { case 'project': return ; default: { + // Guards against a future scope level being added without updating this switch — TS flags the + // assignment below at compile time, while runtime still renders a safe fallback instead of crashing. const exhaustiveCheck: never = scope; - return exhaustiveCheck; + void exhaustiveCheck; + return ; } } }; diff --git a/libs/ui-components/src/icons.test.tsx b/libs/ui-components/src/icons.test.tsx deleted file mode 100644 index ce535518..00000000 --- a/libs/ui-components/src/icons.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import CloudIcon from '@patternfly/react-icons/dist/esm/icons/cloud-icon'; -import ServerIcon from '@patternfly/react-icons/dist/esm/icons/server-icon'; -import VirtualMachineIcon from '@patternfly/react-icons/dist/esm/icons/virtual-machine-icon'; -import { render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import { CatalogItemIcon } from './icons'; - -const renderedIconPath = (ui: React.ReactElement) => - render(ui).container.querySelector('svg path')?.getAttribute('d'); - -describe('CatalogItemIcon', () => { - it.each([ - ['osac.public.v1.ClusterCatalogItem', CloudIcon], - ['osac.private.v1.ClusterCatalogItem', CloudIcon], - ['osac.public.v1.BareMetalInstanceCatalogItem', ServerIcon], - ['osac.private.v1.BareMetalInstanceCatalogItem', ServerIcon], - ['osac.public.v1.ComputeInstanceCatalogItem', VirtualMachineIcon], - ['osac.private.v1.ComputeInstanceCatalogItem', VirtualMachineIcon], - ] as const)('renders the expected icon for kind %s', (kind, ExpectedIcon) => { - expect(renderedIconPath()).toBe( - renderedIconPath(), - ); - }); -}); diff --git a/libs/ui-components/src/pages/admin/BareMetalInstanceCatalogManagementPanel.tsx b/libs/ui-components/src/pages/admin/BareMetalInstanceCatalogManagementPanel.tsx new file mode 100644 index 00000000..b2b2edee --- /dev/null +++ b/libs/ui-components/src/pages/admin/BareMetalInstanceCatalogManagementPanel.tsx @@ -0,0 +1,162 @@ +import { useNavigate } from 'react-router-dom'; +import { + Button, + EmptyState, + EmptyStateBody, + Flex, + FlexItem, + Gallery, + GalleryItem, + SearchInput, + Stack, + StackItem, + Title, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core'; + +import { useBareMetalInstanceCatalogItems } from '@osac/ui-components/api/v1/baremetal-instance'; +import { usePrivateBareMetalInstanceCatalogItems } from '@osac/ui-components/api/v1/private/baremetal-instance-catalog-item'; +import CatalogItemCard from '@osac/ui-components/components/catalog/CatalogItemCard'; +import { + type PublicationFilter, + catalogItemScope, + filterCatalogItemsBySearch, + matchesPublicationFilter, +} from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import CatalogItemScopeBadge from '@osac/ui-components/components/catalogManagement/CatalogItemScopeBadge'; +import CatalogItemStatusLabel from '@osac/ui-components/components/catalogManagement/CatalogItemStatusLabel'; +import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import type { DemoShellRole } from '@osac/ui-components/shellTypes'; + +interface BareMetalInstanceCatalogManagementPanelProps { + isActive: boolean; + search: string; + setSearch: (value: string) => void; + publicationFilter: PublicationFilter; + setPublicationFilter: (value: PublicationFilter) => void; + role: DemoShellRole; +} + +const BareMetalInstanceCatalogManagementPanel = ({ + isActive, + search, + setSearch, + publicationFilter, + setPublicationFilter, + role, +}: BareMetalInstanceCatalogManagementPanelProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = useBareMetalInstanceCatalogItems(undefined, isActive && !isProviderAdmin); + const privateResult = usePrivateBareMetalInstanceCatalogItems( + undefined, + isActive && isProviderAdmin, + ); + const { data = [], isLoading, error, isSuccess } = isProviderAdmin ? privateResult : publicResult; + + const filteredItems = filterCatalogItemsBySearch(data, search).filter((item) => + matchesPublicationFilter(item, publicationFilter), + ); + + const publicationFilters: ReadonlyArray<{ value: PublicationFilter; label: string }> = [ + { value: 'all', label: t('All') }, + { value: 'published', label: t('Published') }, + { value: 'unpublished', label: t('Unpublished') }, + ]; + + const isFiltered = search.trim().length > 0 || publicationFilter !== 'all'; + const showEmptyState = isSuccess && !error && filteredItems.length === 0; + + return ( + + + + + + + setSearch(value)} + onClear={() => setSearch('')} + aria-label={t('Filter catalog by keyword')} + isDisabled={isLoading || !!error} + /> + + + + {publicationFilters.map((option) => ( + setPublicationFilter(option.value)} + /> + ))} + + + + + + + + + + {showEmptyState ? ( + + + + {isFiltered + ? t('No catalog items match your search or filter.') + : t('No catalog items have been created yet.')} + + + + ) : ( + + + + + {t('Bare Metal')} + + + + + {filteredItems.map((item) => ( + + navigate(`/admin/catalog/baremetal-instance/${item.id}`)} + scopeBadge={} + statusLabel={} + /> + + ))} + + + + + )} + + ); +}; + +export default BareMetalInstanceCatalogManagementPanel; diff --git a/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx b/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx index 8817e8a9..c1b55bc0 100644 --- a/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx +++ b/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx @@ -87,31 +87,15 @@ const privateVmItem: PrivateComputeInstanceCatalogItem = { const emptyList = () => ({ items: [] }); -const createTestTransport = (options: { onUpdate?: (req: unknown) => void } = {}) => +const createTestTransport = () => createRouterTransport((router) => { - router.service(PrivateClusterCatalogItems, { - list: () => ({ items: [privateClusterItem] }), - update: (req) => { - options.onUpdate?.(req); - return { object: privateClusterItem }; - }, - }); - router.service(ClusterCatalogItems, { - list: () => ({ items: [publicClusterItem] }), - update: (req) => { - options.onUpdate?.(req); - return { object: publicClusterItem }; - }, - }); + router.service(PrivateClusterCatalogItems, { list: () => ({ items: [privateClusterItem] }) }); + router.service(ClusterCatalogItems, { list: () => ({ items: [publicClusterItem] }) }); router.service(PrivateComputeInstanceCatalogItems, { list: () => ({ items: [privateVmItem] }), }); router.service(ComputeInstanceCatalogItems, { list: () => ({ items: [publicUnpublishedVmItem] }), - update: (req) => { - options.onUpdate?.(req); - return { object: publicUnpublishedVmItem }; - }, }); router.service(PrivateBareMetalInstanceCatalogItems, { list: emptyList }); router.service(BareMetalInstanceCatalogItems, { list: emptyList }); @@ -247,37 +231,4 @@ describe('CatalogManagementListPage', () => { expect(screen.getByText('detail-page')).toBeInTheDocument(); }); }); - - it('disables the publish toggle for a Tenant Admin viewing a general item', async () => { - renderPage('tenantAdmin'); - await waitFor(() => { - expect(screen.getByText(publicClusterItem.title)).toBeInTheDocument(); - }); - expect(screen.getByRole('switch')).toBeDisabled(); - }); - - it('sends an update when the publish toggle is used by a CSP Admin', async () => { - let lastReq: unknown; - const { user } = renderPage( - 'providerAdmin', - createTestTransport({ - onUpdate: (req) => { - lastReq = req; - }, - }), - ); - - await waitFor(() => { - expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('switch')); - - await waitFor(() => { - expect(lastReq).toMatchObject({ - object: { id: privateClusterItem.id, published: false }, - updateMask: { paths: ['published'] }, - }); - }); - }); }); diff --git a/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx b/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx index 05245e23..79e2a508 100644 --- a/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx +++ b/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx @@ -1,26 +1,16 @@ import { useState } from 'react'; import { Tab, TabTitleText, Tabs } from '@patternfly/react-core'; -import { - useAdminBareMetalInstanceCatalogItems, - useAdminSetBareMetalInstanceCatalogItemPublished, -} from '@osac/ui-components/api/v1/baremetal-instance'; -import { - useAdminClusterCatalogItems, - useAdminSetClusterCatalogItemPublished, -} from '@osac/ui-components/api/v1/cluster-catalog-item'; -import { - useAdminComputeInstanceCatalogItems, - useAdminSetComputeInstanceCatalogItemPublished, -} from '@osac/ui-components/api/v1/compute-instance-catalog-item'; +import { type PublicationFilter } from '@osac/ui-components/components/catalog/catalogItemDisplay'; import ListPage from '@osac/ui-components/components/Page/ListPage'; import { useSession } from '@osac/ui-components/hooks/use-session'; import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; -import CatalogManagementTabPanel, { - type CatalogManagementTabKey, - type PublicationFilter, -} from './CatalogManagementTabPanel'; +import BareMetalInstanceCatalogManagementPanel from './BareMetalInstanceCatalogManagementPanel'; +import ClusterCatalogManagementPanel from './ClusterCatalogManagementPanel'; +import ComputeInstanceCatalogManagementPanel from './ComputeInstanceCatalogManagementPanel'; + +type CatalogManagementTabKey = 'cluster' | 'compute-instance' | 'baremetal-instance'; const CatalogManagementListPage = () => { const { t } = useTranslation(); @@ -29,20 +19,6 @@ const CatalogManagementListPage = () => { const [search, setSearch] = useState(''); const [publicationFilter, setPublicationFilter] = useState('all'); - const clusterItems = useAdminClusterCatalogItems(undefined, activeTab === 'cluster'); - const computeInstanceItems = useAdminComputeInstanceCatalogItems( - undefined, - activeTab === 'compute-instance', - ); - const bareMetalItems = useAdminBareMetalInstanceCatalogItems( - undefined, - activeTab === 'baremetal-instance', - ); - - const setClusterPublished = useAdminSetClusterCatalogItemPublished(); - const setComputeInstancePublished = useAdminSetComputeInstanceCatalogItemPublished(); - const setBareMetalPublished = useAdminSetBareMetalInstanceCatalogItemPublished(); - const sharedPanelProps = { search, setSearch, publicationFilter, setPublicationFilter, role }; return ( @@ -53,32 +29,20 @@ const CatalogManagementListPage = () => { aria-label={t('Catalog management resource type tabs')} > {t('Clusters')}}> - + {t('Virtual Machines')}} > - {t('Bare Metal')}}> - diff --git a/libs/ui-components/src/pages/admin/CatalogManagementTabPanel.tsx b/libs/ui-components/src/pages/admin/ClusterCatalogManagementPanel.tsx similarity index 59% rename from libs/ui-components/src/pages/admin/CatalogManagementTabPanel.tsx rename to libs/ui-components/src/pages/admin/ClusterCatalogManagementPanel.tsx index 88bd17e5..30212044 100644 --- a/libs/ui-components/src/pages/admin/CatalogManagementTabPanel.tsx +++ b/libs/ui-components/src/pages/admin/ClusterCatalogManagementPanel.tsx @@ -5,44 +5,33 @@ import { EmptyStateBody, Flex, FlexItem, + Gallery, + GalleryItem, SearchInput, Stack, StackItem, + Title, ToggleGroup, ToggleGroupItem, } from '@patternfly/react-core'; -import type { UseQueryResult } from '@tanstack/react-query'; -import type { CatalogItem } from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import { useClusterCatalogItems } from '@osac/ui-components/api/v1/cluster-catalog-item'; +import { usePrivateClusterCatalogItems } from '@osac/ui-components/api/v1/private/cluster-catalog-item'; +import CatalogItemCard from '@osac/ui-components/components/catalog/CatalogItemCard'; import { + type PublicationFilter, catalogItemScope, filterCatalogItemsBySearch, + matchesPublicationFilter, } from '@osac/ui-components/components/catalog/catalogItemDisplay'; -import { CatalogItemListSection } from '@osac/ui-components/components/catalog/CatalogItemListSection'; -import CatalogItemPublishToggle from '@osac/ui-components/components/catalogManagement/CatalogItemPublishToggle'; import CatalogItemScopeBadge from '@osac/ui-components/components/catalogManagement/CatalogItemScopeBadge'; import CatalogItemStatusLabel from '@osac/ui-components/components/catalogManagement/CatalogItemStatusLabel'; +import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; import type { DemoShellRole } from '@osac/ui-components/shellTypes'; -export type CatalogManagementTabKey = 'cluster' | 'compute-instance' | 'baremetal-instance'; -export type PublicationFilter = 'all' | 'published' | 'unpublished'; - -const matchesPublicationFilter = (item: CatalogItem, filter: PublicationFilter): boolean => { - if (filter === 'published') { - return item.published; - } - if (filter === 'unpublished') { - return !item.published; - } - return true; -}; - -interface CatalogManagementTabPanelProps { - tabKey: CatalogManagementTabKey; - title: string; - result: UseQueryResult; - setPublished: (input: { id: string; published: boolean }) => void; +interface ClusterCatalogManagementPanelProps { + isActive: boolean; search: string; setSearch: (value: string) => void; publicationFilter: PublicationFilter; @@ -50,20 +39,20 @@ interface CatalogManagementTabPanelProps { role: DemoShellRole; } -const CatalogManagementTabPanel = ({ - tabKey, - title, - result, - setPublished, +const ClusterCatalogManagementPanel = ({ + isActive, search, setSearch, publicationFilter, setPublicationFilter, role, -}: CatalogManagementTabPanelProps) => { +}: ClusterCatalogManagementPanelProps) => { const { t } = useTranslation(); const navigate = useNavigate(); - const { data = [], isLoading, error } = result; + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = useClusterCatalogItems(undefined, isActive && !isProviderAdmin); + const privateResult = usePrivateClusterCatalogItems(undefined, isActive && isProviderAdmin); + const { data = [], isLoading, error, isSuccess } = isProviderAdmin ? privateResult : publicResult; const filteredItems = filterCatalogItemsBySearch(data, search).filter((item) => matchesPublicationFilter(item, publicationFilter), @@ -76,10 +65,10 @@ const CatalogManagementTabPanel = ({ ]; const isFiltered = search.trim().length > 0 || publicationFilter !== 'all'; - // `result.isSuccess` (not just `!isLoading`) guards against a disabled, not-yet-fetched query on - // an inactive tab — those report `isLoading: false` with no data, which would otherwise show this + // `isSuccess` (not just `!isLoading`) guards against a disabled, not-yet-fetched query on an + // inactive tab — those report `isLoading: false` with no data, which would otherwise show this // tab as empty before it has ever actually fetched. - const showEmptyState = result.isSuccess && !error && filteredItems.length === 0; + const showEmptyState = isSuccess && !error && filteredItems.length === 0; return ( @@ -122,7 +111,7 @@ const CatalogManagementTabPanel = ({
- @@ -139,31 +128,32 @@ const CatalogManagementTabPanel = ({
) : ( - navigate(`/admin/catalog/${tabKey}/${item.id}`)} - renderCardAddons={(item) => { - const scope = catalogItemScope(item, role); - const isToggleDisabled = role === 'tenantAdmin' && scope.level === 'general'; - return { - scopeBadge: , - statusLabel: , - publishToggle: ( - setPublished({ id: item.id, published })} - /> - ), - }; - }} - /> + + + + + {t('Clusters')} + + + + + {filteredItems.map((item) => ( + + navigate(`/admin/catalog/cluster/${item.id}`)} + scopeBadge={} + statusLabel={} + /> + + ))} + + + + )} ); }; -export default CatalogManagementTabPanel; +export default ClusterCatalogManagementPanel; diff --git a/libs/ui-components/src/pages/admin/ComputeInstanceCatalogManagementPanel.tsx b/libs/ui-components/src/pages/admin/ComputeInstanceCatalogManagementPanel.tsx new file mode 100644 index 00000000..16d2e9b4 --- /dev/null +++ b/libs/ui-components/src/pages/admin/ComputeInstanceCatalogManagementPanel.tsx @@ -0,0 +1,162 @@ +import { useNavigate } from 'react-router-dom'; +import { + Button, + EmptyState, + EmptyStateBody, + Flex, + FlexItem, + Gallery, + GalleryItem, + SearchInput, + Stack, + StackItem, + Title, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core'; + +import { useComputeInstanceCatalogItems } from '@osac/ui-components/api/v1/compute-instance-catalog-item'; +import { usePrivateComputeInstanceCatalogItems } from '@osac/ui-components/api/v1/private/compute-instance-catalog-item'; +import CatalogItemCard from '@osac/ui-components/components/catalog/CatalogItemCard'; +import { + type PublicationFilter, + catalogItemScope, + filterCatalogItemsBySearch, + matchesPublicationFilter, +} from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import CatalogItemScopeBadge from '@osac/ui-components/components/catalogManagement/CatalogItemScopeBadge'; +import CatalogItemStatusLabel from '@osac/ui-components/components/catalogManagement/CatalogItemStatusLabel'; +import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import type { DemoShellRole } from '@osac/ui-components/shellTypes'; + +interface ComputeInstanceCatalogManagementPanelProps { + isActive: boolean; + search: string; + setSearch: (value: string) => void; + publicationFilter: PublicationFilter; + setPublicationFilter: (value: PublicationFilter) => void; + role: DemoShellRole; +} + +const ComputeInstanceCatalogManagementPanel = ({ + isActive, + search, + setSearch, + publicationFilter, + setPublicationFilter, + role, +}: ComputeInstanceCatalogManagementPanelProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = useComputeInstanceCatalogItems(undefined, isActive && !isProviderAdmin); + const privateResult = usePrivateComputeInstanceCatalogItems( + undefined, + isActive && isProviderAdmin, + ); + const { data = [], isLoading, error, isSuccess } = isProviderAdmin ? privateResult : publicResult; + + const filteredItems = filterCatalogItemsBySearch(data, search).filter((item) => + matchesPublicationFilter(item, publicationFilter), + ); + + const publicationFilters: ReadonlyArray<{ value: PublicationFilter; label: string }> = [ + { value: 'all', label: t('All') }, + { value: 'published', label: t('Published') }, + { value: 'unpublished', label: t('Unpublished') }, + ]; + + const isFiltered = search.trim().length > 0 || publicationFilter !== 'all'; + const showEmptyState = isSuccess && !error && filteredItems.length === 0; + + return ( + + + + + + + setSearch(value)} + onClear={() => setSearch('')} + aria-label={t('Filter catalog by keyword')} + isDisabled={isLoading || !!error} + /> + + + + {publicationFilters.map((option) => ( + setPublicationFilter(option.value)} + /> + ))} + + + + + + + + + + {showEmptyState ? ( + + + + {isFiltered + ? t('No catalog items match your search or filter.') + : t('No catalog items have been created yet.')} + + + + ) : ( + + + + + {t('Virtual Machines')} + + + + + {filteredItems.map((item) => ( + + navigate(`/admin/catalog/compute-instance/${item.id}`)} + scopeBadge={} + statusLabel={} + /> + + ))} + + + + + )} + + ); +}; + +export default ComputeInstanceCatalogManagementPanel; From ba422a1cbd1a40d3e29ac848a33442b0df89e63f Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Mon, 27 Jul 2026 16:55:05 +0300 Subject: [PATCH 11/12] OSAC-2932: fix formatting after merge with main Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/ui-components/src/api/v1/baremetal-instance.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 087e3215..23a46fcc 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -5,15 +5,19 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { BareMetalInstanceCatalogItem } from '@osac/types'; -import { BareMetalInstanceCatalogItems, BareMetalInstanceRunStrategy, BareMetalInstances } from '@osac/types'; +import { + BareMetalInstanceCatalogItems, + BareMetalInstanceRunStrategy, + BareMetalInstances, +} from '@osac/types'; import { type PatchBareMetalInstanceInput, useBareMetalInstanceCatalogItems, usePatchBareMetalInstance, } from './baremetal-instance'; -import { ApiProvider } from '../api-context'; import { renderHookWithProviders } from '../../test-utils/TestProviders'; +import { ApiProvider } from '../api-context'; const item: BareMetalInstanceCatalogItem = { $typeName: 'osac.public.v1.BareMetalInstanceCatalogItem', From f3fcf0b5b4683659f95269b51cafc07d1cc441a1 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Mon, 27 Jul 2026 17:33:03 +0300 Subject: [PATCH 12/12] OSAC-2932: extract shared catalog hook test factory CodeRabbit flagged that the "fetches items" / "does not fetch when disabled" test pair was copy-pasted across all five catalog-item list hook test files (public and private, all three kinds), differing only in hook/service/role. Extracts createCatalogHookTests, which owns the two test bodies while each call site supplies its own concretely-typed registerList callback, so the mock service registration stays fully type-checked against the real Connect service descriptor. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/api/v1/baremetal-instance.test.ts | 38 +++---------- .../src/api/v1/cluster-catalog-item.test.ts | 42 ++++---------- .../v1/compute-instance-catalog-item.test.ts | 42 ++++---------- .../baremetal-instance-catalog-item.test.ts | 42 ++++---------- .../v1/private/cluster-catalog-item.test.ts | 42 ++++---------- .../compute-instance-catalog-item.test.ts | 42 ++++---------- .../src/test-utils/catalogHookTestHelpers.ts | 56 +++++++++++++++++++ 7 files changed, 115 insertions(+), 189 deletions(-) create mode 100644 libs/ui-components/src/test-utils/catalogHookTestHelpers.ts 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 23a46fcc..fe0e21ca 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -16,7 +16,7 @@ import { useBareMetalInstanceCatalogItems, usePatchBareMetalInstance, } from './baremetal-instance'; -import { renderHookWithProviders } from '../../test-utils/TestProviders'; +import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers'; import { ApiProvider } from '../api-context'; const item: BareMetalInstanceCatalogItem = { @@ -30,38 +30,18 @@ const item: BareMetalInstanceCatalogItem = { }; describe('useBareMetalInstanceCatalogItems', () => { - it('fetches items from the public BareMetalInstanceCatalogItems List endpoint', async () => { - const transport = createRouterTransport((router) => { - router.service(BareMetalInstanceCatalogItems, { list: () => ({ items: [item] }) }); - }); - - const { result } = renderHookWithProviders(() => useBareMetalInstanceCatalogItems(), { - role: 'tenantAdmin', - transport, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([item]); - }); - - it('does not fetch when disabled', async () => { - let listCalled = false; - const transport = createRouterTransport((router) => { + createCatalogHookTests({ + endpointDescription: 'public BareMetalInstanceCatalogItems', + useHook: useBareMetalInstanceCatalogItems, + role: 'tenantAdmin', + item, + registerList: (router, onList) => router.service(BareMetalInstanceCatalogItems, { list: () => { - listCalled = true; + onList?.(); return { items: [item] }; }, - }); - }); - - renderHookWithProviders(() => useBareMetalInstanceCatalogItems({}, false), { - role: 'tenantAdmin', - transport, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(listCalled).toBe(false); + }), }); }); 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 30b19b45..43cb0967 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,12 +1,10 @@ -import { createRouterTransport } from '@connectrpc/connect'; -import { waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe } from 'vitest'; import type { ClusterCatalogItem } from '@osac/types'; import { ClusterCatalogItems } from '@osac/types'; import { useClusterCatalogItems } from './cluster-catalog-item'; -import { renderHookWithProviders } from '../../test-utils/TestProviders'; +import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers'; const item: ClusterCatalogItem = { $typeName: 'osac.public.v1.ClusterCatalogItem', @@ -19,37 +17,17 @@ const item: ClusterCatalogItem = { }; describe('useClusterCatalogItems', () => { - it('fetches items from the public ClusterCatalogItems List endpoint', async () => { - const transport = createRouterTransport((router) => { - router.service(ClusterCatalogItems, { list: () => ({ items: [item] }) }); - }); - - const { result } = renderHookWithProviders(() => useClusterCatalogItems(), { - role: 'tenantAdmin', - transport, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([item]); - }); - - it('does not fetch when disabled', async () => { - let listCalled = false; - const transport = createRouterTransport((router) => { + createCatalogHookTests({ + endpointDescription: 'public ClusterCatalogItems', + useHook: useClusterCatalogItems, + role: 'tenantAdmin', + item, + registerList: (router, onList) => router.service(ClusterCatalogItems, { list: () => { - listCalled = true; + onList?.(); return { items: [item] }; }, - }); - }); - - renderHookWithProviders(() => useClusterCatalogItems({}, false), { - role: 'tenantAdmin', - transport, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(listCalled).toBe(false); + }), }); }); 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 6cb52f1d..4af1d13b 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,12 +1,10 @@ -import { createRouterTransport } from '@connectrpc/connect'; -import { waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe } from 'vitest'; import type { ComputeInstanceCatalogItem } from '@osac/types'; import { ComputeInstanceCatalogItems } from '@osac/types'; import { useComputeInstanceCatalogItems } from './compute-instance-catalog-item'; -import { renderHookWithProviders } from '../../test-utils/TestProviders'; +import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers'; const item: ComputeInstanceCatalogItem = { $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', @@ -19,37 +17,17 @@ const item: ComputeInstanceCatalogItem = { }; describe('useComputeInstanceCatalogItems', () => { - it('fetches items from the public ComputeInstanceCatalogItems List endpoint', async () => { - const transport = createRouterTransport((router) => { - router.service(ComputeInstanceCatalogItems, { list: () => ({ items: [item] }) }); - }); - - const { result } = renderHookWithProviders(() => useComputeInstanceCatalogItems(), { - role: 'tenantAdmin', - transport, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([item]); - }); - - it('does not fetch when disabled', async () => { - let listCalled = false; - const transport = createRouterTransport((router) => { + createCatalogHookTests({ + endpointDescription: 'public ComputeInstanceCatalogItems', + useHook: useComputeInstanceCatalogItems, + role: 'tenantAdmin', + item, + registerList: (router, onList) => router.service(ComputeInstanceCatalogItems, { list: () => { - listCalled = true; + onList?.(); return { items: [item] }; }, - }); - }); - - renderHookWithProviders(() => useComputeInstanceCatalogItems({}, false), { - role: 'tenantAdmin', - transport, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(listCalled).toBe(false); + }), }); }); diff --git a/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts index a81d4f30..1f0b0b89 100644 --- a/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts +++ b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts @@ -1,12 +1,10 @@ -import { createRouterTransport } from '@connectrpc/connect'; -import { waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe } from 'vitest'; import type { BareMetalInstanceCatalogItem } from '@osac/types/private'; import { BareMetalInstanceCatalogItems } from '@osac/types/private'; import { usePrivateBareMetalInstanceCatalogItems } from './baremetal-instance-catalog-item'; -import { renderHookWithProviders } from '../../../test-utils/TestProviders'; +import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; const item: BareMetalInstanceCatalogItem = { $typeName: 'osac.private.v1.BareMetalInstanceCatalogItem', @@ -20,37 +18,17 @@ const item: BareMetalInstanceCatalogItem = { }; describe('usePrivateBareMetalInstanceCatalogItems', () => { - it('fetches items from the private BareMetalInstanceCatalogItems List endpoint', async () => { - const transport = createRouterTransport((router) => { - router.service(BareMetalInstanceCatalogItems, { list: () => ({ items: [item] }) }); - }); - - const { result } = renderHookWithProviders(() => usePrivateBareMetalInstanceCatalogItems(), { - role: 'providerAdmin', - transport, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([item]); - }); - - it('does not fetch when disabled', async () => { - let listCalled = false; - const transport = createRouterTransport((router) => { + createCatalogHookTests({ + endpointDescription: 'private BareMetalInstanceCatalogItems', + useHook: usePrivateBareMetalInstanceCatalogItems, + role: 'providerAdmin', + item, + registerList: (router, onList) => router.service(BareMetalInstanceCatalogItems, { list: () => { - listCalled = true; + onList?.(); return { items: [item] }; }, - }); - }); - - renderHookWithProviders(() => usePrivateBareMetalInstanceCatalogItems({}, false), { - role: 'providerAdmin', - transport, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(listCalled).toBe(false); + }), }); }); diff --git a/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts index afdf7931..52da0d79 100644 --- a/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts +++ b/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts @@ -1,12 +1,10 @@ -import { createRouterTransport } from '@connectrpc/connect'; -import { waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe } from 'vitest'; import type { ClusterCatalogItem } from '@osac/types/private'; import { ClusterCatalogItems } from '@osac/types/private'; import { usePrivateClusterCatalogItems } from './cluster-catalog-item'; -import { renderHookWithProviders } from '../../../test-utils/TestProviders'; +import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; const item: ClusterCatalogItem = { $typeName: 'osac.private.v1.ClusterCatalogItem', @@ -20,37 +18,17 @@ const item: ClusterCatalogItem = { }; describe('usePrivateClusterCatalogItems', () => { - it('fetches items from the private ClusterCatalogItems List endpoint', async () => { - const transport = createRouterTransport((router) => { - router.service(ClusterCatalogItems, { list: () => ({ items: [item] }) }); - }); - - const { result } = renderHookWithProviders(() => usePrivateClusterCatalogItems(), { - role: 'providerAdmin', - transport, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([item]); - }); - - it('does not fetch when disabled', async () => { - let listCalled = false; - const transport = createRouterTransport((router) => { + createCatalogHookTests({ + endpointDescription: 'private ClusterCatalogItems', + useHook: usePrivateClusterCatalogItems, + role: 'providerAdmin', + item, + registerList: (router, onList) => router.service(ClusterCatalogItems, { list: () => { - listCalled = true; + onList?.(); return { items: [item] }; }, - }); - }); - - renderHookWithProviders(() => usePrivateClusterCatalogItems({}, false), { - role: 'providerAdmin', - transport, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(listCalled).toBe(false); + }), }); }); diff --git a/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts index 105f98b6..cbdad046 100644 --- a/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts +++ b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts @@ -1,12 +1,10 @@ -import { createRouterTransport } from '@connectrpc/connect'; -import { waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe } from 'vitest'; import type { ComputeInstanceCatalogItem } from '@osac/types/private'; import { ComputeInstanceCatalogItems } from '@osac/types/private'; import { usePrivateComputeInstanceCatalogItems } from './compute-instance-catalog-item'; -import { renderHookWithProviders } from '../../../test-utils/TestProviders'; +import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; const item: ComputeInstanceCatalogItem = { $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', @@ -20,37 +18,17 @@ const item: ComputeInstanceCatalogItem = { }; describe('usePrivateComputeInstanceCatalogItems', () => { - it('fetches items from the private ComputeInstanceCatalogItems List endpoint', async () => { - const transport = createRouterTransport((router) => { - router.service(ComputeInstanceCatalogItems, { list: () => ({ items: [item] }) }); - }); - - const { result } = renderHookWithProviders(() => usePrivateComputeInstanceCatalogItems(), { - role: 'providerAdmin', - transport, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([item]); - }); - - it('does not fetch when disabled', async () => { - let listCalled = false; - const transport = createRouterTransport((router) => { + createCatalogHookTests({ + endpointDescription: 'private ComputeInstanceCatalogItems', + useHook: usePrivateComputeInstanceCatalogItems, + role: 'providerAdmin', + item, + registerList: (router, onList) => router.service(ComputeInstanceCatalogItems, { list: () => { - listCalled = true; + onList?.(); return { items: [item] }; }, - }); - }); - - renderHookWithProviders(() => usePrivateComputeInstanceCatalogItems({}, false), { - role: 'providerAdmin', - transport, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(listCalled).toBe(false); + }), }); }); diff --git a/libs/ui-components/src/test-utils/catalogHookTestHelpers.ts b/libs/ui-components/src/test-utils/catalogHookTestHelpers.ts new file mode 100644 index 00000000..7a29f934 --- /dev/null +++ b/libs/ui-components/src/test-utils/catalogHookTestHelpers.ts @@ -0,0 +1,56 @@ +import { createRouterTransport } from '@connectrpc/connect'; +import type { ConnectRouter } from '@connectrpc/connect'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { waitFor } from '@testing-library/react'; +import { expect, it } from 'vitest'; + +import { renderHookWithProviders } from './TestProviders'; +import type { ListParams } from '../api/types'; +import type { DemoShellRole } from '../shellTypes'; + +interface CatalogHookTestConfig { + /** Human-readable endpoint description used in the generated test name, e.g. "public ClusterCatalogItems". */ + endpointDescription: string; + useHook: (params?: ListParams, enabled?: boolean) => UseQueryResult; + role: DemoShellRole; + item: TItem; + /** Registers the mock service on the router; call `onList` when the List RPC is invoked. */ + registerList: (router: ConnectRouter, onList?: () => void) => void; +} + +/** + * Shared "fetches items from the List endpoint" + "does not fetch when disabled" test pair for the + * per-kind catalog-item list hooks (public and private). Each call site keeps its own concretely-typed + * `registerList` callback so the mock service registration stays fully type-checked against the real + * Connect service descriptor. + */ +export const createCatalogHookTests = ({ + endpointDescription, + useHook, + role, + item, + registerList, +}: CatalogHookTestConfig) => { + it(`fetches items from the ${endpointDescription} List endpoint`, async () => { + const transport = createRouterTransport((router) => registerList(router)); + + const { result } = renderHookWithProviders(() => useHook(), { role, transport }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([item]); + }); + + it('does not fetch when disabled', async () => { + let listCalled = false; + const transport = createRouterTransport((router) => + registerList(router, () => { + listCalled = true; + }), + ); + + renderHookWithProviders(() => useHook({}, false), { role, transport }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); +};