From a2a9ba4378229177e6a4d4b3ef8ad22f23689bc7 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 15:12:55 +0300 Subject: [PATCH 01/22] OSAC-2934: extract escapeCelStringLiteral into shared api/cel module Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/ui-components/src/api/cel.test.ts | 13 +++++++++++++ libs/ui-components/src/api/cel.ts | 2 ++ libs/ui-components/src/api/v1/networking.test.ts | 9 --------- libs/ui-components/src/api/v1/networking.ts | 4 +--- 4 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 libs/ui-components/src/api/cel.test.ts create mode 100644 libs/ui-components/src/api/cel.ts diff --git a/libs/ui-components/src/api/cel.test.ts b/libs/ui-components/src/api/cel.test.ts new file mode 100644 index 00000000..722f910b --- /dev/null +++ b/libs/ui-components/src/api/cel.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { escapeCelStringLiteral } from './cel'; + +describe('escapeCelStringLiteral', () => { + it('escapes embedded quotes for CEL string literals', () => { + expect(escapeCelStringLiteral('say "hello"')).toBe('say \\"hello\\"'); + }); + + it('escapes backslashes for CEL string literals', () => { + expect(escapeCelStringLiteral('path\\to\\thing')).toBe('path\\\\to\\\\thing'); + }); +}); diff --git a/libs/ui-components/src/api/cel.ts b/libs/ui-components/src/api/cel.ts new file mode 100644 index 00000000..540091f9 --- /dev/null +++ b/libs/ui-components/src/api/cel.ts @@ -0,0 +1,2 @@ +export const escapeCelStringLiteral = (value: string): string => + value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); diff --git a/libs/ui-components/src/api/v1/networking.test.ts b/libs/ui-components/src/api/v1/networking.test.ts index 6f5862d2..7b57b0fd 100644 --- a/libs/ui-components/src/api/v1/networking.test.ts +++ b/libs/ui-components/src/api/v1/networking.test.ts @@ -5,7 +5,6 @@ import { SecurityGroupState, SubnetState, VirtualNetworkState } from '@osac/type import { VIRTUAL_NETWORK_READY_LIST_FILTER, - escapeCelStringLiteral, invalidateSecurityGroupsQueries, invalidateSubnetsQueries, invalidateVirtualNetworksQueries, @@ -21,14 +20,6 @@ describe('networking list filters', () => { ); }); - it('escapes embedded quotes for CEL string literals', () => { - expect(escapeCelStringLiteral('say "hello"')).toBe('say \\"hello\\"'); - }); - - it('escapes backslashes for CEL string literals', () => { - expect(escapeCelStringLiteral('path\\to\\thing')).toBe('path\\\\to\\\\thing'); - }); - it('combines virtual network scope and ready state for subnets', () => { expect(virtualNetworkFilterForSubnetList('vn-1')).toBe( `(this.spec.virtual_network == "vn-1") && (this.status.state == ${SubnetState.READY})`, diff --git a/libs/ui-components/src/api/v1/networking.ts b/libs/ui-components/src/api/v1/networking.ts index af421235..fa8223b1 100644 --- a/libs/ui-components/src/api/v1/networking.ts +++ b/libs/ui-components/src/api/v1/networking.ts @@ -13,6 +13,7 @@ import { } from '@osac/types'; import { useApiFetch } from '../api-context'; +import { escapeCelStringLiteral } from '../cel'; import { type ListParams, apiQueryKey } from '../types'; import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query'; @@ -90,9 +91,6 @@ const combineListFilters = (...parts: string[]): string => { return parts.map((part) => `(${part})`).join(' && '); }; -export const escapeCelStringLiteral = (value: string): string => - value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); - const virtualNetworkScopeFilter = (virtualNetworkId: string): string => `this.spec.virtual_network == "${escapeCelStringLiteral(virtualNetworkId)}"`; From 134e0b1a28e72d1bab3ab0200762c2e5156c369b Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 15:21:43 +0300 Subject: [PATCH 02/22] OSAC-2934: add paginated provisioned-resources hooks per catalog item kind Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/ui-components/src/api/cel.test.ts | 16 +++- libs/ui-components/src/api/cel.ts | 3 + .../src/api/v1/baremetal-instance.test.ts | 75 ++++++++++++++++ .../src/api/v1/baremetal-instance.ts | 16 ++++ libs/ui-components/src/api/v1/cluster.test.ts | 88 +++++++++++++++++++ libs/ui-components/src/api/v1/cluster.ts | 16 ++++ .../src/api/v1/compute-instance.test.ts | 83 ++++++++++++++++- .../src/api/v1/compute-instance.ts | 16 ++++ 8 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 libs/ui-components/src/api/v1/cluster.test.ts diff --git a/libs/ui-components/src/api/cel.test.ts b/libs/ui-components/src/api/cel.test.ts index 722f910b..9d6c3604 100644 --- a/libs/ui-components/src/api/cel.test.ts +++ b/libs/ui-components/src/api/cel.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { escapeCelStringLiteral } from './cel'; +import { catalogItemProvisionedResourcesFilter, escapeCelStringLiteral } from './cel'; describe('escapeCelStringLiteral', () => { it('escapes embedded quotes for CEL string literals', () => { @@ -11,3 +11,17 @@ describe('escapeCelStringLiteral', () => { expect(escapeCelStringLiteral('path\\to\\thing')).toBe('path\\\\to\\\\thing'); }); }); + +describe('catalogItemProvisionedResourcesFilter', () => { + it('filters resources by catalog item id', () => { + expect(catalogItemProvisionedResourcesFilter('catalog-1')).toBe( + 'this.spec.catalog_item == "catalog-1"', + ); + }); + + it('escapes CEL injection characters in the catalog item id', () => { + expect(catalogItemProvisionedResourcesFilter(`"'] || true || this.id in ['`)).toBe( + `this.spec.catalog_item == "\\"'] || true || this.id in ['"`, + ); + }); +}); diff --git a/libs/ui-components/src/api/cel.ts b/libs/ui-components/src/api/cel.ts index 540091f9..e496cdb5 100644 --- a/libs/ui-components/src/api/cel.ts +++ b/libs/ui-components/src/api/cel.ts @@ -1,2 +1,5 @@ export const escapeCelStringLiteral = (value: string): string => value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + +export const catalogItemProvisionedResourcesFilter = (catalogItemId: string): string => + `this.spec.catalog_item == "${escapeCelStringLiteral(catalogItemId)}"`; diff --git a/libs/ui-components/src/api/v1/baremetal-instance.test.ts b/libs/ui-components/src/api/v1/baremetal-instance.test.ts index fe0e21ca..127eb9c6 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -14,9 +14,11 @@ import { import { type PatchBareMetalInstanceInput, useBareMetalInstanceCatalogItems, + useBareMetalInstancesForCatalogItem, usePatchBareMetalInstance, } from './baremetal-instance'; import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers'; +import { renderHookWithProviders } from '../../test-utils/TestProviders'; import { ApiProvider } from '../api-context'; const item: BareMetalInstanceCatalogItem = { @@ -140,3 +142,76 @@ describe('usePatchBareMetalInstance', () => { expect(object?.spec?.restartTrigger).toBe(4n); }); }); + +describe('useBareMetalInstancesForCatalogItem', () => { + const createTestTransport = (onList: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(BareMetalInstances, { + list: (req) => { + onList(req); + return { items: [makeBmi('bmi-1')], total: 1, size: 1 }; + }, + }); + }); + + it('filters by catalog item id and forwards pagination params', async () => { + let captured: Record | undefined; + const transport = createTestTransport((req) => { + captured = req as Record; + }); + + const { result } = renderHookWithProviders( + () => useBareMetalInstancesForCatalogItem('catalog-1', { limit: 10, offset: 20 }), + { role: 'providerAdmin', transport }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(captured).toMatchObject({ + filter: 'this.spec.catalog_item == "catalog-1"', + limit: 10, + offset: 20, + }); + }); + + it('returns items and total from the list response', async () => { + const transport = createTestTransport(() => {}); + + const { result } = renderHookWithProviders( + () => useBareMetalInstancesForCatalogItem('catalog-1', {}), + { role: 'providerAdmin', transport }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject({ items: [makeBmi('bmi-1')], total: 1 }); + }); + + it('does not fetch when catalogItemId is empty', async () => { + let listCalled = false; + const transport = createTestTransport(() => { + listCalled = true; + }); + + renderHookWithProviders(() => useBareMetalInstancesForCatalogItem('', {}), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); + + it('does not fetch when catalogItemId is whitespace-only', async () => { + let listCalled = false; + const transport = createTestTransport(() => { + listCalled = true; + }); + + renderHookWithProviders(() => useBareMetalInstancesForCatalogItem(' ', {}), { + role: 'providerAdmin', + transport, + }); + + 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 7cd547e3..8703555e 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.ts @@ -9,6 +9,7 @@ import { } from '@osac/types'; import { useApiFetch } from '../api-context'; +import { catalogItemProvisionedResourcesFilter } from '../cel'; import { type ListParams, apiQueryKey } from '../types'; import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query'; import { buildUpdateMaskPaths } from './update-mask'; @@ -22,6 +23,21 @@ export const useBareMetalInstances = () => { }); }; +export const useBareMetalInstancesForCatalogItem = ( + catalogItemId: string, + params: Pick = {}, +) => { + const client = useApiFetch(BareMetalInstances); + const trimmedId = catalogItemId.trim(); + const filter = catalogItemProvisionedResourcesFilter(trimmedId); + return useApiQuery({ + queryKey: apiQueryKey('v1/baremetal_instances', undefined, { ...params, filter }), + queryFn: () => client.list({ ...params, filter }), + select: (data) => ({ items: data.items, total: data.total }), + enabled: Boolean(trimmedId), + }); +}; + export const useBareMetalInstance = (id: string) => { const client = useApiFetch(BareMetalInstances); return useApiQuery({ diff --git a/libs/ui-components/src/api/v1/cluster.test.ts b/libs/ui-components/src/api/v1/cluster.test.ts new file mode 100644 index 00000000..94b78fd5 --- /dev/null +++ b/libs/ui-components/src/api/v1/cluster.test.ts @@ -0,0 +1,88 @@ +import { createRouterTransport } from '@connectrpc/connect'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Clusters } from '@osac/types'; + +import { useClustersForCatalogItem } from './cluster'; +import { renderHookWithProviders } from '../../test-utils/TestProviders'; + +const makeCluster = (id: string) => ({ + id, + metadata: { name: `cluster-${id}` }, + spec: { catalogItem: 'catalog-1' }, + status: {}, +}); + +describe('useClustersForCatalogItem', () => { + const createTestTransport = (onList: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(Clusters, { + list: (req) => { + onList(req); + return { items: [makeCluster('cluster-1')], total: 1, size: 1 }; + }, + }); + }); + + it('filters by catalog item id and forwards pagination params', async () => { + let captured: Record | undefined; + const transport = createTestTransport((req) => { + captured = req as Record; + }); + + const { result } = renderHookWithProviders( + () => useClustersForCatalogItem('catalog-1', { limit: 10, offset: 20 }), + { role: 'providerAdmin', transport }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(captured).toMatchObject({ + filter: 'this.spec.catalog_item == "catalog-1"', + limit: 10, + offset: 20, + }); + }); + + it('returns items and total from the list response', async () => { + const transport = createTestTransport(() => {}); + + const { result } = renderHookWithProviders(() => useClustersForCatalogItem('catalog-1', {}), { + role: 'providerAdmin', + transport, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject({ items: [makeCluster('cluster-1')], total: 1 }); + }); + + it('does not fetch when catalogItemId is empty', async () => { + let listCalled = false; + const transport = createTestTransport(() => { + listCalled = true; + }); + + renderHookWithProviders(() => useClustersForCatalogItem('', {}), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); + + it('does not fetch when catalogItemId is whitespace-only', async () => { + let listCalled = false; + const transport = createTestTransport(() => { + listCalled = true; + }); + + renderHookWithProviders(() => useClustersForCatalogItem(' ', {}), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); +}); diff --git a/libs/ui-components/src/api/v1/cluster.ts b/libs/ui-components/src/api/v1/cluster.ts index c491b2a7..11b9fa3d 100644 --- a/libs/ui-components/src/api/v1/cluster.ts +++ b/libs/ui-components/src/api/v1/cluster.ts @@ -5,6 +5,7 @@ import { useMutation } from '@tanstack/react-query'; import { ClusterSchema, Clusters } from '@osac/types'; import { useApiFetch } from '../api-context'; +import { catalogItemProvisionedResourcesFilter } from '../cel'; import { type ListParams, apiQueryKey } from '../types'; import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query'; @@ -17,6 +18,21 @@ export const useClusters = (params: ListParams = {}) => { }); }; +export const useClustersForCatalogItem = ( + catalogItemId: string, + params: Pick = {}, +) => { + const client = useApiFetch(Clusters); + const trimmedId = catalogItemId.trim(); + const filter = catalogItemProvisionedResourcesFilter(trimmedId); + return useApiQuery({ + queryKey: apiQueryKey('v1/clusters', undefined, { ...params, filter }), + queryFn: () => client.list({ ...params, filter }), + select: (data) => ({ items: data.items, total: data.total }), + enabled: Boolean(trimmedId), + }); +}; + export const useCluster = (id: string) => { const client = useApiFetch(Clusters); const trimmedId = id?.trim() ?? ''; diff --git a/libs/ui-components/src/api/v1/compute-instance.test.ts b/libs/ui-components/src/api/v1/compute-instance.test.ts index 89ceb5c4..62854ca9 100644 --- a/libs/ui-components/src/api/v1/compute-instance.test.ts +++ b/libs/ui-components/src/api/v1/compute-instance.test.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from 'vitest'; import { ComputeInstanceState, ComputeInstances } from '@osac/types'; -import { usePatchComputeInstance } from './compute-instance'; +import { useComputeInstancesForCatalogItem, usePatchComputeInstance } from './compute-instance'; +import { renderHookWithProviders } from '../../test-utils/TestProviders'; import { ApiProvider } from '../api-context'; const makeVm = (id: string, state: ComputeInstanceState) => ({ @@ -90,3 +91,83 @@ describe('usePatchComputeInstance', () => { expect(req).toBeDefined(); }); }); + +describe('useComputeInstancesForCatalogItem', () => { + const createTestTransport = (onList: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(ComputeInstances, { + list: (req) => { + onList(req); + return { + items: [makeVm('vm-1', ComputeInstanceState.RUNNING)], + total: 1, + size: 1, + }; + }, + }); + }); + + it('filters by catalog item id and forwards pagination params', async () => { + let captured: Record | undefined; + const transport = createTestTransport((req) => { + captured = req as Record; + }); + + const { result } = renderHookWithProviders( + () => useComputeInstancesForCatalogItem('catalog-1', { limit: 10, offset: 20 }), + { role: 'providerAdmin', transport }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(captured).toMatchObject({ + filter: 'this.spec.catalog_item == "catalog-1"', + limit: 10, + offset: 20, + }); + }); + + it('returns items and total from the list response', async () => { + const transport = createTestTransport(() => {}); + + const { result } = renderHookWithProviders( + () => useComputeInstancesForCatalogItem('catalog-1', {}), + { role: 'providerAdmin', transport }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject({ + items: [makeVm('vm-1', ComputeInstanceState.RUNNING)], + total: 1, + }); + }); + + it('does not fetch when catalogItemId is empty', async () => { + let listCalled = false; + const transport = createTestTransport(() => { + listCalled = true; + }); + + renderHookWithProviders(() => useComputeInstancesForCatalogItem('', {}), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); + + it('does not fetch when catalogItemId is whitespace-only', async () => { + let listCalled = false; + const transport = createTestTransport(() => { + listCalled = true; + }); + + renderHookWithProviders(() => useComputeInstancesForCatalogItem(' ', {}), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); +}); diff --git a/libs/ui-components/src/api/v1/compute-instance.ts b/libs/ui-components/src/api/v1/compute-instance.ts index 5030d5ae..c1c30c8e 100644 --- a/libs/ui-components/src/api/v1/compute-instance.ts +++ b/libs/ui-components/src/api/v1/compute-instance.ts @@ -10,6 +10,7 @@ import { } from '@osac/types'; import { useApiFetch } from '../api-context'; +import { catalogItemProvisionedResourcesFilter } from '../cel'; import { type ListParams, apiQueryKey } from '../types'; import { buildUpdateMaskPaths } from './update-mask'; import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query'; @@ -23,6 +24,21 @@ export const useComputeInstances = (params: ListParams = {}) => { }); }; +export const useComputeInstancesForCatalogItem = ( + catalogItemId: string, + params: Pick = {}, +) => { + const client = useApiFetch(ComputeInstances); + const trimmedId = catalogItemId.trim(); + const filter = catalogItemProvisionedResourcesFilter(trimmedId); + return useApiQuery({ + queryKey: apiQueryKey('v1/compute_instances', undefined, { ...params, filter }), + queryFn: () => client.list({ ...params, filter }), + select: (data) => ({ items: data.items, total: data.total }), + enabled: Boolean(trimmedId), + }); +}; + export const useComputeInstance = (id: string) => { const client = useApiFetch(ComputeInstances); const trimmedId = id?.trim() ?? ''; From ded0ad2617f12dd2586233631b39bd97645d50c0 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 15:27:52 +0300 Subject: [PATCH 03/22] OSAC-2934: add missing single-item catalog item fetch hooks Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/api/v1/baremetal-instance.test.ts | 39 +++++++++++++++ .../src/api/v1/baremetal-instance.ts | 14 ++++++ .../baremetal-instance-catalog-item.test.ts | 48 ++++++++++++++++++- .../baremetal-instance-catalog-item.ts | 14 ++++++ .../v1/private/cluster-catalog-item.test.ts | 48 ++++++++++++++++++- .../api/v1/private/cluster-catalog-item.ts | 11 +++++ .../compute-instance-catalog-item.test.ts | 48 ++++++++++++++++++- .../private/compute-instance-catalog-item.ts | 14 ++++++ 8 files changed, 230 insertions(+), 6 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 127eb9c6..99ffd003 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -13,6 +13,7 @@ import { import { type PatchBareMetalInstanceInput, + useBareMetalInstanceCatalogItem, useBareMetalInstanceCatalogItems, useBareMetalInstancesForCatalogItem, usePatchBareMetalInstance, @@ -47,6 +48,44 @@ describe('useBareMetalInstanceCatalogItems', () => { }); }); +describe('useBareMetalInstanceCatalogItem', () => { + const createTestTransport = (onGet?: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(BareMetalInstanceCatalogItems, { + get: (req) => { + onGet?.(req); + return { object: item }; + }, + }); + }); + + it('fetches a single catalog item by id from the Get endpoint', async () => { + const transport = createTestTransport(); + const { result } = renderHookWithProviders(() => useBareMetalInstanceCatalogItem('public-1'), { + role: 'tenantAdmin', + transport, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject(item); + }); + + it('does not fetch when id is undefined', async () => { + let getCalled = false; + const transport = createTestTransport(() => { + getCalled = true; + }); + + renderHookWithProviders(() => useBareMetalInstanceCatalogItem(undefined), { + role: 'tenantAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getCalled).toBe(false); + }); +}); + const makeBmi = (id: string) => ({ id, metadata: { name: `bmi-${id}` }, diff --git a/libs/ui-components/src/api/v1/baremetal-instance.ts b/libs/ui-components/src/api/v1/baremetal-instance.ts index 8703555e..eb8e8b82 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.ts @@ -58,6 +58,20 @@ export const useBareMetalInstanceCatalogItems = (params: ListParams = {}, enable }); }; +export const useBareMetalInstanceCatalogItem = (id: string | undefined) => { + const client = useApiFetch(BareMetalInstanceCatalogItems); + const trimmedId = id?.trim() ?? ''; + return useApiQuery({ + queryKey: apiQueryKey( + 'v1/baremetal_instance_catalog_items', + trimmedId ? [trimmedId] : undefined, + ), + queryFn: () => client.get({ id: trimmedId }), + select: (data) => data.object, + enabled: Boolean(trimmedId), + }); +}; + export const invalidateBareMetalInstancesQueries = async (qc: ApiQueryClient) => { await qc.invalidateQueries({ queryKey: apiQueryKey('v1/baremetal_instances') }); }; 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 1f0b0b89..37baca9c 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,10 +1,16 @@ -import { describe } from 'vitest'; +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 { + usePrivateBareMetalInstanceCatalogItem, + usePrivateBareMetalInstanceCatalogItems, +} from './baremetal-instance-catalog-item'; import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; +import { renderHookWithProviders } from '../../../test-utils/TestProviders'; const item: BareMetalInstanceCatalogItem = { $typeName: 'osac.private.v1.BareMetalInstanceCatalogItem', @@ -32,3 +38,41 @@ describe('usePrivateBareMetalInstanceCatalogItems', () => { }), }); }); + +describe('usePrivateBareMetalInstanceCatalogItem', () => { + const createTestTransport = (onGet?: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(BareMetalInstanceCatalogItems, { + get: (req) => { + onGet?.(req); + return { object: item }; + }, + }); + }); + + it('fetches a single catalog item by id from the Get endpoint', async () => { + const transport = createTestTransport(); + const { result } = renderHookWithProviders( + () => usePrivateBareMetalInstanceCatalogItem('private-1'), + { role: 'providerAdmin', transport }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject(item); + }); + + it('does not fetch when id is undefined', async () => { + let getCalled = false; + const transport = createTestTransport(() => { + getCalled = true; + }); + + renderHookWithProviders(() => usePrivateBareMetalInstanceCatalogItem(undefined), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getCalled).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 index fcb209c2..20885b76 100644 --- 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 @@ -16,3 +16,17 @@ export const usePrivateBareMetalInstanceCatalogItems = ( enabled, }); }; + +export const usePrivateBareMetalInstanceCatalogItem = (id: string | undefined) => { + const client = useApiFetch(BareMetalInstanceCatalogItems); + const trimmedId = id?.trim() ?? ''; + return useApiQuery({ + queryKey: apiQueryKey( + 'v1/private/baremetal_instance_catalog_items', + trimmedId ? [trimmedId] : undefined, + ), + queryFn: () => client.get({ id: trimmedId }), + select: (data) => data.object, + enabled: Boolean(trimmedId), + }); +}; 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 52da0d79..b08b42b7 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,10 +1,16 @@ -import { describe } from 'vitest'; +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 { + usePrivateClusterCatalogItem, + usePrivateClusterCatalogItems, +} from './cluster-catalog-item'; import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; +import { renderHookWithProviders } from '../../../test-utils/TestProviders'; const item: ClusterCatalogItem = { $typeName: 'osac.private.v1.ClusterCatalogItem', @@ -32,3 +38,41 @@ describe('usePrivateClusterCatalogItems', () => { }), }); }); + +describe('usePrivateClusterCatalogItem', () => { + const createTestTransport = (onGet?: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(ClusterCatalogItems, { + get: (req) => { + onGet?.(req); + return { object: item }; + }, + }); + }); + + it('fetches a single catalog item by id from the Get endpoint', async () => { + const transport = createTestTransport(); + const { result } = renderHookWithProviders(() => usePrivateClusterCatalogItem('private-1'), { + role: 'providerAdmin', + transport, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject(item); + }); + + it('does not fetch when id is undefined', async () => { + let getCalled = false; + const transport = createTestTransport(() => { + getCalled = true; + }); + + renderHookWithProviders(() => usePrivateClusterCatalogItem(undefined), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getCalled).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 index b02f2035..14baf935 100644 --- a/libs/ui-components/src/api/v1/private/cluster-catalog-item.ts +++ b/libs/ui-components/src/api/v1/private/cluster-catalog-item.ts @@ -13,3 +13,14 @@ export const usePrivateClusterCatalogItems = (params: ListParams = {}, enabled = enabled, }); }; + +export const usePrivateClusterCatalogItem = (id: string | undefined) => { + const client = useApiFetch(ClusterCatalogItems); + const trimmedId = id?.trim() ?? ''; + return useApiQuery({ + queryKey: apiQueryKey('v1/private/cluster_catalog_items', trimmedId ? [trimmedId] : undefined), + queryFn: () => client.get({ id: trimmedId }), + select: (data) => data.object, + enabled: Boolean(trimmedId), + }); +}; 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 cbdad046..ecb0de87 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,10 +1,16 @@ -import { describe } from 'vitest'; +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 { + usePrivateComputeInstanceCatalogItem, + usePrivateComputeInstanceCatalogItems, +} from './compute-instance-catalog-item'; import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; +import { renderHookWithProviders } from '../../../test-utils/TestProviders'; const item: ComputeInstanceCatalogItem = { $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', @@ -32,3 +38,41 @@ describe('usePrivateComputeInstanceCatalogItems', () => { }), }); }); + +describe('usePrivateComputeInstanceCatalogItem', () => { + const createTestTransport = (onGet?: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(ComputeInstanceCatalogItems, { + get: (req) => { + onGet?.(req); + return { object: item }; + }, + }); + }); + + it('fetches a single catalog item by id from the Get endpoint', async () => { + const transport = createTestTransport(); + const { result } = renderHookWithProviders( + () => usePrivateComputeInstanceCatalogItem('private-1'), + { role: 'providerAdmin', transport }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject(item); + }); + + it('does not fetch when id is undefined', async () => { + let getCalled = false; + const transport = createTestTransport(() => { + getCalled = true; + }); + + renderHookWithProviders(() => usePrivateComputeInstanceCatalogItem(undefined), { + role: 'providerAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getCalled).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 index 70741ca2..a5e0a54f 100644 --- 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 @@ -13,3 +13,17 @@ export const usePrivateComputeInstanceCatalogItems = (params: ListParams = {}, e enabled, }); }; + +export const usePrivateComputeInstanceCatalogItem = (id: string | undefined) => { + const client = useApiFetch(ComputeInstanceCatalogItems); + const trimmedId = id?.trim() ?? ''; + return useApiQuery({ + queryKey: apiQueryKey( + 'v1/private/compute_instance_catalog_items', + trimmedId ? [trimmedId] : undefined, + ), + queryFn: () => client.get({ id: trimmedId }), + select: (data) => data.object, + enabled: Boolean(trimmedId), + }); +}; From 6ba2231a00850f7db6db5cf0a2740c29d97b91af Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 16:20:12 +0300 Subject: [PATCH 04/22] OSAC-2934: add SanitizedMarkdown component for catalog item descriptions Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/ui-components/package.json | 2 + .../Primitives/SanitizedMarkdown.test.tsx | 49 ++ .../Primitives/SanitizedMarkdown.tsx | 16 + pnpm-lock.yaml | 681 ++++++++++++++++++ 4 files changed, 748 insertions(+) create mode 100644 libs/ui-components/src/components/Primitives/SanitizedMarkdown.test.tsx create mode 100644 libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx diff --git a/libs/ui-components/package.json b/libs/ui-components/package.json index b91f0175..cdae5592 100644 --- a/libs/ui-components/package.json +++ b/libs/ui-components/package.json @@ -23,6 +23,8 @@ "ip-address": "^10.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", + "rehype-sanitize": "^6.0.0", "yup": "^1.7.1" }, "scripts": { diff --git a/libs/ui-components/src/components/Primitives/SanitizedMarkdown.test.tsx b/libs/ui-components/src/components/Primitives/SanitizedMarkdown.test.tsx new file mode 100644 index 00000000..e8ed8ad3 --- /dev/null +++ b/libs/ui-components/src/components/Primitives/SanitizedMarkdown.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import SanitizedMarkdown from './SanitizedMarkdown'; + +describe('SanitizedMarkdown', () => { + it('renders basic Markdown formatting', () => { + render({'**bold** and [a link](https://example.com)'}); + + expect(screen.getByText('bold').tagName).toBe('STRONG'); + const link = screen.getByRole('link', { name: 'a link' }); + expect(link).toHaveAttribute('href', 'https://example.com'); + }); + + it('does not render a script element for embedded script tags', () => { + const { container } = render( + {'before after'}, + ); + + expect(container.querySelector('script')).toBeNull(); + }); + + it('strips javascript: URLs from links', () => { + const { container } = render( + {'[click me](javascript:alert(1))'}, + ); + + expect(container.innerHTML.toLowerCase()).not.toContain('javascript:'); + }); + + it('strips javascript: URLs from images', () => { + const { container } = render( + {'![alt text](javascript:alert(1))'}, + ); + + expect(container.innerHTML.toLowerCase()).not.toContain('javascript:'); + }); + + it('does not throw on malformed Markdown', () => { + expect(() => + render({'[unterminated link(('}), + ).not.toThrow(); + }); + + it('renders nothing for empty content', () => { + const { container } = render({''}); + expect(container.textContent).toBe(''); + }); +}); diff --git a/libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx b/libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx new file mode 100644 index 00000000..c0e79f1c --- /dev/null +++ b/libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx @@ -0,0 +1,16 @@ +import ReactMarkdown from 'react-markdown'; +import rehypeSanitize from 'rehype-sanitize'; + +interface SanitizedMarkdownProps { + children: string; +} + +// No rehype-raw plugin is used, so react-markdown already drops embedded raw HTML as escaped +// text and restricts link/image URL protocols on its own. rehype-sanitize's schema is currently +// a defense-in-depth no-op on top of that — it becomes load-bearing (and must be reviewed) the +// moment rehype-raw or a plugin like remark-gfm that introduces id-based DOM clobbering is added. +const SanitizedMarkdown = ({ children }: SanitizedMarkdownProps) => ( + {children} +); + +export default SanitizedMarkdown; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ad77ed..2efb2ef3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,9 +214,15 @@ importers: react-i18next: specifier: ^15.0.0 version: 15.7.4(i18next@23.16.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.5) react-router-dom: specifier: ^7.0.0 version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 yup: specifier: ^1.7.1 version: 1.7.1 @@ -1188,12 +1194,21 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/hoist-non-react-statics@3.3.7': resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} peerDependencies: @@ -1205,6 +1220,12 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} @@ -1216,6 +1237,12 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.58.2': resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1275,6 +1302,9 @@ packages: resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1436,6 +1466,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1487,6 +1520,9 @@ packages: caniuse-lite@1.0.30001788: resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1499,6 +1535,18 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -1533,6 +1581,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -1652,6 +1703,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1681,6 +1735,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1841,6 +1898,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1856,6 +1916,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2039,6 +2102,15 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2055,6 +2127,9 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -2124,6 +2199,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + inquirer@14.0.2: resolution: {integrity: sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -2145,6 +2223,12 @@ packages: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2181,6 +2265,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2197,6 +2284,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -2428,6 +2518,9 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2453,6 +2546,93 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -2594,6 +2774,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -2679,6 +2862,9 @@ packages: property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -2741,6 +2927,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-router-dom@7.14.1: resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==} engines: {node: '>=20.0.0'} @@ -2786,6 +2978,15 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2910,6 +3111,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2951,6 +3155,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -2971,6 +3178,12 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -3051,6 +3264,12 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -3118,6 +3337,24 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3132,6 +3369,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-area@37.3.6: resolution: {integrity: sha512-wVC8LKrZJLiSySNuJLRCB449qZTsPiRyzLlNoJwe21y+XA/a2HJbmJSeywmo8P153aX8viKe1H8ygDsTFXQhHw==} engines: {node: '>=18.0.0'} @@ -3522,6 +3765,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@adobe/css-tools@4.5.0': {} @@ -4304,10 +4550,22 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -4317,6 +4575,12 @@ snapshots: '@types/json5@0.0.29': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@24.12.2': dependencies: undici-types: 7.16.0 @@ -4330,6 +4594,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4421,6 +4689,8 @@ snapshots: '@typescript-eslint/types': 8.58.2 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.3': {} + '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -4625,6 +4895,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -4675,6 +4947,8 @@ snapshots: caniuse-lite@1.0.30001788: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -4684,6 +4958,14 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chardet@2.2.0: {} chokidar@3.6.0: @@ -4722,6 +5004,8 @@ snapshots: color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@14.0.3: {} concat-map@0.0.1: {} @@ -4835,6 +5119,10 @@ snapshots: decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-is@0.1.4: {} deepmerge@2.2.1: {} @@ -4861,6 +5149,10 @@ snapshots: detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -5166,6 +5458,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -5189,6 +5483,8 @@ snapshots: expect-type@1.3.0: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -5373,6 +5669,36 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.3.3 + unist-util-position: 5.0.0 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -5391,6 +5717,8 @@ snapshots: dependencies: void-elements: 3.1.0 + html-url-attributes@3.0.1: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -5483,6 +5811,8 @@ snapshots: indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} + inquirer@14.0.2(@types/node@24.12.2): dependencies: '@inquirer/ansi': 2.0.7 @@ -5504,6 +5834,13 @@ snapshots: ip-address@10.2.0: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -5548,6 +5885,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -5566,6 +5905,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-interactive@2.0.0: {} is-map@2.0.3: {} @@ -5774,6 +6115,8 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.1.2 + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5794,6 +6137,228 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@5.5.0) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + mimic-function@5.0.1: {} min-indent@1.0.1: {} @@ -5944,6 +6509,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-ms@4.0.0: {} parse5@7.3.0: @@ -6014,6 +6589,8 @@ snapshots: property-expr@2.0.6: {} + property-information@7.2.0: {} + pstree.remy@1.1.8: {} punycode@2.3.1: {} @@ -6058,6 +6635,24 @@ snapshots: react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.5 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-router-dom@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: react: 19.2.5 @@ -6107,6 +6702,28 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-sanitize: 5.0.2 + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: @@ -6263,6 +6880,8 @@ snapshots: source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} std-env@4.1.0: {} @@ -6329,6 +6948,11 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -6343,6 +6967,14 @@ snapshots: strip-json-comments@3.1.1: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-color@10.2.2: {} supports-color@5.5.0: @@ -6403,6 +7035,10 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -6490,6 +7126,39 @@ snapshots: unicorn-magic@0.3.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -6504,6 +7173,16 @@ snapshots: dependencies: react: 19.2.7 + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + victory-area@37.3.6(react@19.2.5): dependencies: lodash: 4.18.1 @@ -6922,3 +7601,5 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zwitch@2.0.4: {} From 190a6b10ddda3fceb635268d1cc48cfdb789a80d Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 16:25:16 +0300 Subject: [PATCH 05/22] OSAC-2934: add validation constraints summary formatter Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../catalog/catalogItemDisplay.test.ts | 111 ++++++++++++++++++ .../components/catalog/catalogItemDisplay.ts | 36 ++++++ 2 files changed, 147 insertions(+) diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts index a9a45dfa..ef52fbe8 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts @@ -10,8 +10,10 @@ import { catalogItemScope, catalogItemSubtitle, filterCatalogItemsBySearch, + formatCatalogFieldValidationSummary, } from './catalogItemDisplay'; import { + type CatalogFieldDefinition, catalogItemFieldDefinitions, readCatalogItemFieldDefinitions, } from '../catalogProvision/catalogFieldDefinition'; @@ -323,6 +325,115 @@ describe('catalogItemScope', () => { }); }); +describe('formatCatalogFieldValidationSummary', () => { + const baseDef: CatalogFieldDefinition = { + path: 'cores', + displayName: 'vCPUs', + editable: true, + }; + + it('returns an em dash when there is no validation schema', () => { + expect(formatCatalogFieldValidationSummary(baseDef)).toBe('—'); + }); + + it('returns an em dash for an empty validation schema', () => { + expect(formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: {} })).toBe('—'); + }); + + it('summarizes minimum and maximum together', () => { + expect( + formatCatalogFieldValidationSummary({ + ...baseDef, + validationSchema: { minimum: 1, maximum: 10 }, + }), + ).toBe('min: 1, max: 10'); + }); + + it('summarizes a minimum-only constraint', () => { + expect( + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { minimum: 2 } }), + ).toBe('min: 2'); + }); + + it('summarizes string length constraints', () => { + expect( + formatCatalogFieldValidationSummary({ + ...baseDef, + validationSchema: { minLength: 3, maxLength: 20 }, + }), + ).toBe('min length: 3, max length: 20'); + }); + + it('summarizes a regex pattern', () => { + expect( + formatCatalogFieldValidationSummary({ + ...baseDef, + validationSchema: { pattern: '^[a-z]+$' }, + }), + ).toBe('pattern: ^[a-z]+$'); + }); + + it('summarizes an enum constraint', () => { + expect( + formatCatalogFieldValidationSummary({ + ...baseDef, + validationSchema: { enum: ['a', 'b', 'c'] }, + }), + ).toBe('enum: [a, b, c]'); + }); + + it('combines multiple constraint types', () => { + expect( + formatCatalogFieldValidationSummary({ + ...baseDef, + validationSchema: { pattern: '^[a-z]+$', minLength: 1 }, + }), + ).toBe('min length: 1, pattern: ^[a-z]+$'); + }); + + it('returns an em dash for unrecognized schema keywords', () => { + expect( + formatCatalogFieldValidationSummary({ + ...baseDef, + validationSchema: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + }), + ).toBe('—'); + }); + + it('includes a zero minimum (falsy but valid)', () => { + expect( + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { minimum: 0 } }), + ).toBe('min: 0'); + }); + + it('ignores an empty enum array', () => { + expect( + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { enum: [] } }), + ).toBe('—'); + }); + + it('returns an em dash for a type-only schema with no constraints', () => { + expect( + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { type: 'boolean' } }), + ).toBe('—'); + }); + + it('summarizes an integer type with no explicit bounds as a whole-number constraint', () => { + expect( + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { type: 'integer' } }), + ).toBe('whole number'); + }); + + it('omits the whole-number label when integer bounds are already present', () => { + expect( + formatCatalogFieldValidationSummary({ + ...baseDef, + validationSchema: { type: 'integer', minimum: 1 }, + }), + ).toBe('min: 1'); + }); +}); + describe('existing display helpers with private-v1 items', () => { it('catalogItemSubtitle falls back to metadata.name when description is empty', () => { const item = privateClusterItem({ description: '' }); diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts index cdedf9ee..be30d76e 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts @@ -175,6 +175,42 @@ export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string = return fieldDefinitionDefaultToInputString(defaultValue) || '—'; }; +export const formatCatalogFieldValidationSummary = (def: CatalogFieldDefinition): string => { + const schema = def.validationSchema; + if (!schema || !Object.keys(schema).length) { + return '—'; + } + + const parts: string[] = []; + if ( + schema.type === 'integer' && + typeof schema.minimum !== 'number' && + typeof schema.maximum !== 'number' + ) { + parts.push('whole number'); + } + if (typeof schema.minimum === 'number') { + parts.push(`min: ${schema.minimum}`); + } + if (typeof schema.maximum === 'number') { + parts.push(`max: ${schema.maximum}`); + } + if (typeof schema.minLength === 'number') { + parts.push(`min length: ${schema.minLength}`); + } + if (typeof schema.maxLength === 'number') { + parts.push(`max length: ${schema.maxLength}`); + } + if (typeof schema.pattern === 'string' && schema.pattern) { + parts.push(`pattern: ${schema.pattern}`); + } + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + parts.push(`enum: [${schema.enum.map(String).join(', ')}]`); + } + + return parts.length > 0 ? parts.join(', ') : '—'; +}; + /** * 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 From 9e646be28a0b833e3e09fdd15240e28ce56b85c3 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 16:34:21 +0300 Subject: [PATCH 06/22] OSAC-2934: add CatalogItemOverviewTab Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 7 ++ .../CatalogItemOverviewTab.test.tsx | 107 ++++++++++++++++++ .../CatalogItemOverviewTab.tsx | 103 +++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.tsx diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 8ed870df..f6f3ea7a 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -87,6 +87,7 @@ "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})": "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})", "Close": "Close", "Cloud Init User Data": "Cloud Init User Data", + "Cluster": "Cluster", "Cluster conditions": "Cluster conditions", "Cluster node sets": "Cluster node sets", "Cluster password": "Cluster password", @@ -125,6 +126,7 @@ "Delete security group?": "Delete security group?", "Deleting": "Deleting", "deprecated": "deprecated", + "Description": "Description", "Destination CIDR": "Destination CIDR", "Details": "Details", "Download kubeconfig": "Download kubeconfig", @@ -249,6 +251,7 @@ "Provisioning failed": "Provisioning failed", "Public IP": "Public IP", "Public SSH key is required": "Public SSH key is required", + "Publication status": "Publication status", "Published": "Published", "Pull secret": "Pull secret", "Pull secret is required": "Pull secret is required", @@ -256,10 +259,12 @@ "Release image": "Release image", "Release image is required": "Release image is required", "Remove node set": "Remove node set", + "Resource type": "Resource type", "Restart": "Restart", "Retry": "Retry", "Running": "Running", "Save": "Save", + "Scope": "Scope", "Search by name": "Search by name", "Search catalog items": "Search catalog items", "Search security groups by name…": "Search security groups by name…", @@ -287,6 +292,7 @@ "Subnets": "Subnets", "Take over": "Take over", "TCP": "TCP", + "Template": "Template", "The console is available when the virtual machine is running.": "The console is available when the virtual machine is running.", "This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.": "This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.", "This field is required": "This field is required", @@ -307,6 +313,7 @@ "User data must not exceed 64 KB.": "User data must not exceed 64 KB.", "View and manage your bare metal instances.": "View and manage your bare metal instances.", "View password": "View password", + "Virtual Machine": "Virtual Machine", "Virtual machine conditions": "Virtual machine conditions", "Virtual machine summary": "Virtual machine summary", "Virtual machines": "Virtual machines", diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx new file mode 100644 index 00000000..1960fbdc --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx @@ -0,0 +1,107 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; + +import CatalogItemOverviewTab from './CatalogItemOverviewTab'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const baseItem: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: '', + template: 'tpl-openshift-4', + published: true, + fieldDefinitions: [], + metadata: { + $typeName: 'osac.public.v1.Metadata', + name: 'catalog-1', + creator: 'admin', + tenant: '', + project: '', + labels: {}, + annotations: {}, + version: 1, + creationTimestamp: { $typeName: 'google.protobuf.Timestamp', seconds: 1700000000n, nanos: 0 }, + }, +}; + +describe('CatalogItemOverviewTab', () => { + it('renders name, resource type, template name, and status', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('OpenShift 4 cluster')).toBeInTheDocument(); + expect(screen.getByText('Cluster')).toBeInTheDocument(); + expect(screen.getByText('OpenShift 4 Template')).toBeInTheDocument(); + expect(screen.getByText('Published')).toBeInTheDocument(); + }); + + it('renders the scope badge for the given role', () => { + renderWithProviders(); + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('renders the rendered Markdown description when present', () => { + renderWithProviders( + , + ); + expect(screen.getByText('standard').tagName).toBe('STRONG'); + }); + + it('renders a fallback when description is empty', () => { + renderWithProviders( + , + ); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('renders a fallback when template name is not provided', () => { + renderWithProviders( + , + ); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('labels a compute instance catalog item as a Virtual Machine', () => { + renderWithProviders( + , + ); + expect(screen.getByText('Virtual Machine')).toBeInTheDocument(); + }); + + it('labels a bare metal catalog item as Bare Metal', () => { + renderWithProviders( + , + ); + expect(screen.getByText('Bare Metal')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.tsx new file mode 100644 index 00000000..6a3d0f9d --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.tsx @@ -0,0 +1,103 @@ +import { + Card, + CardBody, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, +} from '@patternfly/react-core'; +import type { TFunction } from 'i18next'; + +import CatalogItemScopeBadge from './CatalogItemScopeBadge'; +import CatalogItemStatusLabel from './CatalogItemStatusLabel'; +import { useTranslation } from '../../hooks/useTranslation'; +import type { DemoShellRole } from '../../shellTypes'; +import { displayValue } from '../../utils/detailFormatters'; +import { type CatalogItem, catalogItemScope } from '../catalog/catalogItemDisplay'; +import SanitizedMarkdown from '../Primitives/SanitizedMarkdown'; +import { Timestamp } from '../Primitives/Timestamp'; + +interface CatalogItemOverviewTabProps { + catalogItem: CatalogItem; + role: DemoShellRole; + templateName?: string; +} + +const catalogItemResourceTypeLabel = (item: CatalogItem, t: TFunction): string => { + switch (item.$typeName) { + case 'osac.public.v1.ClusterCatalogItem': + case 'osac.private.v1.ClusterCatalogItem': + return t('Cluster'); + case 'osac.public.v1.ComputeInstanceCatalogItem': + case 'osac.private.v1.ComputeInstanceCatalogItem': + return t('Virtual Machine'); + case 'osac.public.v1.BareMetalInstanceCatalogItem': + case 'osac.private.v1.BareMetalInstanceCatalogItem': + return t('Bare Metal'); + default: { + const exhaustiveCheck: never = item; + void exhaustiveCheck; + return t('Unknown'); + } + } +}; + +const CatalogItemOverviewTab = ({ + catalogItem, + role, + templateName, +}: CatalogItemOverviewTabProps) => { + const { t } = useTranslation(); + const description = catalogItem.description?.trim(); + + return ( + + + + + {t('Name')} + + {displayValue(catalogItem.title)} + + + + {t('Description')} + + {description ? {description} : displayValue()} + + + + {t('Resource type')} + + {catalogItemResourceTypeLabel(catalogItem, t)} + + + + {t('Scope')} + + + + + + {t('Template')} + {displayValue(templateName)} + + + {t('Publication status')} + + + + + + {t('Created')} + + + + + + + + ); +}; + +export default CatalogItemOverviewTab; From d74a36570e60f23a394579de8848a8866b907df8 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 16:39:43 +0300 Subject: [PATCH 07/22] OSAC-2934: add CatalogItemFieldDefinitionsTab Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 8 ++ .../CatalogItemFieldDefinitionsTab.test.tsx | 77 +++++++++++++++++++ .../CatalogItemFieldDefinitionsTab.tsx | 56 ++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.tsx diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index f6f3ea7a..f960a8fe 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -118,6 +118,7 @@ "Create virtual network": "Create virtual network", "Created": "Created", "Creator": "Creator", + "Default Value": "Default Value", "Delete": "Delete", "Delete {{name}}?": "Delete {{name}}?", "Delete rule": "Delete rule", @@ -129,6 +130,7 @@ "Description": "Description", "Destination CIDR": "Destination CIDR", "Details": "Details", + "Display Name": "Display Name", "Download kubeconfig": "Download kubeconfig", "Each host type can only be selected once": "Each host type can only be selected once", "Edit": "Edit", @@ -159,6 +161,7 @@ "Failed to load graphical console viewer": "Failed to load graphical console viewer", "Failed to load security groups": "Failed to load security groups", "Failed to load subnets": "Failed to load subnets", + "Field definitions": "Field definitions", "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", @@ -200,12 +203,14 @@ "Name must be at most 63 characters long": "Name must be at most 63 characters long", "Name must only contain lowercase letters (a-z), digits (0-9), and hyphens (-)": "Name must only contain lowercase letters (a-z), digits (0-9), and hyphens (-)", "Networking": "Networking", + "No": "No", "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 field definitions have been configured for this catalog item.": "No field definitions have been configured for this catalog item.", "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.", "No node sets configured.": "No node sets configured.", @@ -230,6 +235,7 @@ "Parent virtual network": "Parent virtual network", "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.": "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.", "Paste from clipboard": "Paste from clipboard", + "Path": "Path", "Paused": "Paused", "Pod CIDR": "Pod CIDR", "Pool size is required": "Pool size is required", @@ -311,6 +317,7 @@ "User data": "User data", "User Data is required": "User Data is required", "User data must not exceed 64 KB.": "User data must not exceed 64 KB.", + "Validation Constraints": "Validation Constraints", "View and manage your bare metal instances.": "View and manage your bare metal instances.", "View password": "View password", "Virtual Machine": "Virtual Machine", @@ -323,5 +330,6 @@ "Virtual network is required": "Virtual network is required", "Virtual networks": "Virtual networks", "Worker nodes": "Worker nodes", + "Yes": "Yes", "You are not authorized to access this resource.": "You are not authorized to access this resource." } diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.test.tsx new file mode 100644 index 00000000..65f0767f --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.test.tsx @@ -0,0 +1,77 @@ +import { screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; + +import CatalogItemFieldDefinitionsTab from './CatalogItemFieldDefinitionsTab'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const itemWithFields = ( + fieldDefinitions: ClusterCatalogItem['fieldDefinitions'], +): ClusterCatalogItem => ({ + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: '', + template: 'tpl-openshift-4', + published: true, + fieldDefinitions, +}); + +const coresField = { + $typeName: 'osac.public.v1.FieldDefinition' as const, + path: 'cores', + displayName: 'vCPUs', + editable: true, + default: { + $typeName: 'google.protobuf.Value' as const, + kind: { case: 'numberValue' as const, value: 4 }, + }, + validationSchema: '{"minimum":1,"maximum":16}', +}; + +const releaseImageField = { + $typeName: 'osac.public.v1.FieldDefinition' as const, + path: 'release_image', + displayName: 'Release Image', + editable: false, + default: { + $typeName: 'google.protobuf.Value' as const, + kind: { case: 'stringValue' as const, value: 'quay.io/release:4.17' }, + }, + validationSchema: '', +}; + +describe('CatalogItemFieldDefinitionsTab', () => { + it('renders a row per field definition with all columns in the correct cells', () => { + renderWithProviders( + , + ); + + const rows = screen.getAllByRole('row'); + const coresRow = within(rows[1]); + expect(coresRow.getByText('cores')).toBeInTheDocument(); + expect(coresRow.getByText('vCPUs')).toBeInTheDocument(); + expect(coresRow.getByText('Yes')).toBeInTheDocument(); + expect(coresRow.getByText('4')).toBeInTheDocument(); + expect(coresRow.getByText('min: 1, max: 16')).toBeInTheDocument(); + + const releaseImageRow = within(rows[2]); + expect(releaseImageRow.getByText('release_image')).toBeInTheDocument(); + expect(releaseImageRow.getByText('Release Image')).toBeInTheDocument(); + expect(releaseImageRow.getByText('No')).toBeInTheDocument(); + expect(releaseImageRow.getByText('quay.io/release:4.17')).toBeInTheDocument(); + expect(releaseImageRow.getByText('—')).toBeInTheDocument(); + }); + + it('renders an empty state when there are no field definitions', () => { + renderWithProviders(); + + expect( + screen.getByText('No field definitions have been configured for this catalog item.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.tsx new file mode 100644 index 00000000..f0f94443 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.tsx @@ -0,0 +1,56 @@ +import { Content } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { + type CatalogItem, + formatCatalogFieldDefault, + formatCatalogFieldValidationSummary, +} from '../catalog/catalogItemDisplay'; +import { catalogItemFieldDefinitions } from '../catalogProvision/catalogFieldDefinition'; + +interface CatalogItemFieldDefinitionsTabProps { + catalogItem: CatalogItem; +} + +const CatalogItemFieldDefinitionsTab = ({ catalogItem }: CatalogItemFieldDefinitionsTabProps) => { + const { t } = useTranslation(); + const fieldDefinitions = catalogItemFieldDefinitions(catalogItem); + + if (fieldDefinitions.length === 0) { + return ( + + {t('No field definitions have been configured for this catalog item.')} + + ); + } + + return ( + + + + + + + + + + + + {fieldDefinitions.map((def) => ( + + + + + + + + ))} + +
{t('Path')}{t('Display Name')}{t('Editable')}{t('Default Value')}{t('Validation Constraints')}
{def.path}{def.displayName || '—'}{def.editable ? t('Yes') : t('No')}{formatCatalogFieldDefault(def)} + {formatCatalogFieldValidationSummary(def)} +
+ ); +}; + +export default CatalogItemFieldDefinitionsTab; From 2ec35e8a8b9b20da096a4f265d1b1c804f2be840 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 16:52:30 +0300 Subject: [PATCH 08/22] OSAC-2934: add CatalogItemProvisionedResourcesTab with server-side pagination Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 2 + ...atalogItemProvisionedResourcesTab.test.tsx | 174 +++++++++++++++++ .../CatalogItemProvisionedResourcesTab.tsx | 175 ++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.tsx diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index f960a8fe..a6f20afb 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -216,6 +216,7 @@ "No node sets configured.": "No node sets configured.", "No outbound rules yet. Add one to allow outgoing traffic.": "No outbound rules yet. Add one to allow outgoing traffic.", "No published catalog items are available yet.": "No published catalog items are available yet.", + "No resources have been provisioned from this catalog item.": "No resources have been provisioned from this catalog item.", "No security groups match your search.": "No security groups match your search.", "No security groups yet. Create one to get started.": "No security groups yet. Create one to get started.", "No subnets yet. Create one to get started.": "No subnets yet. Create one to get started.", @@ -253,6 +254,7 @@ "Protocol is required": "Protocol is required", "Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.", "Provision bare metal": "Provision bare metal", + "Provisioned resources": "Provisioned resources", "Provisioning": "Provisioning", "Provisioning failed": "Provisioning failed", "Public IP": "Public IP", diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.test.tsx new file mode 100644 index 00000000..6add4ad4 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.test.tsx @@ -0,0 +1,174 @@ +import { screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Cluster } from '@osac/types'; + +import { mockQueryResult } from '../../test-utils/query'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +vi.mock('@osac/ui-components/api/v1/cluster', () => ({ + useClustersForCatalogItem: vi.fn(), +})); +vi.mock('@osac/ui-components/api/v1/compute-instance', () => ({ + useComputeInstancesForCatalogItem: vi.fn(), +})); +vi.mock('@osac/ui-components/api/v1/baremetal-instance', () => ({ + useBareMetalInstancesForCatalogItem: vi.fn(), +})); + +const { useClustersForCatalogItem } = await import('@osac/ui-components/api/v1/cluster'); +const { useComputeInstancesForCatalogItem } = + await import('@osac/ui-components/api/v1/compute-instance'); +const { useBareMetalInstancesForCatalogItem } = + await import('@osac/ui-components/api/v1/baremetal-instance'); + +const CatalogItemProvisionedResourcesTab = (await import('./CatalogItemProvisionedResourcesTab')) + .default; + +const cluster = (id: string): Cluster => + ({ + id, + metadata: { name: `cluster-${id}` }, + status: {}, + }) as Cluster; + +describe('CatalogItemProvisionedResourcesTab', () => { + beforeEach(() => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< + typeof useClustersForCatalogItem + >, + ); + vi.mocked(useComputeInstancesForCatalogItem).mockReturnValue( + mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< + typeof useComputeInstancesForCatalogItem + >, + ); + vi.mocked(useBareMetalInstancesForCatalogItem).mockReturnValue( + mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< + typeof useBareMetalInstancesForCatalogItem + >, + ); + }); + + it('renders cluster rows linking to the cluster detail page', () => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [cluster('cluster-1')], total: 1 }, + }) as ReturnType, + ); + + renderWithProviders( + , + ); + + const link = screen.getByRole('link', { name: 'cluster-cluster-1' }); + expect(link).toHaveAttribute('href', '/clusters/cluster-1'); + }); + + it('only queries the hook matching the given kind', () => { + renderWithProviders( + , + ); + + expect(useClustersForCatalogItem).toHaveBeenCalledWith( + 'catalog-1', + expect.objectContaining({ limit: 10, offset: 0 }), + ); + expect(useComputeInstancesForCatalogItem).toHaveBeenCalledWith( + '', + expect.objectContaining({ limit: 10, offset: 0 }), + ); + expect(useBareMetalInstancesForCatalogItem).toHaveBeenCalledWith( + '', + expect.objectContaining({ limit: 10, offset: 0 }), + ); + }); + + it('renders an empty state when there are no provisioned resources', () => { + renderWithProviders( + , + ); + + expect( + screen.getByText('No resources have been provisioned from this catalog item.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('shows a loading spinner while the query is in flight', () => { + vi.mocked(useClustersForCatalogItem).mockReturnValue(mockQueryResult({ isLoading: true })); + + renderWithProviders( + , + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('does not render pagination when there are no results', () => { + renderWithProviders( + , + ); + + expect(screen.queryByLabelText(/pagination/i)).not.toBeInTheDocument(); + }); + + it('renders pagination sized to the total item count', () => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [cluster('cluster-1')], total: 42 }, + }) as ReturnType, + ); + + renderWithProviders( + , + ); + + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('advances to the next page and re-queries with the updated offset', async () => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [cluster('cluster-1')], total: 42 }, + }) as ReturnType, + ); + + const { user } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('button', { name: /go to next page/i })); + + expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( + 'catalog-1', + expect.objectContaining({ limit: 10, offset: 10 }), + ); + }); + + it('resets to page 1 when the catalog item id changes', async () => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [cluster('cluster-1')], total: 42 }, + }) as ReturnType, + ); + + const { user, rerender } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('button', { name: /go to next page/i })); + expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( + 'catalog-1', + expect.objectContaining({ offset: 10 }), + ); + + rerender(); + + expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( + 'catalog-2', + expect.objectContaining({ offset: 0 }), + ); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.tsx new file mode 100644 index 00000000..ecd4ab52 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.tsx @@ -0,0 +1,175 @@ +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Content, Pagination, PaginationVariant, Stack, StackItem } from '@patternfly/react-core'; +import type { OnPerPageSelect, OnSetPage } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import type { BareMetalInstance, Cluster, ComputeInstance } from '@osac/types'; + +import { useBareMetalInstancesForCatalogItem } from '../../api/v1/baremetal-instance'; +import { useClustersForCatalogItem } from '../../api/v1/cluster'; +import { useComputeInstancesForCatalogItem } from '../../api/v1/compute-instance'; +import { resourceDisplayName } from '../../api/v1/networking'; +import { useTranslation } from '../../hooks/useTranslation'; +import { VmStatusLabel } from '../../VmStatusLabel'; +import { BareMetalStatusLabel } from '../BareMetalInstance/BareMetalStatusLabel'; +import { ClusterStatusLabel } from '../Cluster/ClusterStatusLabel'; +import ListPageBody from '../Page/ListPageBody'; +import { Timestamp, type TimestampProps } from '../Primitives/Timestamp'; + +export type CatalogItemDetailKind = 'cluster' | 'compute-instance' | 'baremetal-instance'; + +interface CatalogItemProvisionedResourcesTabProps { + catalogItemId: string; + kind: CatalogItemDetailKind; +} + +interface ProvisionedResourceRow { + id: string; + name: string; + status: ReactNode; + createdAt: TimestampProps['value']; + href: string; +} + +const clusterRow = (item: Cluster): ProvisionedResourceRow => ({ + id: item.id, + name: resourceDisplayName(item.metadata, item.id), + status: , + createdAt: item.metadata?.creationTimestamp, + href: `/clusters/${item.id}`, +}); + +const computeInstanceRow = (item: ComputeInstance): ProvisionedResourceRow => ({ + id: item.id, + name: resourceDisplayName(item.metadata, item.id), + status: , + createdAt: item.metadata?.creationTimestamp, + href: `/vms/${item.id}`, +}); + +const bareMetalRow = (item: BareMetalInstance): ProvisionedResourceRow => ({ + id: item.id, + name: resourceDisplayName(item.metadata, item.id), + status: , + createdAt: item.metadata?.creationTimestamp, + href: `/bare-metal/${item.id}`, +}); + +const DEFAULT_PER_PAGE = 10; + +const CatalogItemProvisionedResourcesTab = ({ + catalogItemId, + kind, +}: CatalogItemProvisionedResourcesTabProps) => { + const { t } = useTranslation(); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); + const offset = (page - 1) * perPage; + + // The parent route only swaps the `:id` param when the admin navigates between catalog items — + // this component's instance is reused rather than remounted, so page state must reset explicitly. + useEffect(() => { + setPage(1); + }, [catalogItemId, kind]); + + const clustersResult = useClustersForCatalogItem(kind === 'cluster' ? catalogItemId : '', { + limit: perPage, + offset, + }); + const computeInstancesResult = useComputeInstancesForCatalogItem( + kind === 'compute-instance' ? catalogItemId : '', + { limit: perPage, offset }, + ); + const bareMetalResult = useBareMetalInstancesForCatalogItem( + kind === 'baremetal-instance' ? catalogItemId : '', + { limit: perPage, offset }, + ); + + let rows: ProvisionedResourceRow[]; + let total: number; + let isLoading: boolean; + let error: unknown; + + switch (kind) { + case 'cluster': + rows = (clustersResult.data?.items ?? []).map(clusterRow); + total = clustersResult.data?.total ?? 0; + isLoading = clustersResult.isLoading; + error = clustersResult.error; + break; + case 'compute-instance': + rows = (computeInstancesResult.data?.items ?? []).map(computeInstanceRow); + total = computeInstancesResult.data?.total ?? 0; + isLoading = computeInstancesResult.isLoading; + error = computeInstancesResult.error; + break; + case 'baremetal-instance': + rows = (bareMetalResult.data?.items ?? []).map(bareMetalRow); + total = bareMetalResult.data?.total ?? 0; + isLoading = bareMetalResult.isLoading; + error = bareMetalResult.error; + break; + } + + const handleSetPage: OnSetPage = (_event, newPage) => { + setPage(newPage); + }; + + const handlePerPageSelect: OnPerPageSelect = (_event, newPerPage) => { + setPerPage(newPerPage); + setPage(1); + }; + + return ( + + + + {rows.length === 0 ? ( + + {t('No resources have been provisioned from this catalog item.')} + + ) : ( + + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
{t('Name')}{t('Status')}{t('Created')}
+ {row.name} + {row.status} + +
+ )} +
+
+ {total > 0 ? ( + + + + ) : null} +
+ ); +}; + +export default CatalogItemProvisionedResourcesTab; From 4332bef0c3efd4fb6c60e753ae40fd3aab61e2c3 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 17:02:02 +0300 Subject: [PATCH 09/22] OSAC-2934: add CatalogItemDetailActionButtons and CatalogItemPublishToggle Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../CatalogItemDetailActionButtons.test.tsx | 174 ++++++++++++++++++ .../CatalogItemDetailActionButtons.tsx | 59 ++++++ .../CatalogItemPublishToggle.test.tsx | 33 ++++ .../CatalogItemPublishToggle.tsx | 30 +++ 4 files changed, 296 insertions(+) create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx new file mode 100644 index 00000000..9e7a4048 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx @@ -0,0 +1,174 @@ +import { Route, Routes } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; +import type { ClusterCatalogItem as PrivateClusterCatalogItem } from '@osac/types/private'; + +import CatalogItemDetailActionButtons from './CatalogItemDetailActionButtons'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const publicItem = (overrides: Partial = {}): ClusterCatalogItem => ({ + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: '', + template: 'tpl-openshift-4', + published: true, + fieldDefinitions: [], + ...overrides, +}); + +const privateItem = ( + overrides: Partial = {}, +): PrivateClusterCatalogItem => ({ + $typeName: 'osac.private.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: '', + template: 'tpl-openshift-4', + published: true, + tenant: '', + fieldDefinitions: [], + ...overrides, +}); + +describe('CatalogItemDetailActionButtons', () => { + it('renders all actions for providerAdmin on an organization-scoped item', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('renders all actions for providerAdmin even on a general (global) item — CSP Admin is never hidden', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('renders all actions for tenantAdmin on an organization-scoped item', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('renders all actions for tenantAdmin on a project-scoped item', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('hides all actions for tenantAdmin on a general (global) item', () => { + const { container } = renderWithProviders( + , + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); + }); + + it('calls onDeleteClick when Delete is clicked', async () => { + const onDeleteClick = vi.fn(); + const { user } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + expect(onDeleteClick).toHaveBeenCalled(); + }); + + it('calls onTogglePublish when the switch is toggled', async () => { + const onTogglePublish = vi.fn(); + const { user } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('switch')); + expect(onTogglePublish).toHaveBeenCalledWith(false); + }); + + it('navigates to editHref when Edit is clicked', async () => { + const { user } = renderWithProviders( + + + } + /> + edit-page} /> + , + { routerEntries: ['/admin/catalog/cluster/catalog-1'] }, + ); + + await user.click(screen.getByRole('button', { name: 'Edit' })); + + await waitFor(() => { + expect(screen.getByText('edit-page')).toBeInTheDocument(); + }); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx new file mode 100644 index 00000000..a79deb61 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx @@ -0,0 +1,59 @@ +import { useNavigate } from 'react-router-dom'; +import { Button, Flex, FlexItem } from '@patternfly/react-core'; +import PencilAltIcon from '@patternfly/react-icons/dist/esm/icons/pencil-alt-icon'; +import TrashIcon from '@patternfly/react-icons/dist/esm/icons/trash-icon'; + +import CatalogItemPublishToggle from './CatalogItemPublishToggle'; +import { useTranslation } from '../../hooks/useTranslation'; +import type { DemoShellRole } from '../../shellTypes'; +import { type CatalogItem, catalogItemScope } from '../catalog/catalogItemDisplay'; + +interface CatalogItemDetailActionButtonsProps { + catalogItem: CatalogItem; + role: DemoShellRole; + editHref: string; + onDeleteClick: () => void; + onTogglePublish: (next: boolean) => void; +} + +const CatalogItemDetailActionButtons = ({ + catalogItem, + role, + editHref, + onDeleteClick, + onTogglePublish, +}: CatalogItemDetailActionButtonsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const scope = catalogItemScope(catalogItem, role); + const isHiddenForTenantAdmin = role === 'tenantAdmin' && scope.level === 'general'; + + if (isHiddenForTenantAdmin) { + return null; + } + + return ( + + + + + + + + + + + + ); +}; + +export default CatalogItemDetailActionButtons; 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..18e6fd15 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.test.tsx @@ -0,0 +1,33 @@ +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('shows the published label and a checked switch when published', () => { + renderWithProviders(); + expect(screen.getByRole('switch', { name: 'Published' })).toBeChecked(); + }); + + it('shows the unpublished label and an unchecked switch when not published', () => { + renderWithProviders(); + expect(screen.getByRole('switch', { name: 'Unpublished' })).not.toBeChecked(); + }); + + it('calls onChange with the inverted value on click', async () => { + const onChange = vi.fn(); + const { user } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('switch')); + + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('respects isDisabled', () => { + renderWithProviders(); + expect(screen.getByRole('switch')).toBeDisabled(); + }); +}); 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..8215996e --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemPublishToggle.tsx @@ -0,0 +1,30 @@ +import { Switch } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +interface CatalogItemPublishToggleProps { + published: boolean; + isDisabled?: boolean; + onChange: (next: boolean) => void; +} + +const CatalogItemPublishToggle = ({ + published, + isDisabled, + onChange, +}: CatalogItemPublishToggleProps) => { + const { t } = useTranslation(); + + return ( + onChange(checked)} + /> + ); +}; + +export default CatalogItemPublishToggle; From dd6ea48576ce017261417795b93abb1ffd9021a4 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 17:19:25 +0300 Subject: [PATCH 10/22] OSAC-2934: add catalog item detail pages and wire admin routing Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/shell/AdminCatalogRoutes.test.tsx | 63 ++++++++ .../src/shell/AdminCatalogRoutes.tsx | 22 ++- .../CatalogItemDetails.test.tsx | 60 ++++++++ .../catalogManagement/CatalogItemDetails.tsx | 138 ++++++++++++++++++ ...etalInstanceCatalogItemDetailPage.test.tsx | 104 +++++++++++++ ...BareMetalInstanceCatalogItemDetailPage.tsx | 72 +++++++++ .../ClusterCatalogItemDetailPage.test.tsx | 117 +++++++++++++++ .../cluster/ClusterCatalogItemDetailPage.tsx | 75 ++++++++++ ...puteInstanceCatalogItemDetailPage.test.tsx | 101 +++++++++++++ .../ComputeInstanceCatalogItemDetailPage.tsx | 72 +++++++++ 10 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 apps/app-frontend/src/shell/AdminCatalogRoutes.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx create mode 100644 libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx create mode 100644 libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx create mode 100644 libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx create mode 100644 libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx create mode 100644 libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx create mode 100644 libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx diff --git a/apps/app-frontend/src/shell/AdminCatalogRoutes.test.tsx b/apps/app-frontend/src/shell/AdminCatalogRoutes.test.tsx new file mode 100644 index 00000000..f52e100c --- /dev/null +++ b/apps/app-frontend/src/shell/AdminCatalogRoutes.test.tsx @@ -0,0 +1,63 @@ +import { Route, Routes } from 'react-router-dom'; +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders'; + +import { AdminCatalogRoutes } from './AdminCatalogRoutes'; + +vi.mock('@osac/ui-components/pages/admin/CatalogManagementListPage', () => ({ + default: () =>
list-page
, +})); +vi.mock('@osac/ui-components/pages/admin/cluster/ClusterCatalogItemDetailPage', () => ({ + default: () =>
cluster-detail-page
, +})); +vi.mock( + '@osac/ui-components/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage', + () => ({ + default: () =>
compute-instance-detail-page
, + }), +); +vi.mock( + '@osac/ui-components/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage', + () => ({ + default: () =>
baremetal-instance-detail-page
, + }), +); + +// Mirrors AppShell.tsx's real mount point (`/admin/catalog/*`) — required so the component's +// internal `` redirect resolves to a route that actually exists. +const renderAt = (path: string) => + renderWithProviders( + + } /> + , + { routerEntries: [`/admin/catalog${path}`] }, + ); + +describe('AdminCatalogRoutes', () => { + it('renders the list page at the index route', () => { + renderAt('/'); + expect(screen.getByText('list-page')).toBeInTheDocument(); + }); + + it('dispatches :type/:id to the cluster detail page for type=cluster', () => { + renderAt('/cluster/catalog-1'); + expect(screen.getByText('cluster-detail-page')).toBeInTheDocument(); + }); + + it('dispatches :type/:id to the compute-instance detail page for type=compute-instance', () => { + renderAt('/compute-instance/catalog-1'); + expect(screen.getByText('compute-instance-detail-page')).toBeInTheDocument(); + }); + + it('dispatches :type/:id to the baremetal-instance detail page for type=baremetal-instance', () => { + renderAt('/baremetal-instance/catalog-1'); + expect(screen.getByText('baremetal-instance-detail-page')).toBeInTheDocument(); + }); + + it('redirects to the list page for an unknown type', () => { + renderAt('/unknown-type/catalog-1'); + expect(screen.getByText('list-page')).toBeInTheDocument(); + }); +}); diff --git a/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx b/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx index fad49d9f..ce97d139 100644 --- a/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx +++ b/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx @@ -1,13 +1,31 @@ -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes, useParams } from 'react-router-dom'; +import BareMetalInstanceCatalogItemDetailPage from '@osac/ui-components/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage'; import CatalogManagementListPage from '@osac/ui-components/pages/admin/CatalogManagementListPage'; +import ClusterCatalogItemDetailPage from '@osac/ui-components/pages/admin/cluster/ClusterCatalogItemDetailPage'; +import ComputeInstanceCatalogItemDetailPage from '@osac/ui-components/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage'; + +const CatalogItemDetailRoute = () => { + const { type } = useParams<{ type: string }>(); + + switch (type) { + case 'cluster': + return ; + case 'compute-instance': + return ; + case 'baremetal-instance': + return ; + default: + return ; + } +}; export const AdminCatalogRoutes = () => { return ( } /> } /> - } /> + } /> } /> ); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx new file mode 100644 index 00000000..0e747fb7 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx @@ -0,0 +1,60 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; + +import CatalogItemDetails from './CatalogItemDetails'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const catalogItem: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: 'A standard OpenShift cluster', + template: 'tpl-openshift-4', + published: true, + fieldDefinitions: [ + { + $typeName: 'osac.public.v1.FieldDefinition', + path: 'release_image', + displayName: 'Release Image', + editable: false, + default: { + $typeName: 'google.protobuf.Value', + kind: { case: 'stringValue', value: 'quay.io/release:4.17' }, + }, + validationSchema: '', + }, + ], +}; + +describe('CatalogItemDetails', () => { + it('renders the header with name and status', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('heading', { name: 'OpenShift 4 cluster' })).toBeInTheDocument(); + expect(screen.getAllByText('Published').length).toBeGreaterThan(0); + }); + + it('renders the header actions', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('shows the Overview tab by default and switches to other tabs on click', async () => { + const { user } = renderWithProviders( + , + ); + + expect(screen.getByText('A standard OpenShift cluster')).toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'Field Definitions' })); + expect(screen.getByText('release_image')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx new file mode 100644 index 00000000..a09dc462 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { + Flex, + FlexItem, + PageSection, + Stack, + StackItem, + Tab, + TabContent, + TabContentBody, + TabTitleText, + Tabs, +} from '@patternfly/react-core'; + +import CatalogItemDetailActionButtons from './CatalogItemDetailActionButtons'; +import CatalogItemFieldDefinitionsTab from './CatalogItemFieldDefinitionsTab'; +import CatalogItemOverviewTab from './CatalogItemOverviewTab'; +import type { CatalogItemDetailKind } from './CatalogItemProvisionedResourcesTab'; +import CatalogItemProvisionedResourcesTab from './CatalogItemProvisionedResourcesTab'; +import CatalogItemStatusLabel from './CatalogItemStatusLabel'; +import { useTranslation } from '../../hooks/useTranslation'; +import type { DemoShellRole } from '../../shellTypes'; +import type { CatalogItem } from '../catalog/catalogItemDisplay'; +import { ResourceDetailHeader } from '../Resource/ResourceDetailHeader'; + +interface CatalogItemDetailsProps { + catalogItem: CatalogItem; + kind: CatalogItemDetailKind; + role: DemoShellRole; + templateName?: string; +} + +const OVERVIEW_TAB_ID = 'catalog-item-detail-tab-overview'; +const FIELD_DEFINITIONS_TAB_ID = 'catalog-item-detail-tab-field-definitions'; +const PROVISIONED_RESOURCES_TAB_ID = 'catalog-item-detail-tab-provisioned-resources'; + +const CatalogItemDetails = ({ catalogItem, kind, role, templateName }: CatalogItemDetailsProps) => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState(0); + const editHref = `/admin/catalog/${kind}/${catalogItem.id}/edit`; + + return ( + <> + + + + + + } + /> + + + {}} + onTogglePublish={() => {}} + /> + + + + + setActiveTab(Number(key))} + > + {t('Overview')}} + tabContentId={OVERVIEW_TAB_ID} + /> + {t('Field Definitions')}} + tabContentId={FIELD_DEFINITIONS_TAB_ID} + /> + {t('Provisioned Resources')}} + tabContentId={PROVISIONED_RESOURCES_TAB_ID} + /> + + + + + + + + + + + + ); +}; + +export default CatalogItemDetails; diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx new file mode 100644 index 00000000..22035f47 --- /dev/null +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx @@ -0,0 +1,104 @@ +import { Route, Routes } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { BareMetalInstanceCatalogItem } from '@osac/types'; +import type { BareMetalInstanceCatalogItem as PrivateBareMetalInstanceCatalogItem } from '@osac/types/private'; + +import { SessionProvider } from '../../../hooks/use-session'; +import { mockQueryResult } from '../../../test-utils/query'; +import { renderWithProviders } from '../../../test-utils/TestProviders'; + +vi.mock('../../../api/v1/baremetal-instance', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useBareMetalInstanceCatalogItem: vi.fn(), + }; +}); +vi.mock('../../../api/v1/private/baremetal-instance-catalog-item', () => ({ + usePrivateBareMetalInstanceCatalogItem: vi.fn(), +})); + +const { useBareMetalInstanceCatalogItem } = await import('../../../api/v1/baremetal-instance'); +const { usePrivateBareMetalInstanceCatalogItem } = + await import('../../../api/v1/private/baremetal-instance-catalog-item'); + +const BareMetalInstanceCatalogItemDetailPage = ( + await import('./BareMetalInstanceCatalogItemDetailPage') +).default; + +const publicItem: BareMetalInstanceCatalogItem = { + $typeName: 'osac.public.v1.BareMetalInstanceCatalogItem', + id: 'catalog-1', + title: 'Bare Metal Worker', + description: '', + template: 'tpl-bm-worker', + published: true, + fieldDefinitions: [], +}; + +const privateItem: PrivateBareMetalInstanceCatalogItem = { + id: publicItem.id, + title: publicItem.title, + description: publicItem.description, + template: publicItem.template, + published: publicItem.published, + fieldDefinitions: [], + $typeName: 'osac.private.v1.BareMetalInstanceCatalogItem', + tenant: 'acme-corp', +}; + +const renderPage = (role: 'providerAdmin' | 'tenantAdmin') => + renderWithProviders( + + + } + /> + + , + { routerEntries: ['/admin/catalog/baremetal-instance/catalog-1'] }, + ); + +describe('BareMetalInstanceCatalogItemDetailPage', () => { + beforeEach(() => { + vi.mocked(useBareMetalInstanceCatalogItem).mockReturnValue( + mockQueryResult({ data: publicItem }) as ReturnType, + ); + vi.mocked(usePrivateBareMetalInstanceCatalogItem).mockReturnValue( + mockQueryResult({ data: privateItem }) as ReturnType< + typeof usePrivateBareMetalInstanceCatalogItem + >, + ); + }); + + it('renders the public catalog item for tenantAdmin', async () => { + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Bare Metal Worker' })).toBeInTheDocument(); + }); + expect(usePrivateBareMetalInstanceCatalogItem).toHaveBeenCalledWith(undefined); + expect(useBareMetalInstanceCatalogItem).toHaveBeenCalledWith('catalog-1'); + }); + + it('renders the private catalog item for providerAdmin', async () => { + renderPage('providerAdmin'); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Bare Metal Worker' })).toBeInTheDocument(); + }); + expect(usePrivateBareMetalInstanceCatalogItem).toHaveBeenCalledWith('catalog-1'); + expect(useBareMetalInstanceCatalogItem).toHaveBeenCalledWith(undefined); + }); + + it('shows a not-found state when the item does not exist', async () => { + vi.mocked(useBareMetalInstanceCatalogItem).mockReturnValue( + mockQueryResult({ data: undefined }) as ReturnType, + ); + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText('Catalog item not found')).toBeInTheDocument(); + }); + }); +}); diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx new file mode 100644 index 00000000..140fc28a --- /dev/null +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx @@ -0,0 +1,72 @@ +import { useParams } from 'react-router-dom'; + +import { useBareMetalInstanceCatalogItem } from '../../../api/v1/baremetal-instance'; +import { usePrivateBareMetalInstanceCatalogItem } from '../../../api/v1/private/baremetal-instance-catalog-item'; +import CatalogItemDetails from '../../../components/catalogManagement/CatalogItemDetails'; +import { ResourceDetailsPageError } from '../../../components/Resource/ResourceDetailsPageError'; +import { ResourceDetailsPageLoading } from '../../../components/Resource/ResourceDetailsPageLoading'; +import { useSession } from '../../../hooks/use-session'; +import { useTranslation } from '../../../hooks/useTranslation'; + +const BareMetalInstanceCatalogItemDetailPage = () => { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + + const publicResult = useBareMetalInstanceCatalogItem(!isProviderAdmin ? id : undefined); + const privateResult = usePrivateBareMetalInstanceCatalogItem(isProviderAdmin ? id : undefined); + const { + data: catalogItem, + isLoading, + isError, + error, + refetch, + } = isProviderAdmin ? privateResult : publicResult; + + if (isLoading) { + return ( + + ); + } + + if (isError) { + return ( + void refetch()} + /> + ); + } + + if (!catalogItem) { + return ( + + ); + } + + return ( + + ); +}; + +export default BareMetalInstanceCatalogItemDetailPage; diff --git a/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx new file mode 100644 index 00000000..6f206632 --- /dev/null +++ b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx @@ -0,0 +1,117 @@ +import { Route, Routes } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; +import type { ClusterCatalogItem as PrivateClusterCatalogItem } from '@osac/types/private'; + +import { SessionProvider } from '../../../hooks/use-session'; +import { mockQueryResult } from '../../../test-utils/query'; +import { renderWithProviders } from '../../../test-utils/TestProviders'; + +vi.mock('../../../api/v1/cluster-catalog-item', () => ({ + useClusterCatalogItem: vi.fn(), +})); +vi.mock('../../../api/v1/private/cluster-catalog-item', () => ({ + usePrivateClusterCatalogItem: vi.fn(), +})); +vi.mock('../../../api/v1/cluster-templates', () => ({ + useClusterTemplate: vi.fn(), +})); + +const { useClusterCatalogItem } = await import('../../../api/v1/cluster-catalog-item'); +const { usePrivateClusterCatalogItem } = + await import('../../../api/v1/private/cluster-catalog-item'); +const { useClusterTemplate } = await import('../../../api/v1/cluster-templates'); + +const ClusterCatalogItemDetailPage = (await import('./ClusterCatalogItemDetailPage')).default; + +const publicItem: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: '', + template: 'tpl-openshift-4', + published: true, + fieldDefinitions: [], +}; + +const privateItem: PrivateClusterCatalogItem = { + id: publicItem.id, + title: publicItem.title, + description: publicItem.description, + template: publicItem.template, + published: publicItem.published, + fieldDefinitions: [], + $typeName: 'osac.private.v1.ClusterCatalogItem', + tenant: 'acme-corp', +}; + +const renderPage = (role: 'providerAdmin' | 'tenantAdmin') => + renderWithProviders( + + + } /> + + , + { routerEntries: ['/admin/catalog/cluster/catalog-1'] }, + ); + +describe('ClusterCatalogItemDetailPage', () => { + beforeEach(() => { + vi.mocked(useClusterCatalogItem).mockReturnValue( + mockQueryResult({ data: publicItem }) as ReturnType, + ); + vi.mocked(usePrivateClusterCatalogItem).mockReturnValue( + mockQueryResult({ data: privateItem }) as ReturnType, + ); + vi.mocked(useClusterTemplate).mockReturnValue( + mockQueryResult({ data: undefined }) as ReturnType, + ); + }); + + it('renders the public catalog item for tenantAdmin', async () => { + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'OpenShift 4 cluster' })).toBeInTheDocument(); + }); + expect(usePrivateClusterCatalogItem).toHaveBeenCalledWith(undefined); + expect(useClusterCatalogItem).toHaveBeenCalledWith('catalog-1'); + }); + + it('renders the private catalog item for providerAdmin', async () => { + renderPage('providerAdmin'); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'OpenShift 4 cluster' })).toBeInTheDocument(); + }); + expect(usePrivateClusterCatalogItem).toHaveBeenCalledWith('catalog-1'); + expect(useClusterCatalogItem).toHaveBeenCalledWith(undefined); + }); + + it('shows a loading state while the query is pending, not the real content', () => { + vi.mocked(useClusterCatalogItem).mockReturnValue(mockQueryResult({ isLoading: true })); + renderPage('tenantAdmin'); + expect(screen.queryByRole('heading', { name: 'OpenShift 4 cluster' })).not.toBeInTheDocument(); + expect(screen.getByText('Catalog management')).toBeInTheDocument(); + }); + + it('shows a not-found state when the item does not exist', async () => { + vi.mocked(useClusterCatalogItem).mockReturnValue( + mockQueryResult({ data: undefined }) as ReturnType, + ); + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText('Catalog item not found')).toBeInTheDocument(); + }); + }); + + it('shows an error state when the query fails', async () => { + vi.mocked(useClusterCatalogItem).mockReturnValue( + mockQueryResult({ isError: true, error: new Error('boom') }), + ); + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText('Could not load catalog item')).toBeInTheDocument(); + }); + }); +}); diff --git a/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx new file mode 100644 index 00000000..95ede268 --- /dev/null +++ b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx @@ -0,0 +1,75 @@ +import { useParams } from 'react-router-dom'; + +import { useClusterCatalogItem } from '../../../api/v1/cluster-catalog-item'; +import { useClusterTemplate } from '../../../api/v1/cluster-templates'; +import { usePrivateClusterCatalogItem } from '../../../api/v1/private/cluster-catalog-item'; +import CatalogItemDetails from '../../../components/catalogManagement/CatalogItemDetails'; +import { ResourceDetailsPageError } from '../../../components/Resource/ResourceDetailsPageError'; +import { ResourceDetailsPageLoading } from '../../../components/Resource/ResourceDetailsPageLoading'; +import { useSession } from '../../../hooks/use-session'; +import { useTranslation } from '../../../hooks/useTranslation'; + +const ClusterCatalogItemDetailPage = () => { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + + const publicResult = useClusterCatalogItem(!isProviderAdmin ? id : undefined); + const privateResult = usePrivateClusterCatalogItem(isProviderAdmin ? id : undefined); + const { + data: catalogItem, + isLoading, + isError, + error, + refetch, + } = isProviderAdmin ? privateResult : publicResult; + + const { data: template } = useClusterTemplate(catalogItem?.template); + + if (isLoading) { + return ( + + ); + } + + if (isError) { + return ( + void refetch()} + /> + ); + } + + if (!catalogItem) { + return ( + + ); + } + + return ( + + ); +}; + +export default ClusterCatalogItemDetailPage; diff --git a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx new file mode 100644 index 00000000..004ef045 --- /dev/null +++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx @@ -0,0 +1,101 @@ +import { Route, Routes } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ComputeInstanceCatalogItem } from '@osac/types'; +import type { ComputeInstanceCatalogItem as PrivateComputeInstanceCatalogItem } from '@osac/types/private'; + +import { SessionProvider } from '../../../hooks/use-session'; +import { mockQueryResult } from '../../../test-utils/query'; +import { renderWithProviders } from '../../../test-utils/TestProviders'; + +vi.mock('../../../api/v1/compute-instance-catalog-item', () => ({ + useComputeInstanceCatalogItem: vi.fn(), +})); +vi.mock('../../../api/v1/private/compute-instance-catalog-item', () => ({ + usePrivateComputeInstanceCatalogItem: vi.fn(), +})); + +const { useComputeInstanceCatalogItem } = + await import('../../../api/v1/compute-instance-catalog-item'); +const { usePrivateComputeInstanceCatalogItem } = + await import('../../../api/v1/private/compute-instance-catalog-item'); + +const ComputeInstanceCatalogItemDetailPage = ( + await import('./ComputeInstanceCatalogItemDetailPage') +).default; + +const publicItem: ComputeInstanceCatalogItem = { + $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', + id: 'catalog-1', + title: 'RHEL 9 VM', + description: '', + template: 'tpl-rhel-9', + published: true, + fieldDefinitions: [], +}; + +const privateItem: PrivateComputeInstanceCatalogItem = { + id: publicItem.id, + title: publicItem.title, + description: publicItem.description, + template: publicItem.template, + published: publicItem.published, + fieldDefinitions: [], + $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', + tenant: 'acme-corp', +}; + +const renderPage = (role: 'providerAdmin' | 'tenantAdmin') => + renderWithProviders( + + + } + /> + + , + { routerEntries: ['/admin/catalog/compute-instance/catalog-1'] }, + ); + +describe('ComputeInstanceCatalogItemDetailPage', () => { + beforeEach(() => { + vi.mocked(useComputeInstanceCatalogItem).mockReturnValue( + mockQueryResult({ data: publicItem }) as ReturnType, + ); + vi.mocked(usePrivateComputeInstanceCatalogItem).mockReturnValue( + mockQueryResult({ data: privateItem }) as ReturnType< + typeof usePrivateComputeInstanceCatalogItem + >, + ); + }); + + it('renders the public catalog item for tenantAdmin', async () => { + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'RHEL 9 VM' })).toBeInTheDocument(); + }); + expect(usePrivateComputeInstanceCatalogItem).toHaveBeenCalledWith(undefined); + expect(useComputeInstanceCatalogItem).toHaveBeenCalledWith('catalog-1'); + }); + + it('renders the private catalog item for providerAdmin', async () => { + renderPage('providerAdmin'); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'RHEL 9 VM' })).toBeInTheDocument(); + }); + expect(usePrivateComputeInstanceCatalogItem).toHaveBeenCalledWith('catalog-1'); + expect(useComputeInstanceCatalogItem).toHaveBeenCalledWith(undefined); + }); + + it('shows a not-found state when the item does not exist', async () => { + vi.mocked(useComputeInstanceCatalogItem).mockReturnValue( + mockQueryResult({ data: undefined }) as ReturnType, + ); + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText('Catalog item not found')).toBeInTheDocument(); + }); + }); +}); diff --git a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx new file mode 100644 index 00000000..0c3de4fb --- /dev/null +++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx @@ -0,0 +1,72 @@ +import { useParams } from 'react-router-dom'; + +import { useComputeInstanceCatalogItem } from '../../../api/v1/compute-instance-catalog-item'; +import { usePrivateComputeInstanceCatalogItem } from '../../../api/v1/private/compute-instance-catalog-item'; +import CatalogItemDetails from '../../../components/catalogManagement/CatalogItemDetails'; +import { ResourceDetailsPageError } from '../../../components/Resource/ResourceDetailsPageError'; +import { ResourceDetailsPageLoading } from '../../../components/Resource/ResourceDetailsPageLoading'; +import { useSession } from '../../../hooks/use-session'; +import { useTranslation } from '../../../hooks/useTranslation'; + +const ComputeInstanceCatalogItemDetailPage = () => { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const { role } = useSession(); + const isProviderAdmin = role === 'providerAdmin'; + + const publicResult = useComputeInstanceCatalogItem(!isProviderAdmin ? id : undefined); + const privateResult = usePrivateComputeInstanceCatalogItem(isProviderAdmin ? id : undefined); + const { + data: catalogItem, + isLoading, + isError, + error, + refetch, + } = isProviderAdmin ? privateResult : publicResult; + + if (isLoading) { + return ( + + ); + } + + if (isError) { + return ( + void refetch()} + /> + ); + } + + if (!catalogItem) { + return ( + + ); + } + + return ( + + ); +}; + +export default ComputeInstanceCatalogItemDetailPage; From 626a88cb6a5cc02008b3f5227c41f527573eb849 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 17:22:05 +0300 Subject: [PATCH 11/22] OSAC-2934: sync i18n translations for detail page tab labels Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index a6f20afb..a7a5d1ea 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -162,6 +162,7 @@ "Failed to load security groups": "Failed to load security groups", "Failed to load subnets": "Failed to load subnets", "Field definitions": "Field definitions", + "Field Definitions": "Field Definitions", "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", @@ -255,6 +256,7 @@ "Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.", "Provision bare metal": "Provision bare metal", "Provisioned resources": "Provisioned resources", + "Provisioned Resources": "Provisioned Resources", "Provisioning": "Provisioning", "Provisioning failed": "Provisioning failed", "Public IP": "Public IP", From 354417f9636ad04d9908f65ca8729858922dfd54 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 28 Jul 2026 20:30:58 +0300 Subject: [PATCH 12/22] =?UTF-8?q?OSAC-2934:=20address=20code=20review=20fi?= =?UTF-8?q?ndings=20=E2=80=94=20i18n,=20test=20coverage,=20security=20comm?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 7 ++ .../Primitives/SanitizedMarkdown.tsx | 3 + .../catalog/catalogItemDisplay.test.ts | 97 ++++++++++++------- .../components/catalog/catalogItemDisplay.ts | 21 ++-- .../CatalogItemDetailActionButtons.test.tsx | 1 + .../CatalogItemFieldDefinitionsTab.tsx | 2 +- 6 files changed, 87 insertions(+), 44 deletions(-) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index a7a5d1ea..8313e338 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -137,6 +137,7 @@ "Edit rule": "Edit rule", "Editable": "Editable", "Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.": "Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.", + "enum: [{{value}}]": "enum: [{{value}}]", "Error": "Error", "Error loading virtual networks": "Error loading virtual networks", "Establishing console connection...": "Establishing console connection...", @@ -195,7 +196,11 @@ "Loading subnets...": "Loading subnets...", "Manage firewall rules for your virtual networks.": "Manage firewall rules for your virtual networks.", "Manage virtual networks for your compute instances.": "Manage virtual networks for your compute instances.", + "max length: {{value}}": "max length: {{value}}", + "max: {{value}}": "max: {{value}}", "Message": "Message", + "min length: {{value}}": "min length: {{value}}", + "min: {{value}}": "min: {{value}}", "Name": "Name", "Name cannot end with a hyphen": "Name cannot end with a hyphen", "Name cannot start with a hyphen": "Name cannot start with a hyphen", @@ -238,6 +243,7 @@ "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.": "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.", "Paste from clipboard": "Paste from clipboard", "Path": "Path", + "pattern: {{value}}": "pattern: {{value}}", "Paused": "Paused", "Pod CIDR": "Pod CIDR", "Pool size is required": "Pool size is required", @@ -333,6 +339,7 @@ "Virtual Network": "Virtual Network", "Virtual network is required": "Virtual network is required", "Virtual networks": "Virtual networks", + "whole number": "whole number", "Worker nodes": "Worker nodes", "Yes": "Yes", "You are not authorized to access this resource.": "You are not authorized to access this resource." diff --git a/libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx b/libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx index c0e79f1c..482fa881 100644 --- a/libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx +++ b/libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx @@ -9,6 +9,9 @@ interface SanitizedMarkdownProps { // text and restricts link/image URL protocols on its own. rehype-sanitize's schema is currently // a defense-in-depth no-op on top of that — it becomes load-bearing (and must be reviewed) the // moment rehype-raw or a plugin like remark-gfm that introduces id-based DOM clobbering is added. +// Links render as same-tab navigation (react-markdown's default, no target/rel added) — this is +// the current default, not a deliberate product decision; revisit if description links are ever +// meant to open externally, adding rel="noopener noreferrer" alongside any target="_blank". const SanitizedMarkdown = ({ children }: SanitizedMarkdownProps) => ( {children} ); diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts index ef52fbe8..62b27c51 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts @@ -1,3 +1,4 @@ +import type { TFunction } from 'i18next'; import { describe, expect, it } from 'vitest'; import { ClusterCatalogItem } from '@osac/types'; @@ -332,104 +333,130 @@ describe('formatCatalogFieldValidationSummary', () => { editable: true, }; + const t = ((key: string, options?: { value?: string | number }) => + options?.value !== undefined + ? key.replace('{{value}}', String(options.value)) + : key) as TFunction; + it('returns an em dash when there is no validation schema', () => { - expect(formatCatalogFieldValidationSummary(baseDef)).toBe('—'); + expect(formatCatalogFieldValidationSummary(baseDef, t)).toBe('—'); }); it('returns an em dash for an empty validation schema', () => { - expect(formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: {} })).toBe('—'); + expect(formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: {} }, t)).toBe('—'); }); it('summarizes minimum and maximum together', () => { expect( - formatCatalogFieldValidationSummary({ - ...baseDef, - validationSchema: { minimum: 1, maximum: 10 }, - }), + formatCatalogFieldValidationSummary( + { + ...baseDef, + validationSchema: { minimum: 1, maximum: 10 }, + }, + t, + ), ).toBe('min: 1, max: 10'); }); it('summarizes a minimum-only constraint', () => { expect( - formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { minimum: 2 } }), + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { minimum: 2 } }, t), ).toBe('min: 2'); }); it('summarizes string length constraints', () => { expect( - formatCatalogFieldValidationSummary({ - ...baseDef, - validationSchema: { minLength: 3, maxLength: 20 }, - }), + formatCatalogFieldValidationSummary( + { + ...baseDef, + validationSchema: { minLength: 3, maxLength: 20 }, + }, + t, + ), ).toBe('min length: 3, max length: 20'); }); it('summarizes a regex pattern', () => { expect( - formatCatalogFieldValidationSummary({ - ...baseDef, - validationSchema: { pattern: '^[a-z]+$' }, - }), + formatCatalogFieldValidationSummary( + { + ...baseDef, + validationSchema: { pattern: '^[a-z]+$' }, + }, + t, + ), ).toBe('pattern: ^[a-z]+$'); }); it('summarizes an enum constraint', () => { expect( - formatCatalogFieldValidationSummary({ - ...baseDef, - validationSchema: { enum: ['a', 'b', 'c'] }, - }), + formatCatalogFieldValidationSummary( + { + ...baseDef, + validationSchema: { enum: ['a', 'b', 'c'] }, + }, + t, + ), ).toBe('enum: [a, b, c]'); }); it('combines multiple constraint types', () => { expect( - formatCatalogFieldValidationSummary({ - ...baseDef, - validationSchema: { pattern: '^[a-z]+$', minLength: 1 }, - }), + formatCatalogFieldValidationSummary( + { + ...baseDef, + validationSchema: { pattern: '^[a-z]+$', minLength: 1 }, + }, + t, + ), ).toBe('min length: 1, pattern: ^[a-z]+$'); }); it('returns an em dash for unrecognized schema keywords', () => { expect( - formatCatalogFieldValidationSummary({ - ...baseDef, - validationSchema: { oneOf: [{ type: 'string' }, { type: 'number' }] }, - }), + formatCatalogFieldValidationSummary( + { + ...baseDef, + validationSchema: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + }, + t, + ), ).toBe('—'); }); it('includes a zero minimum (falsy but valid)', () => { expect( - formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { minimum: 0 } }), + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { minimum: 0 } }, t), ).toBe('min: 0'); }); it('ignores an empty enum array', () => { expect( - formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { enum: [] } }), + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { enum: [] } }, t), ).toBe('—'); }); it('returns an em dash for a type-only schema with no constraints', () => { expect( - formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { type: 'boolean' } }), + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { type: 'boolean' } }, t), ).toBe('—'); }); it('summarizes an integer type with no explicit bounds as a whole-number constraint', () => { expect( - formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { type: 'integer' } }), + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { type: 'integer' } }, t), ).toBe('whole number'); }); it('omits the whole-number label when integer bounds are already present', () => { expect( - formatCatalogFieldValidationSummary({ - ...baseDef, - validationSchema: { type: 'integer', minimum: 1 }, - }), + formatCatalogFieldValidationSummary( + { + ...baseDef, + validationSchema: { type: 'integer', minimum: 1 }, + }, + t, + ), ).toBe('min: 1'); }); }); diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts index be30d76e..5147d9aa 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts @@ -1,3 +1,5 @@ +import type { TFunction } from 'i18next'; + import type { BareMetalInstanceCatalogItem, ClusterCatalogItem, @@ -175,7 +177,10 @@ export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string = return fieldDefinitionDefaultToInputString(defaultValue) || '—'; }; -export const formatCatalogFieldValidationSummary = (def: CatalogFieldDefinition): string => { +export const formatCatalogFieldValidationSummary = ( + def: CatalogFieldDefinition, + t: TFunction, +): string => { const schema = def.validationSchema; if (!schema || !Object.keys(schema).length) { return '—'; @@ -187,25 +192,25 @@ export const formatCatalogFieldValidationSummary = (def: CatalogFieldDefinition) typeof schema.minimum !== 'number' && typeof schema.maximum !== 'number' ) { - parts.push('whole number'); + parts.push(t('whole number')); } if (typeof schema.minimum === 'number') { - parts.push(`min: ${schema.minimum}`); + parts.push(t('min: {{value}}', { value: schema.minimum })); } if (typeof schema.maximum === 'number') { - parts.push(`max: ${schema.maximum}`); + parts.push(t('max: {{value}}', { value: schema.maximum })); } if (typeof schema.minLength === 'number') { - parts.push(`min length: ${schema.minLength}`); + parts.push(t('min length: {{value}}', { value: schema.minLength })); } if (typeof schema.maxLength === 'number') { - parts.push(`max length: ${schema.maxLength}`); + parts.push(t('max length: {{value}}', { value: schema.maxLength })); } if (typeof schema.pattern === 'string' && schema.pattern) { - parts.push(`pattern: ${schema.pattern}`); + parts.push(t('pattern: {{value}}', { value: schema.pattern })); } if (Array.isArray(schema.enum) && schema.enum.length > 0) { - parts.push(`enum: [${schema.enum.map(String).join(', ')}]`); + parts.push(t('enum: [{{value}}]', { value: schema.enum.map(String).join(', ') })); } return parts.length > 0 ? parts.join(', ') : '—'; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx index 9e7a4048..557ae418 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx @@ -79,6 +79,7 @@ describe('CatalogItemDetailActionButtons', () => { expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeInTheDocument(); }); it('renders all actions for tenantAdmin on a project-scoped item', () => { diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.tsx index f0f94443..7025edc8 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemFieldDefinitionsTab.tsx @@ -44,7 +44,7 @@ const CatalogItemFieldDefinitionsTab = ({ catalogItem }: CatalogItemFieldDefinit {def.editable ? t('Yes') : t('No')} {formatCatalogFieldDefault(def)} - {formatCatalogFieldValidationSummary(def)} + {formatCatalogFieldValidationSummary(def, t)} ))} From 7ba674c7b6b8ddc830983eb8b8afbd8dd5ed9ba0 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 29 Jul 2026 09:10:53 +0300 Subject: [PATCH 13/22] OSAC-2934: disable Delete and publish toggle with a tooltip until OSAC-2933 lands Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 1 + .../CatalogItemDetailActionButtons.test.tsx | 44 +++++++++++++++++++ .../CatalogItemDetailActionButtons.tsx | 35 ++++++++++++--- .../CatalogItemDetails.test.tsx | 7 ++- .../catalogManagement/CatalogItemDetails.tsx | 1 + 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 8313e338..2eec4434 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -126,6 +126,7 @@ "Delete security group": "Delete security group", "Delete security group?": "Delete security group?", "Deleting": "Deleting", + "Deleting and publishing catalog items is not yet available.": "Deleting and publishing catalog items is not yet available.", "deprecated": "deprecated", "Description": "Description", "Destination CIDR": "Destination CIDR", diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx index 557ae418..82dca5ce 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx @@ -172,4 +172,48 @@ describe('CatalogItemDetailActionButtons', () => { expect(screen.getByText('edit-page')).toBeInTheDocument(); }); }); + + it('disables Delete and the publish toggle and shows a tooltip when disabledReason is set', async () => { + const onDeleteClick = vi.fn(); + const onTogglePublish = vi.fn(); + const { user } = renderWithProviders( + , + ); + + const deleteButton = screen.getByRole('button', { name: 'Delete' }); + expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); + await user.click(deleteButton); + expect(onDeleteClick).not.toHaveBeenCalled(); + + expect(screen.getByRole('switch')).toBeDisabled(); + await user.click(screen.getByRole('switch')); + expect(onTogglePublish).not.toHaveBeenCalled(); + + await user.hover(deleteButton); + expect( + await screen.findAllByText('Deleting and publishing catalog items is not yet available.'), + ).not.toHaveLength(0); + }); + + it('does not disable Delete or the publish toggle when disabledReason is not set', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('button', { name: 'Delete' })).not.toHaveAttribute('aria-disabled'); + expect(screen.getByRole('switch')).not.toBeDisabled(); + }); }); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx index a79deb61..07672edf 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { Button, Flex, FlexItem } from '@patternfly/react-core'; +import { Button, Flex, FlexItem, Tooltip } from '@patternfly/react-core'; import PencilAltIcon from '@patternfly/react-icons/dist/esm/icons/pencil-alt-icon'; import TrashIcon from '@patternfly/react-icons/dist/esm/icons/trash-icon'; @@ -14,6 +14,8 @@ interface CatalogItemDetailActionButtonsProps { editHref: string; onDeleteClick: () => void; onTogglePublish: (next: boolean) => void; + /** When set, disables Delete and the publish toggle and shows this text in a tooltip on both. */ + disabledReason?: string; } const CatalogItemDetailActionButtons = ({ @@ -22,6 +24,7 @@ const CatalogItemDetailActionButtons = ({ editHref, onDeleteClick, onTogglePublish, + disabledReason, }: CatalogItemDetailActionButtonsProps) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -32,6 +35,8 @@ const CatalogItemDetailActionButtons = ({ return null; } + const isDisabled = Boolean(disabledReason); + return ( - + {isDisabled ? ( + + + + + + ) : ( + + )} - + {isDisabled ? ( + + + + ) : ( + + )} ); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx index 0e747fb7..cf8553b0 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx @@ -38,13 +38,16 @@ describe('CatalogItemDetails', () => { expect(screen.getAllByText('Published').length).toBeGreaterThan(0); }); - it('renders the header actions', () => { + it('renders the header actions, with Delete and the publish toggle disabled', () => { renderWithProviders( , ); expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + const deleteButton = screen.getByRole('button', { name: 'Delete' }); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('switch')).toBeDisabled(); }); it('shows the Overview tab by default and switches to other tabs on click', async () => { diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx index a09dc462..c85d108f 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx @@ -65,6 +65,7 @@ const CatalogItemDetails = ({ catalogItem, kind, role, templateName }: CatalogIt editHref={editHref} onDeleteClick={() => {}} onTogglePublish={() => {}} + disabledReason={t('Deleting and publishing catalog items is not yet available.')} /> From 7990260ae6f6db642d43c610330befbda470135f Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 29 Jul 2026 09:25:52 +0300 Subject: [PATCH 14/22] OSAC-2934: resolve template names for ComputeInstance and BareMetalInstance kinds Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/types/src/index.ts | 4 +- libs/ui-components/src/api/types.ts | 1 + .../v1/baremetal-instance-templates.test.ts | 55 +++++++++++++++++++ .../api/v1/baremetal-instance-templates.ts | 15 +++++ .../api/v1/compute-instance-templates.test.ts | 55 +++++++++++++++++++ .../src/api/v1/compute-instance-templates.ts | 15 +++++ ...etalInstanceCatalogItemDetailPage.test.tsx | 27 +++++++++ ...BareMetalInstanceCatalogItemDetailPage.tsx | 5 +- .../ClusterCatalogItemDetailPage.test.tsx | 20 +++++++ ...puteInstanceCatalogItemDetailPage.test.tsx | 26 +++++++++ .../ComputeInstanceCatalogItemDetailPage.tsx | 5 +- 11 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts create mode 100644 libs/ui-components/src/api/v1/baremetal-instance-templates.ts create mode 100644 libs/ui-components/src/api/v1/compute-instance-templates.test.ts create mode 100644 libs/ui-components/src/api/v1/compute-instance-templates.ts diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 484276cc..e8ce05b0 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -55,4 +55,6 @@ export * from './osac/public/v1/field_definition_type_pb.js' export * from './osac/public/v1/baremetal_instance_type_pb.js'; export * from './osac/public/v1/baremetal_instances_service_pb.js'; export * from './osac/public/v1/baremetal_instance_catalog_item_type_pb.js'; -export * from './osac/public/v1/baremetal_instance_catalog_items_service_pb.js'; \ No newline at end of file +export * from './osac/public/v1/baremetal_instance_catalog_items_service_pb.js'; +export * from './osac/public/v1/baremetal_instance_template_type_pb.js'; +export * from './osac/public/v1/baremetal_instance_templates_service_pb.js'; \ No newline at end of file diff --git a/libs/ui-components/src/api/types.ts b/libs/ui-components/src/api/types.ts index ccd54911..94660ad6 100644 --- a/libs/ui-components/src/api/types.ts +++ b/libs/ui-components/src/api/types.ts @@ -22,6 +22,7 @@ export type ApiRoute = | 'v1/subnets' | 'v1/security_groups' | 'v1/baremetal_instance_catalog_items' + | 'v1/baremetal_instance_templates' | 'v1/baremetal_instances' | 'v1/public_ips' | 'v1/public_ip_attachments' diff --git a/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts b/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts new file mode 100644 index 00000000..94a9de62 --- /dev/null +++ b/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts @@ -0,0 +1,55 @@ +import { createRouterTransport } from '@connectrpc/connect'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { BareMetalInstanceTemplate } from '@osac/types'; +import { BareMetalInstanceTemplates } from '@osac/types'; + +import { useBareMetalInstanceTemplate } from './baremetal-instance-templates'; +import { renderHookWithProviders } from '../../test-utils/TestProviders'; + +const template: BareMetalInstanceTemplate = { + $typeName: 'osac.public.v1.BareMetalInstanceTemplate', + id: 'tpl-bm-worker', + title: 'Bare Metal Worker', + description: '', + parameters: [], +}; + +describe('useBareMetalInstanceTemplate', () => { + const createTestTransport = (onGet?: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(BareMetalInstanceTemplates, { + get: (req) => { + onGet?.(req); + return { object: template }; + }, + }); + }); + + it('fetches a single template by id from the Get endpoint', async () => { + const transport = createTestTransport(); + const { result } = renderHookWithProviders( + () => useBareMetalInstanceTemplate('tpl-bm-worker'), + { role: 'tenantAdmin', transport }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject(template); + }); + + it('does not fetch when id is undefined', async () => { + let getCalled = false; + const transport = createTestTransport(() => { + getCalled = true; + }); + + renderHookWithProviders(() => useBareMetalInstanceTemplate(undefined), { + role: 'tenantAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getCalled).toBe(false); + }); +}); diff --git a/libs/ui-components/src/api/v1/baremetal-instance-templates.ts b/libs/ui-components/src/api/v1/baremetal-instance-templates.ts new file mode 100644 index 00000000..20a58bc6 --- /dev/null +++ b/libs/ui-components/src/api/v1/baremetal-instance-templates.ts @@ -0,0 +1,15 @@ +import { BareMetalInstanceTemplates } from '@osac/types'; + +import { useApiFetch } from '../api-context'; +import { apiQueryKey } from '../types'; +import { useApiQuery } from '../use-api-query'; + +export const useBareMetalInstanceTemplate = (id: string | undefined) => { + const client = useApiFetch(BareMetalInstanceTemplates); + return useApiQuery({ + queryKey: apiQueryKey('v1/baremetal_instance_templates', id ? [id] : undefined), + queryFn: () => client.get({ id: id ?? '' }), + select: (data) => data.object, + enabled: Boolean(id), + }); +}; diff --git a/libs/ui-components/src/api/v1/compute-instance-templates.test.ts b/libs/ui-components/src/api/v1/compute-instance-templates.test.ts new file mode 100644 index 00000000..8e378bbb --- /dev/null +++ b/libs/ui-components/src/api/v1/compute-instance-templates.test.ts @@ -0,0 +1,55 @@ +import { createRouterTransport } from '@connectrpc/connect'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ComputeInstanceTemplate } from '@osac/types'; +import { ComputeInstanceTemplates } from '@osac/types'; + +import { useComputeInstanceTemplate } from './compute-instance-templates'; +import { renderHookWithProviders } from '../../test-utils/TestProviders'; + +const template: ComputeInstanceTemplate = { + $typeName: 'osac.public.v1.ComputeInstanceTemplate', + id: 'tpl-rhel-9', + title: 'RHEL 9', + description: '', + parameters: [], +}; + +describe('useComputeInstanceTemplate', () => { + const createTestTransport = (onGet?: (req: unknown) => void) => + createRouterTransport((router) => { + router.service(ComputeInstanceTemplates, { + get: (req) => { + onGet?.(req); + return { object: template }; + }, + }); + }); + + it('fetches a single template by id from the Get endpoint', async () => { + const transport = createTestTransport(); + const { result } = renderHookWithProviders(() => useComputeInstanceTemplate('tpl-rhel-9'), { + role: 'tenantAdmin', + transport, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject(template); + }); + + it('does not fetch when id is undefined', async () => { + let getCalled = false; + const transport = createTestTransport(() => { + getCalled = true; + }); + + renderHookWithProviders(() => useComputeInstanceTemplate(undefined), { + role: 'tenantAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getCalled).toBe(false); + }); +}); diff --git a/libs/ui-components/src/api/v1/compute-instance-templates.ts b/libs/ui-components/src/api/v1/compute-instance-templates.ts new file mode 100644 index 00000000..f495768a --- /dev/null +++ b/libs/ui-components/src/api/v1/compute-instance-templates.ts @@ -0,0 +1,15 @@ +import { ComputeInstanceTemplates } from '@osac/types'; + +import { useApiFetch } from '../api-context'; +import { apiQueryKey } from '../types'; +import { useApiQuery } from '../use-api-query'; + +export const useComputeInstanceTemplate = (id: string | undefined) => { + const client = useApiFetch(ComputeInstanceTemplates); + return useApiQuery({ + queryKey: apiQueryKey('v1/compute_instance_templates', id ? [id] : undefined), + queryFn: () => client.get({ id: id ?? '' }), + select: (data) => data.object, + enabled: Boolean(id), + }); +}; diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx index 22035f47..4709d6f5 100644 --- a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx @@ -19,10 +19,15 @@ vi.mock('../../../api/v1/baremetal-instance', async (importOriginal) => { vi.mock('../../../api/v1/private/baremetal-instance-catalog-item', () => ({ usePrivateBareMetalInstanceCatalogItem: vi.fn(), })); +vi.mock('../../../api/v1/baremetal-instance-templates', () => ({ + useBareMetalInstanceTemplate: vi.fn(), +})); const { useBareMetalInstanceCatalogItem } = await import('../../../api/v1/baremetal-instance'); const { usePrivateBareMetalInstanceCatalogItem } = await import('../../../api/v1/private/baremetal-instance-catalog-item'); +const { useBareMetalInstanceTemplate } = + await import('../../../api/v1/baremetal-instance-templates'); const BareMetalInstanceCatalogItemDetailPage = ( await import('./BareMetalInstanceCatalogItemDetailPage') @@ -72,6 +77,9 @@ describe('BareMetalInstanceCatalogItemDetailPage', () => { typeof usePrivateBareMetalInstanceCatalogItem >, ); + vi.mocked(useBareMetalInstanceTemplate).mockReturnValue( + mockQueryResult({ data: undefined }) as ReturnType, + ); }); it('renders the public catalog item for tenantAdmin', async () => { @@ -83,6 +91,25 @@ describe('BareMetalInstanceCatalogItemDetailPage', () => { expect(useBareMetalInstanceCatalogItem).toHaveBeenCalledWith('catalog-1'); }); + it('resolves and displays the template title when available', async () => { + vi.mocked(useBareMetalInstanceTemplate).mockReturnValue( + mockQueryResult({ + data: { + $typeName: 'osac.public.v1.BareMetalInstanceTemplate', + id: 'tpl-bm-worker', + title: 'Bare Metal Worker Template', + description: '', + parameters: [], + }, + }) as ReturnType, + ); + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText('Bare Metal Worker Template')).toBeInTheDocument(); + }); + expect(useBareMetalInstanceTemplate).toHaveBeenCalledWith('tpl-bm-worker'); + }); + it('renders the private catalog item for providerAdmin', async () => { renderPage('providerAdmin'); await waitFor(() => { diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx index 140fc28a..2e50b5d3 100644 --- a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom'; import { useBareMetalInstanceCatalogItem } from '../../../api/v1/baremetal-instance'; +import { useBareMetalInstanceTemplate } from '../../../api/v1/baremetal-instance-templates'; import { usePrivateBareMetalInstanceCatalogItem } from '../../../api/v1/private/baremetal-instance-catalog-item'; import CatalogItemDetails from '../../../components/catalogManagement/CatalogItemDetails'; import { ResourceDetailsPageError } from '../../../components/Resource/ResourceDetailsPageError'; @@ -24,6 +25,8 @@ const BareMetalInstanceCatalogItemDetailPage = () => { refetch, } = isProviderAdmin ? privateResult : publicResult; + const { data: template } = useBareMetalInstanceTemplate(catalogItem?.template); + if (isLoading) { return ( { catalogItem={catalogItem} kind="baremetal-instance" role={role} - templateName={catalogItem.template} + templateName={template?.title ?? catalogItem.template} /> ); }; diff --git a/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx index 6f206632..830d9a61 100644 --- a/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx +++ b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx @@ -79,6 +79,26 @@ describe('ClusterCatalogItemDetailPage', () => { expect(useClusterCatalogItem).toHaveBeenCalledWith('catalog-1'); }); + it('resolves and displays the template title when available', async () => { + vi.mocked(useClusterTemplate).mockReturnValue( + mockQueryResult({ + data: { + $typeName: 'osac.public.v1.ClusterTemplate', + id: 'tpl-openshift-4', + title: 'OpenShift 4 Template', + description: '', + parameters: [], + nodeSets: {}, + }, + }) as ReturnType, + ); + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText('OpenShift 4 Template')).toBeInTheDocument(); + }); + expect(useClusterTemplate).toHaveBeenCalledWith('tpl-openshift-4'); + }); + it('renders the private catalog item for providerAdmin', async () => { renderPage('providerAdmin'); await waitFor(() => { diff --git a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx index 004ef045..b56b0f06 100644 --- a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx +++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx @@ -15,11 +15,15 @@ vi.mock('../../../api/v1/compute-instance-catalog-item', () => ({ vi.mock('../../../api/v1/private/compute-instance-catalog-item', () => ({ usePrivateComputeInstanceCatalogItem: vi.fn(), })); +vi.mock('../../../api/v1/compute-instance-templates', () => ({ + useComputeInstanceTemplate: vi.fn(), +})); const { useComputeInstanceCatalogItem } = await import('../../../api/v1/compute-instance-catalog-item'); const { usePrivateComputeInstanceCatalogItem } = await import('../../../api/v1/private/compute-instance-catalog-item'); +const { useComputeInstanceTemplate } = await import('../../../api/v1/compute-instance-templates'); const ComputeInstanceCatalogItemDetailPage = ( await import('./ComputeInstanceCatalogItemDetailPage') @@ -69,6 +73,9 @@ describe('ComputeInstanceCatalogItemDetailPage', () => { typeof usePrivateComputeInstanceCatalogItem >, ); + vi.mocked(useComputeInstanceTemplate).mockReturnValue( + mockQueryResult({ data: undefined }) as ReturnType, + ); }); it('renders the public catalog item for tenantAdmin', async () => { @@ -80,6 +87,25 @@ describe('ComputeInstanceCatalogItemDetailPage', () => { expect(useComputeInstanceCatalogItem).toHaveBeenCalledWith('catalog-1'); }); + it('resolves and displays the template title when available', async () => { + vi.mocked(useComputeInstanceTemplate).mockReturnValue( + mockQueryResult({ + data: { + $typeName: 'osac.public.v1.ComputeInstanceTemplate', + id: 'tpl-rhel-9', + title: 'RHEL 9 Template', + description: '', + parameters: [], + }, + }) as ReturnType, + ); + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText('RHEL 9 Template')).toBeInTheDocument(); + }); + expect(useComputeInstanceTemplate).toHaveBeenCalledWith('tpl-rhel-9'); + }); + it('renders the private catalog item for providerAdmin', async () => { renderPage('providerAdmin'); await waitFor(() => { diff --git a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx index 0c3de4fb..6e1b73d8 100644 --- a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx +++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom'; import { useComputeInstanceCatalogItem } from '../../../api/v1/compute-instance-catalog-item'; +import { useComputeInstanceTemplate } from '../../../api/v1/compute-instance-templates'; import { usePrivateComputeInstanceCatalogItem } from '../../../api/v1/private/compute-instance-catalog-item'; import CatalogItemDetails from '../../../components/catalogManagement/CatalogItemDetails'; import { ResourceDetailsPageError } from '../../../components/Resource/ResourceDetailsPageError'; @@ -24,6 +25,8 @@ const ComputeInstanceCatalogItemDetailPage = () => { refetch, } = isProviderAdmin ? privateResult : publicResult; + const { data: template } = useComputeInstanceTemplate(catalogItem?.template); + if (isLoading) { return ( { catalogItem={catalogItem} kind="compute-instance" role={role} - templateName={catalogItem.template} + templateName={template?.title ?? catalogItem.template} /> ); }; From 43b21ca9778ad9e2e278ceaff61f6e80999a8cba Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 29 Jul 2026 09:34:20 +0300 Subject: [PATCH 15/22] OSAC-2934: extract shared loading/error/not-found shell for detail pages Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../admin/CatalogItemDetailPageShell.test.tsx | 86 +++++++++++++++++++ .../admin/CatalogItemDetailPageShell.tsx | 79 +++++++++++++++++ ...BareMetalInstanceCatalogItemDetailPage.tsx | 49 ++--------- .../cluster/ClusterCatalogItemDetailPage.tsx | 49 ++--------- .../ComputeInstanceCatalogItemDetailPage.tsx | 49 ++--------- 5 files changed, 186 insertions(+), 126 deletions(-) create mode 100644 libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx create mode 100644 libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.tsx diff --git a/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx b/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx new file mode 100644 index 00000000..db11fb72 --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx @@ -0,0 +1,86 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; + +import CatalogItemDetailPageShell from './CatalogItemDetailPageShell'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const catalogItem: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: '', + template: 'tpl-openshift-4', + published: true, + fieldDefinitions: [], +}; + +describe('CatalogItemDetailPageShell', () => { + it('shows a loading state and not the real content', () => { + renderWithProviders( + , + ); + + expect(screen.queryByRole('heading', { name: 'OpenShift 4 cluster' })).not.toBeInTheDocument(); + expect(screen.getByText('Catalog management')).toBeInTheDocument(); + }); + + it('shows an error state when the query fails', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Could not load catalog item')).toBeInTheDocument(); + }); + + it('shows a not-found state when the item does not exist', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Catalog item not found')).toBeInTheDocument(); + }); + + it('renders CatalogItemDetails once the catalog item is loaded', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('heading', { name: 'OpenShift 4 cluster' })).toBeInTheDocument(); + expect(screen.getByText('OpenShift 4 Template')).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.tsx b/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.tsx new file mode 100644 index 00000000..c0e260ed --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.tsx @@ -0,0 +1,79 @@ +import type { CatalogItem } from '../../components/catalog/catalogItemDisplay'; +import CatalogItemDetails from '../../components/catalogManagement/CatalogItemDetails'; +import type { CatalogItemDetailKind } from '../../components/catalogManagement/CatalogItemProvisionedResourcesTab'; +import { ResourceDetailsPageError } from '../../components/Resource/ResourceDetailsPageError'; +import { ResourceDetailsPageLoading } from '../../components/Resource/ResourceDetailsPageLoading'; +import { useTranslation } from '../../hooks/useTranslation'; +import type { DemoShellRole } from '../../shellTypes'; + +interface CatalogItemDetailPageShellProps { + catalogItem: CatalogItem | undefined; + kind: CatalogItemDetailKind; + role: DemoShellRole; + templateName?: string; + isLoading: boolean; + isError: boolean; + error: unknown; + onRetry: () => void; +} + +/** Shared loading/error/not-found/success shell for the three kind-specific catalog item detail + * pages — each page owns its own role-based hook selection, then delegates rendering here. */ +const CatalogItemDetailPageShell = ({ + catalogItem, + kind, + role, + templateName, + isLoading, + isError, + error, + onRetry, +}: CatalogItemDetailPageShellProps) => { + const { t } = useTranslation(); + + if (isLoading) { + return ( + + ); + } + + if (isError) { + return ( + + ); + } + + if (!catalogItem) { + return ( + + ); + } + + return ( + + ); +}; + +export default CatalogItemDetailPageShell; diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx index 2e50b5d3..0be4a2bd 100644 --- a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx @@ -3,14 +3,10 @@ import { useParams } from 'react-router-dom'; import { useBareMetalInstanceCatalogItem } from '../../../api/v1/baremetal-instance'; import { useBareMetalInstanceTemplate } from '../../../api/v1/baremetal-instance-templates'; import { usePrivateBareMetalInstanceCatalogItem } from '../../../api/v1/private/baremetal-instance-catalog-item'; -import CatalogItemDetails from '../../../components/catalogManagement/CatalogItemDetails'; -import { ResourceDetailsPageError } from '../../../components/Resource/ResourceDetailsPageError'; -import { ResourceDetailsPageLoading } from '../../../components/Resource/ResourceDetailsPageLoading'; import { useSession } from '../../../hooks/use-session'; -import { useTranslation } from '../../../hooks/useTranslation'; +import CatalogItemDetailPageShell from '../CatalogItemDetailPageShell'; const BareMetalInstanceCatalogItemDetailPage = () => { - const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const { role } = useSession(); const isProviderAdmin = role === 'providerAdmin'; @@ -27,47 +23,16 @@ const BareMetalInstanceCatalogItemDetailPage = () => { const { data: template } = useBareMetalInstanceTemplate(catalogItem?.template); - if (isLoading) { - return ( - - ); - } - - if (isError) { - return ( - void refetch()} - /> - ); - } - - if (!catalogItem) { - return ( - - ); - } - return ( - void refetch()} /> ); }; diff --git a/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx index 95ede268..16dbfcf8 100644 --- a/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx +++ b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx @@ -3,14 +3,10 @@ import { useParams } from 'react-router-dom'; import { useClusterCatalogItem } from '../../../api/v1/cluster-catalog-item'; import { useClusterTemplate } from '../../../api/v1/cluster-templates'; import { usePrivateClusterCatalogItem } from '../../../api/v1/private/cluster-catalog-item'; -import CatalogItemDetails from '../../../components/catalogManagement/CatalogItemDetails'; -import { ResourceDetailsPageError } from '../../../components/Resource/ResourceDetailsPageError'; -import { ResourceDetailsPageLoading } from '../../../components/Resource/ResourceDetailsPageLoading'; import { useSession } from '../../../hooks/use-session'; -import { useTranslation } from '../../../hooks/useTranslation'; +import CatalogItemDetailPageShell from '../CatalogItemDetailPageShell'; const ClusterCatalogItemDetailPage = () => { - const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const { role } = useSession(); const isProviderAdmin = role === 'providerAdmin'; @@ -27,47 +23,16 @@ const ClusterCatalogItemDetailPage = () => { const { data: template } = useClusterTemplate(catalogItem?.template); - if (isLoading) { - return ( - - ); - } - - if (isError) { - return ( - void refetch()} - /> - ); - } - - if (!catalogItem) { - return ( - - ); - } - return ( - void refetch()} /> ); }; diff --git a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx index 6e1b73d8..2a35db94 100644 --- a/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx +++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx @@ -3,14 +3,10 @@ import { useParams } from 'react-router-dom'; import { useComputeInstanceCatalogItem } from '../../../api/v1/compute-instance-catalog-item'; import { useComputeInstanceTemplate } from '../../../api/v1/compute-instance-templates'; import { usePrivateComputeInstanceCatalogItem } from '../../../api/v1/private/compute-instance-catalog-item'; -import CatalogItemDetails from '../../../components/catalogManagement/CatalogItemDetails'; -import { ResourceDetailsPageError } from '../../../components/Resource/ResourceDetailsPageError'; -import { ResourceDetailsPageLoading } from '../../../components/Resource/ResourceDetailsPageLoading'; import { useSession } from '../../../hooks/use-session'; -import { useTranslation } from '../../../hooks/useTranslation'; +import CatalogItemDetailPageShell from '../CatalogItemDetailPageShell'; const ComputeInstanceCatalogItemDetailPage = () => { - const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const { role } = useSession(); const isProviderAdmin = role === 'providerAdmin'; @@ -27,47 +23,16 @@ const ComputeInstanceCatalogItemDetailPage = () => { const { data: template } = useComputeInstanceTemplate(catalogItem?.template); - if (isLoading) { - return ( - - ); - } - - if (isError) { - return ( - void refetch()} - /> - ); - } - - if (!catalogItem) { - return ( - - ); - } - return ( - void refetch()} /> ); }; From efbdd1c65f76076493e50214e4a384e19f48d22e Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 29 Jul 2026 13:46:06 +0300 Subject: [PATCH 16/22] OSAC-2934: split provisioned resources tab per kind, derive kind internally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single kind-switching CatalogItemProvisionedResourcesTab with three per-kind components (Cluster/ComputeInstance/BareMetalInstance), each calling only its own hook — matching the per-kind convention used elsewhere in catalog management (list panels, create wizard pages). CatalogItemDetails now derives its kind from catalogItem.$typeName via catalogItemDetailKind() instead of requiring callers to pass a redundant kind prop. Addresses review feedback from rawagner on PR #106. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- ...alInstanceProvisionedResourcesTab.test.tsx | 57 ++++++ ...reMetalInstanceProvisionedResourcesTab.tsx | 68 +++++++ .../CatalogItemDetails.test.tsx | 47 ++++- .../catalogManagement/CatalogItemDetails.tsx | 20 +- ...atalogItemProvisionedResourcesTab.test.tsx | 174 ----------------- .../CatalogItemProvisionedResourcesTab.tsx | 175 ------------------ .../ClusterProvisionedResourcesTab.test.tsx | 98 ++++++++++ .../ClusterProvisionedResourcesTab.tsx | 66 +++++++ ...teInstanceProvisionedResourcesTab.test.tsx | 57 ++++++ ...ComputeInstanceProvisionedResourcesTab.tsx | 68 +++++++ .../ProvisionedResourcesTable.test.tsx | 139 ++++++++++++++ .../ProvisionedResourcesTable.tsx | 92 +++++++++ .../catalogItemDetailKind.test.ts | 73 ++++++++ .../catalogItemDetailKind.ts | 22 +++ .../admin/CatalogItemDetailPageShell.test.tsx | 4 - .../admin/CatalogItemDetailPageShell.tsx | 12 +- ...BareMetalInstanceCatalogItemDetailPage.tsx | 1 - .../cluster/ClusterCatalogItemDetailPage.tsx | 1 - .../ComputeInstanceCatalogItemDetailPage.tsx | 1 - 19 files changed, 794 insertions(+), 381 deletions(-) create mode 100644 libs/ui-components/src/components/catalogManagement/BareMetalInstanceProvisionedResourcesTab.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/BareMetalInstanceProvisionedResourcesTab.tsx delete mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.test.tsx delete mode 100644 libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/ClusterProvisionedResourcesTab.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/ClusterProvisionedResourcesTab.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/ComputeInstanceProvisionedResourcesTab.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/ComputeInstanceProvisionedResourcesTab.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/ProvisionedResourcesTable.test.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/ProvisionedResourcesTable.tsx create mode 100644 libs/ui-components/src/components/catalogManagement/catalogItemDetailKind.test.ts create mode 100644 libs/ui-components/src/components/catalogManagement/catalogItemDetailKind.ts diff --git a/libs/ui-components/src/components/catalogManagement/BareMetalInstanceProvisionedResourcesTab.test.tsx b/libs/ui-components/src/components/catalogManagement/BareMetalInstanceProvisionedResourcesTab.test.tsx new file mode 100644 index 00000000..fcd313e6 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/BareMetalInstanceProvisionedResourcesTab.test.tsx @@ -0,0 +1,57 @@ +import { screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { BareMetalInstance } from '@osac/types'; + +import { mockQueryResult } from '../../test-utils/query'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +vi.mock('@osac/ui-components/api/v1/baremetal-instance', () => ({ + useBareMetalInstancesForCatalogItem: vi.fn(), +})); + +const { useBareMetalInstancesForCatalogItem } = + await import('@osac/ui-components/api/v1/baremetal-instance'); + +const BareMetalInstanceProvisionedResourcesTab = ( + await import('./BareMetalInstanceProvisionedResourcesTab') +).default; + +const bmi = (id: string): BareMetalInstance => + ({ + id, + metadata: { name: `bmi-${id}` }, + status: {}, + }) as BareMetalInstance; + +describe('BareMetalInstanceProvisionedResourcesTab', () => { + beforeEach(() => { + vi.mocked(useBareMetalInstancesForCatalogItem).mockReturnValue( + mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< + typeof useBareMetalInstancesForCatalogItem + >, + ); + }); + + it('renders bare metal instance rows linking to the detail page', () => { + vi.mocked(useBareMetalInstancesForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [bmi('bmi-1')], total: 1 }, + }) as ReturnType, + ); + + renderWithProviders(); + + const link = screen.getByRole('link', { name: 'bmi-bmi-1' }); + expect(link).toHaveAttribute('href', '/bare-metal/bmi-1'); + }); + + it('queries only bare metal instances scoped to the given catalog item id', () => { + renderWithProviders(); + + expect(useBareMetalInstancesForCatalogItem).toHaveBeenCalledWith( + 'catalog-1', + expect.objectContaining({ limit: 10, offset: 0 }), + ); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/BareMetalInstanceProvisionedResourcesTab.tsx b/libs/ui-components/src/components/catalogManagement/BareMetalInstanceProvisionedResourcesTab.tsx new file mode 100644 index 00000000..7974c876 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/BareMetalInstanceProvisionedResourcesTab.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import type { OnPerPageSelect, OnSetPage } from '@patternfly/react-core'; + +import type { BareMetalInstance } from '@osac/types'; + +import ProvisionedResourcesTable, { + type ProvisionedResourceRow, +} from './ProvisionedResourcesTable'; +import { useBareMetalInstancesForCatalogItem } from '../../api/v1/baremetal-instance'; +import { resourceDisplayName } from '../../api/v1/networking'; +import { BareMetalStatusLabel } from '../BareMetalInstance/BareMetalStatusLabel'; + +interface BareMetalInstanceProvisionedResourcesTabProps { + catalogItemId: string; +} + +const DEFAULT_PER_PAGE = 10; + +const bareMetalRow = (item: BareMetalInstance): ProvisionedResourceRow => ({ + id: item.id, + name: resourceDisplayName(item.metadata, item.id), + status: , + createdAt: item.metadata?.creationTimestamp, + href: `/bare-metal/${item.id}`, +}); + +const BareMetalInstanceProvisionedResourcesTab = ({ + catalogItemId, +}: BareMetalInstanceProvisionedResourcesTabProps) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); + const offset = (page - 1) * perPage; + + // The parent route only swaps the `:id` param when the admin navigates between catalog items — + // this component's instance is reused rather than remounted, so page state must reset explicitly. + useEffect(() => { + setPage(1); + }, [catalogItemId]); + + const { data, isLoading, error } = useBareMetalInstancesForCatalogItem(catalogItemId, { + limit: perPage, + offset, + }); + + const handleSetPage: OnSetPage = (_event, newPage) => { + setPage(newPage); + }; + + const handlePerPageSelect: OnPerPageSelect = (_event, newPerPage) => { + setPerPage(newPerPage); + setPage(1); + }; + + return ( + + ); +}; + +export default BareMetalInstanceProvisionedResourcesTab; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx index cf8553b0..3e230ea8 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx @@ -1,7 +1,8 @@ -import { screen } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import type { ClusterCatalogItem } from '@osac/types'; +import type { BareMetalInstanceCatalogItem, ClusterCatalogItem } from '@osac/types'; import CatalogItemDetails from './CatalogItemDetails'; import { renderWithProviders } from '../../test-utils/TestProviders'; @@ -30,18 +31,14 @@ const catalogItem: ClusterCatalogItem = { describe('CatalogItemDetails', () => { it('renders the header with name and status', () => { - renderWithProviders( - , - ); + renderWithProviders(); expect(screen.getByRole('heading', { name: 'OpenShift 4 cluster' })).toBeInTheDocument(); expect(screen.getAllByText('Published').length).toBeGreaterThan(0); }); it('renders the header actions, with Delete and the publish toggle disabled', () => { - renderWithProviders( - , - ); + renderWithProviders(); expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); const deleteButton = screen.getByRole('button', { name: 'Delete' }); @@ -52,7 +49,7 @@ describe('CatalogItemDetails', () => { it('shows the Overview tab by default and switches to other tabs on click', async () => { const { user } = renderWithProviders( - , + , ); expect(screen.getByText('A standard OpenShift cluster')).toBeInTheDocument(); @@ -60,4 +57,36 @@ describe('CatalogItemDetails', () => { await user.click(screen.getByRole('tab', { name: 'Field Definitions' })); expect(screen.getByText('release_image')).toBeInTheDocument(); }); + + it('derives the kind from the catalog item type rather than requiring a prop', async () => { + const bareMetalItem: BareMetalInstanceCatalogItem = { + $typeName: 'osac.public.v1.BareMetalInstanceCatalogItem', + id: 'catalog-2', + title: 'Bare Metal Worker', + description: '', + template: 'tpl-bm-worker', + published: true, + fieldDefinitions: [], + }; + + const { user } = renderWithProviders( + + } + /> + edit-page} /> + , + { routerEntries: ['/admin/catalog/baremetal-instance/catalog-2'] }, + ); + + // The Edit button's href is derived from `catalogItemDetailKind(catalogItem)` — navigating to + // the bare-metal-specific edit route (rather than a cluster or compute-instance one) proves + // `kind` was correctly derived as 'baremetal-instance' without ever passing a `kind` prop. + await user.click(screen.getByRole('button', { name: 'Edit' })); + + await waitFor(() => { + expect(screen.getByText('edit-page')).toBeInTheDocument(); + }); + }); }); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx index c85d108f..b5b1c3df 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx @@ -12,12 +12,14 @@ import { Tabs, } from '@patternfly/react-core'; +import BareMetalInstanceProvisionedResourcesTab from './BareMetalInstanceProvisionedResourcesTab'; import CatalogItemDetailActionButtons from './CatalogItemDetailActionButtons'; +import { catalogItemDetailKind } from './catalogItemDetailKind'; import CatalogItemFieldDefinitionsTab from './CatalogItemFieldDefinitionsTab'; import CatalogItemOverviewTab from './CatalogItemOverviewTab'; -import type { CatalogItemDetailKind } from './CatalogItemProvisionedResourcesTab'; -import CatalogItemProvisionedResourcesTab from './CatalogItemProvisionedResourcesTab'; import CatalogItemStatusLabel from './CatalogItemStatusLabel'; +import ClusterProvisionedResourcesTab from './ClusterProvisionedResourcesTab'; +import ComputeInstanceProvisionedResourcesTab from './ComputeInstanceProvisionedResourcesTab'; import { useTranslation } from '../../hooks/useTranslation'; import type { DemoShellRole } from '../../shellTypes'; import type { CatalogItem } from '../catalog/catalogItemDisplay'; @@ -25,7 +27,6 @@ import { ResourceDetailHeader } from '../Resource/ResourceDetailHeader'; interface CatalogItemDetailsProps { catalogItem: CatalogItem; - kind: CatalogItemDetailKind; role: DemoShellRole; templateName?: string; } @@ -34,9 +35,10 @@ const OVERVIEW_TAB_ID = 'catalog-item-detail-tab-overview'; const FIELD_DEFINITIONS_TAB_ID = 'catalog-item-detail-tab-field-definitions'; const PROVISIONED_RESOURCES_TAB_ID = 'catalog-item-detail-tab-provisioned-resources'; -const CatalogItemDetails = ({ catalogItem, kind, role, templateName }: CatalogItemDetailsProps) => { +const CatalogItemDetails = ({ catalogItem, role, templateName }: CatalogItemDetailsProps) => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(0); + const kind = catalogItemDetailKind(catalogItem); const editHref = `/admin/catalog/${kind}/${catalogItem.id}/edit`; return ( @@ -128,7 +130,15 @@ const CatalogItemDetails = ({ catalogItem, kind, role, templateName }: CatalogIt hidden={activeTab !== 2} > - + {kind === 'cluster' && ( + + )} + {kind === 'compute-instance' && ( + + )} + {kind === 'baremetal-instance' && ( + + )} diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.test.tsx deleted file mode 100644 index 6add4ad4..00000000 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { Cluster } from '@osac/types'; - -import { mockQueryResult } from '../../test-utils/query'; -import { renderWithProviders } from '../../test-utils/TestProviders'; - -vi.mock('@osac/ui-components/api/v1/cluster', () => ({ - useClustersForCatalogItem: vi.fn(), -})); -vi.mock('@osac/ui-components/api/v1/compute-instance', () => ({ - useComputeInstancesForCatalogItem: vi.fn(), -})); -vi.mock('@osac/ui-components/api/v1/baremetal-instance', () => ({ - useBareMetalInstancesForCatalogItem: vi.fn(), -})); - -const { useClustersForCatalogItem } = await import('@osac/ui-components/api/v1/cluster'); -const { useComputeInstancesForCatalogItem } = - await import('@osac/ui-components/api/v1/compute-instance'); -const { useBareMetalInstancesForCatalogItem } = - await import('@osac/ui-components/api/v1/baremetal-instance'); - -const CatalogItemProvisionedResourcesTab = (await import('./CatalogItemProvisionedResourcesTab')) - .default; - -const cluster = (id: string): Cluster => - ({ - id, - metadata: { name: `cluster-${id}` }, - status: {}, - }) as Cluster; - -describe('CatalogItemProvisionedResourcesTab', () => { - beforeEach(() => { - vi.mocked(useClustersForCatalogItem).mockReturnValue( - mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< - typeof useClustersForCatalogItem - >, - ); - vi.mocked(useComputeInstancesForCatalogItem).mockReturnValue( - mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< - typeof useComputeInstancesForCatalogItem - >, - ); - vi.mocked(useBareMetalInstancesForCatalogItem).mockReturnValue( - mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< - typeof useBareMetalInstancesForCatalogItem - >, - ); - }); - - it('renders cluster rows linking to the cluster detail page', () => { - vi.mocked(useClustersForCatalogItem).mockReturnValue( - mockQueryResult({ - data: { items: [cluster('cluster-1')], total: 1 }, - }) as ReturnType, - ); - - renderWithProviders( - , - ); - - const link = screen.getByRole('link', { name: 'cluster-cluster-1' }); - expect(link).toHaveAttribute('href', '/clusters/cluster-1'); - }); - - it('only queries the hook matching the given kind', () => { - renderWithProviders( - , - ); - - expect(useClustersForCatalogItem).toHaveBeenCalledWith( - 'catalog-1', - expect.objectContaining({ limit: 10, offset: 0 }), - ); - expect(useComputeInstancesForCatalogItem).toHaveBeenCalledWith( - '', - expect.objectContaining({ limit: 10, offset: 0 }), - ); - expect(useBareMetalInstancesForCatalogItem).toHaveBeenCalledWith( - '', - expect.objectContaining({ limit: 10, offset: 0 }), - ); - }); - - it('renders an empty state when there are no provisioned resources', () => { - renderWithProviders( - , - ); - - expect( - screen.getByText('No resources have been provisioned from this catalog item.'), - ).toBeInTheDocument(); - expect(screen.queryByRole('table')).not.toBeInTheDocument(); - }); - - it('shows a loading spinner while the query is in flight', () => { - vi.mocked(useClustersForCatalogItem).mockReturnValue(mockQueryResult({ isLoading: true })); - - renderWithProviders( - , - ); - - expect(screen.getByRole('progressbar')).toBeInTheDocument(); - }); - - it('does not render pagination when there are no results', () => { - renderWithProviders( - , - ); - - expect(screen.queryByLabelText(/pagination/i)).not.toBeInTheDocument(); - }); - - it('renders pagination sized to the total item count', () => { - vi.mocked(useClustersForCatalogItem).mockReturnValue( - mockQueryResult({ - data: { items: [cluster('cluster-1')], total: 42 }, - }) as ReturnType, - ); - - renderWithProviders( - , - ); - - expect(screen.getByText('42')).toBeInTheDocument(); - }); - - it('advances to the next page and re-queries with the updated offset', async () => { - vi.mocked(useClustersForCatalogItem).mockReturnValue( - mockQueryResult({ - data: { items: [cluster('cluster-1')], total: 42 }, - }) as ReturnType, - ); - - const { user } = renderWithProviders( - , - ); - - await user.click(screen.getByRole('button', { name: /go to next page/i })); - - expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( - 'catalog-1', - expect.objectContaining({ limit: 10, offset: 10 }), - ); - }); - - it('resets to page 1 when the catalog item id changes', async () => { - vi.mocked(useClustersForCatalogItem).mockReturnValue( - mockQueryResult({ - data: { items: [cluster('cluster-1')], total: 42 }, - }) as ReturnType, - ); - - const { user, rerender } = renderWithProviders( - , - ); - - await user.click(screen.getByRole('button', { name: /go to next page/i })); - expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( - 'catalog-1', - expect.objectContaining({ offset: 10 }), - ); - - rerender(); - - expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( - 'catalog-2', - expect.objectContaining({ offset: 0 }), - ); - }); -}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.tsx deleted file mode 100644 index ecd4ab52..00000000 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemProvisionedResourcesTab.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import type { ReactNode } from 'react'; -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Content, Pagination, PaginationVariant, Stack, StackItem } from '@patternfly/react-core'; -import type { OnPerPageSelect, OnSetPage } from '@patternfly/react-core'; -import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; - -import type { BareMetalInstance, Cluster, ComputeInstance } from '@osac/types'; - -import { useBareMetalInstancesForCatalogItem } from '../../api/v1/baremetal-instance'; -import { useClustersForCatalogItem } from '../../api/v1/cluster'; -import { useComputeInstancesForCatalogItem } from '../../api/v1/compute-instance'; -import { resourceDisplayName } from '../../api/v1/networking'; -import { useTranslation } from '../../hooks/useTranslation'; -import { VmStatusLabel } from '../../VmStatusLabel'; -import { BareMetalStatusLabel } from '../BareMetalInstance/BareMetalStatusLabel'; -import { ClusterStatusLabel } from '../Cluster/ClusterStatusLabel'; -import ListPageBody from '../Page/ListPageBody'; -import { Timestamp, type TimestampProps } from '../Primitives/Timestamp'; - -export type CatalogItemDetailKind = 'cluster' | 'compute-instance' | 'baremetal-instance'; - -interface CatalogItemProvisionedResourcesTabProps { - catalogItemId: string; - kind: CatalogItemDetailKind; -} - -interface ProvisionedResourceRow { - id: string; - name: string; - status: ReactNode; - createdAt: TimestampProps['value']; - href: string; -} - -const clusterRow = (item: Cluster): ProvisionedResourceRow => ({ - id: item.id, - name: resourceDisplayName(item.metadata, item.id), - status: , - createdAt: item.metadata?.creationTimestamp, - href: `/clusters/${item.id}`, -}); - -const computeInstanceRow = (item: ComputeInstance): ProvisionedResourceRow => ({ - id: item.id, - name: resourceDisplayName(item.metadata, item.id), - status: , - createdAt: item.metadata?.creationTimestamp, - href: `/vms/${item.id}`, -}); - -const bareMetalRow = (item: BareMetalInstance): ProvisionedResourceRow => ({ - id: item.id, - name: resourceDisplayName(item.metadata, item.id), - status: , - createdAt: item.metadata?.creationTimestamp, - href: `/bare-metal/${item.id}`, -}); - -const DEFAULT_PER_PAGE = 10; - -const CatalogItemProvisionedResourcesTab = ({ - catalogItemId, - kind, -}: CatalogItemProvisionedResourcesTabProps) => { - const { t } = useTranslation(); - const [page, setPage] = useState(1); - const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); - const offset = (page - 1) * perPage; - - // The parent route only swaps the `:id` param when the admin navigates between catalog items — - // this component's instance is reused rather than remounted, so page state must reset explicitly. - useEffect(() => { - setPage(1); - }, [catalogItemId, kind]); - - const clustersResult = useClustersForCatalogItem(kind === 'cluster' ? catalogItemId : '', { - limit: perPage, - offset, - }); - const computeInstancesResult = useComputeInstancesForCatalogItem( - kind === 'compute-instance' ? catalogItemId : '', - { limit: perPage, offset }, - ); - const bareMetalResult = useBareMetalInstancesForCatalogItem( - kind === 'baremetal-instance' ? catalogItemId : '', - { limit: perPage, offset }, - ); - - let rows: ProvisionedResourceRow[]; - let total: number; - let isLoading: boolean; - let error: unknown; - - switch (kind) { - case 'cluster': - rows = (clustersResult.data?.items ?? []).map(clusterRow); - total = clustersResult.data?.total ?? 0; - isLoading = clustersResult.isLoading; - error = clustersResult.error; - break; - case 'compute-instance': - rows = (computeInstancesResult.data?.items ?? []).map(computeInstanceRow); - total = computeInstancesResult.data?.total ?? 0; - isLoading = computeInstancesResult.isLoading; - error = computeInstancesResult.error; - break; - case 'baremetal-instance': - rows = (bareMetalResult.data?.items ?? []).map(bareMetalRow); - total = bareMetalResult.data?.total ?? 0; - isLoading = bareMetalResult.isLoading; - error = bareMetalResult.error; - break; - } - - const handleSetPage: OnSetPage = (_event, newPage) => { - setPage(newPage); - }; - - const handlePerPageSelect: OnPerPageSelect = (_event, newPerPage) => { - setPerPage(newPerPage); - setPage(1); - }; - - return ( - - - - {rows.length === 0 ? ( - - {t('No resources have been provisioned from this catalog item.')} - - ) : ( - - - - - - - - - - {rows.map((row) => ( - - - - - - ))} - -
{t('Name')}{t('Status')}{t('Created')}
- {row.name} - {row.status} - -
- )} -
-
- {total > 0 ? ( - - - - ) : null} -
- ); -}; - -export default CatalogItemProvisionedResourcesTab; diff --git a/libs/ui-components/src/components/catalogManagement/ClusterProvisionedResourcesTab.test.tsx b/libs/ui-components/src/components/catalogManagement/ClusterProvisionedResourcesTab.test.tsx new file mode 100644 index 00000000..f16bdb64 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/ClusterProvisionedResourcesTab.test.tsx @@ -0,0 +1,98 @@ +import { screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Cluster } from '@osac/types'; + +import { mockQueryResult } from '../../test-utils/query'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +vi.mock('@osac/ui-components/api/v1/cluster', () => ({ + useClustersForCatalogItem: vi.fn(), +})); + +const { useClustersForCatalogItem } = await import('@osac/ui-components/api/v1/cluster'); + +const ClusterProvisionedResourcesTab = (await import('./ClusterProvisionedResourcesTab')).default; + +const cluster = (id: string): Cluster => + ({ + id, + metadata: { name: `cluster-${id}` }, + status: {}, + }) as Cluster; + +describe('ClusterProvisionedResourcesTab', () => { + beforeEach(() => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< + typeof useClustersForCatalogItem + >, + ); + }); + + it('renders cluster rows linking to the cluster detail page', () => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [cluster('cluster-1')], total: 1 }, + }) as ReturnType, + ); + + renderWithProviders(); + + const link = screen.getByRole('link', { name: 'cluster-cluster-1' }); + expect(link).toHaveAttribute('href', '/clusters/cluster-1'); + }); + + it('queries only clusters scoped to the given catalog item id', () => { + renderWithProviders(); + + expect(useClustersForCatalogItem).toHaveBeenCalledWith( + 'catalog-1', + expect.objectContaining({ limit: 10, offset: 0 }), + ); + }); + + it('advances to the next page and re-queries with the updated offset', async () => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [cluster('cluster-1')], total: 42 }, + }) as ReturnType, + ); + + const { user } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('button', { name: /go to next page/i })); + + expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( + 'catalog-1', + expect.objectContaining({ limit: 10, offset: 10 }), + ); + }); + + it('resets to page 1 when the catalog item id changes', async () => { + vi.mocked(useClustersForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [cluster('cluster-1')], total: 42 }, + }) as ReturnType, + ); + + const { user, rerender } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('button', { name: /go to next page/i })); + expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( + 'catalog-1', + expect.objectContaining({ offset: 10 }), + ); + + rerender(); + + expect(useClustersForCatalogItem).toHaveBeenLastCalledWith( + 'catalog-2', + expect.objectContaining({ offset: 0 }), + ); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/ClusterProvisionedResourcesTab.tsx b/libs/ui-components/src/components/catalogManagement/ClusterProvisionedResourcesTab.tsx new file mode 100644 index 00000000..650e3c32 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/ClusterProvisionedResourcesTab.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import type { OnPerPageSelect, OnSetPage } from '@patternfly/react-core'; + +import type { Cluster } from '@osac/types'; + +import ProvisionedResourcesTable, { + type ProvisionedResourceRow, +} from './ProvisionedResourcesTable'; +import { useClustersForCatalogItem } from '../../api/v1/cluster'; +import { resourceDisplayName } from '../../api/v1/networking'; +import { ClusterStatusLabel } from '../Cluster/ClusterStatusLabel'; + +interface ClusterProvisionedResourcesTabProps { + catalogItemId: string; +} + +const DEFAULT_PER_PAGE = 10; + +const clusterRow = (item: Cluster): ProvisionedResourceRow => ({ + id: item.id, + name: resourceDisplayName(item.metadata, item.id), + status: , + createdAt: item.metadata?.creationTimestamp, + href: `/clusters/${item.id}`, +}); + +const ClusterProvisionedResourcesTab = ({ catalogItemId }: ClusterProvisionedResourcesTabProps) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); + const offset = (page - 1) * perPage; + + // The parent route only swaps the `:id` param when the admin navigates between catalog items — + // this component's instance is reused rather than remounted, so page state must reset explicitly. + useEffect(() => { + setPage(1); + }, [catalogItemId]); + + const { data, isLoading, error } = useClustersForCatalogItem(catalogItemId, { + limit: perPage, + offset, + }); + + const handleSetPage: OnSetPage = (_event, newPage) => { + setPage(newPage); + }; + + const handlePerPageSelect: OnPerPageSelect = (_event, newPerPage) => { + setPerPage(newPerPage); + setPage(1); + }; + + return ( + + ); +}; + +export default ClusterProvisionedResourcesTab; diff --git a/libs/ui-components/src/components/catalogManagement/ComputeInstanceProvisionedResourcesTab.test.tsx b/libs/ui-components/src/components/catalogManagement/ComputeInstanceProvisionedResourcesTab.test.tsx new file mode 100644 index 00000000..1a762410 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/ComputeInstanceProvisionedResourcesTab.test.tsx @@ -0,0 +1,57 @@ +import { screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ComputeInstance } from '@osac/types'; + +import { mockQueryResult } from '../../test-utils/query'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +vi.mock('@osac/ui-components/api/v1/compute-instance', () => ({ + useComputeInstancesForCatalogItem: vi.fn(), +})); + +const { useComputeInstancesForCatalogItem } = + await import('@osac/ui-components/api/v1/compute-instance'); + +const ComputeInstanceProvisionedResourcesTab = ( + await import('./ComputeInstanceProvisionedResourcesTab') +).default; + +const vm = (id: string): ComputeInstance => + ({ + id, + metadata: { name: `vm-${id}` }, + status: {}, + }) as ComputeInstance; + +describe('ComputeInstanceProvisionedResourcesTab', () => { + beforeEach(() => { + vi.mocked(useComputeInstancesForCatalogItem).mockReturnValue( + mockQueryResult({ data: { items: [], total: 0 } }) as ReturnType< + typeof useComputeInstancesForCatalogItem + >, + ); + }); + + it('renders VM rows linking to the VM detail page', () => { + vi.mocked(useComputeInstancesForCatalogItem).mockReturnValue( + mockQueryResult({ + data: { items: [vm('vm-1')], total: 1 }, + }) as ReturnType, + ); + + renderWithProviders(); + + const link = screen.getByRole('link', { name: 'vm-vm-1' }); + expect(link).toHaveAttribute('href', '/vms/vm-1'); + }); + + it('queries only VMs scoped to the given catalog item id', () => { + renderWithProviders(); + + expect(useComputeInstancesForCatalogItem).toHaveBeenCalledWith( + 'catalog-1', + expect.objectContaining({ limit: 10, offset: 0 }), + ); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/ComputeInstanceProvisionedResourcesTab.tsx b/libs/ui-components/src/components/catalogManagement/ComputeInstanceProvisionedResourcesTab.tsx new file mode 100644 index 00000000..6c365ae5 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/ComputeInstanceProvisionedResourcesTab.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import type { OnPerPageSelect, OnSetPage } from '@patternfly/react-core'; + +import type { ComputeInstance } from '@osac/types'; + +import ProvisionedResourcesTable, { + type ProvisionedResourceRow, +} from './ProvisionedResourcesTable'; +import { useComputeInstancesForCatalogItem } from '../../api/v1/compute-instance'; +import { resourceDisplayName } from '../../api/v1/networking'; +import { VmStatusLabel } from '../../VmStatusLabel'; + +interface ComputeInstanceProvisionedResourcesTabProps { + catalogItemId: string; +} + +const DEFAULT_PER_PAGE = 10; + +const computeInstanceRow = (item: ComputeInstance): ProvisionedResourceRow => ({ + id: item.id, + name: resourceDisplayName(item.metadata, item.id), + status: , + createdAt: item.metadata?.creationTimestamp, + href: `/vms/${item.id}`, +}); + +const ComputeInstanceProvisionedResourcesTab = ({ + catalogItemId, +}: ComputeInstanceProvisionedResourcesTabProps) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); + const offset = (page - 1) * perPage; + + // The parent route only swaps the `:id` param when the admin navigates between catalog items — + // this component's instance is reused rather than remounted, so page state must reset explicitly. + useEffect(() => { + setPage(1); + }, [catalogItemId]); + + const { data, isLoading, error } = useComputeInstancesForCatalogItem(catalogItemId, { + limit: perPage, + offset, + }); + + const handleSetPage: OnSetPage = (_event, newPage) => { + setPage(newPage); + }; + + const handlePerPageSelect: OnPerPageSelect = (_event, newPerPage) => { + setPerPage(newPerPage); + setPage(1); + }; + + return ( + + ); +}; + +export default ComputeInstanceProvisionedResourcesTab; diff --git a/libs/ui-components/src/components/catalogManagement/ProvisionedResourcesTable.test.tsx b/libs/ui-components/src/components/catalogManagement/ProvisionedResourcesTable.test.tsx new file mode 100644 index 00000000..d538ee4e --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/ProvisionedResourcesTable.test.tsx @@ -0,0 +1,139 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import ProvisionedResourcesTable from './ProvisionedResourcesTable'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +describe('ProvisionedResourcesTable', () => { + it('renders rows linking to the resource detail page', () => { + renderWithProviders( + , + ); + + const link = screen.getByRole('link', { name: 'cluster-1' }); + expect(link).toHaveAttribute('href', '/clusters/cluster-1'); + }); + + it('renders an empty state when there are no rows', () => { + renderWithProviders( + , + ); + + expect( + screen.getByText('No resources have been provisioned from this catalog item.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('shows a loading spinner while the query is in flight', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('does not render pagination when there are no results', () => { + renderWithProviders( + , + ); + + expect(screen.queryByLabelText(/pagination/i)).not.toBeInTheDocument(); + }); + + it('renders pagination sized to the total item count', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('calls onSetPage when the pagination control advances', async () => { + const onSetPage = vi.fn(); + const { user } = renderWithProviders( + , + ); + + await user.click(screen.getByRole('button', { name: /go to next page/i })); + expect(onSetPage).toHaveBeenCalled(); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/ProvisionedResourcesTable.tsx b/libs/ui-components/src/components/catalogManagement/ProvisionedResourcesTable.tsx new file mode 100644 index 00000000..6dd43c38 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/ProvisionedResourcesTable.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Content, Pagination, PaginationVariant, Stack, StackItem } from '@patternfly/react-core'; +import type { OnPerPageSelect, OnSetPage } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import { useTranslation } from '../../hooks/useTranslation'; +import ListPageBody from '../Page/ListPageBody'; +import { Timestamp, type TimestampProps } from '../Primitives/Timestamp'; + +export interface ProvisionedResourceRow { + id: string; + name: string; + status: ReactNode; + createdAt: TimestampProps['value']; + href: string; +} + +interface ProvisionedResourcesTableProps { + rows: ProvisionedResourceRow[]; + total: number; + isLoading: boolean; + error: unknown; + page: number; + perPage: number; + onSetPage: OnSetPage; + onPerPageSelect: OnPerPageSelect; +} + +const ProvisionedResourcesTable = ({ + rows, + total, + isLoading, + error, + page, + perPage, + onSetPage, + onPerPageSelect, +}: ProvisionedResourcesTableProps) => { + const { t } = useTranslation(); + + return ( + + + + {rows.length === 0 ? ( + + {t('No resources have been provisioned from this catalog item.')} + + ) : ( + + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
{t('Name')}{t('Status')}{t('Created')}
+ {row.name} + {row.status} + +
+ )} +
+
+ {total > 0 ? ( + + + + ) : null} +
+ ); +}; + +export default ProvisionedResourcesTable; diff --git a/libs/ui-components/src/components/catalogManagement/catalogItemDetailKind.test.ts b/libs/ui-components/src/components/catalogManagement/catalogItemDetailKind.test.ts new file mode 100644 index 00000000..55ad9916 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/catalogItemDetailKind.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import type { + BareMetalInstanceCatalogItem, + ClusterCatalogItem, + ComputeInstanceCatalogItem, +} from '@osac/types'; +import type { + BareMetalInstanceCatalogItem as PrivateBareMetalInstanceCatalogItem, + ClusterCatalogItem as PrivateClusterCatalogItem, + ComputeInstanceCatalogItem as PrivateComputeInstanceCatalogItem, +} from '@osac/types/private'; + +import { catalogItemDetailKind } from './catalogItemDetailKind'; + +const baseFields = { + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: '', + template: 'tpl-openshift-4', + published: true, + fieldDefinitions: [], +}; + +const publicClusterItem: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + ...baseFields, +}; + +const privateClusterItem: PrivateClusterCatalogItem = { + $typeName: 'osac.private.v1.ClusterCatalogItem', + tenant: '', + ...baseFields, +}; + +const publicComputeInstanceItem: ComputeInstanceCatalogItem = { + $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', + ...baseFields, +}; + +const privateComputeInstanceItem: PrivateComputeInstanceCatalogItem = { + $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', + tenant: '', + ...baseFields, +}; + +const publicBareMetalInstanceItem: BareMetalInstanceCatalogItem = { + $typeName: 'osac.public.v1.BareMetalInstanceCatalogItem', + ...baseFields, +}; + +const privateBareMetalInstanceItem: PrivateBareMetalInstanceCatalogItem = { + $typeName: 'osac.private.v1.BareMetalInstanceCatalogItem', + tenant: '', + ...baseFields, +}; + +describe('catalogItemDetailKind', () => { + it('returns cluster for public and private ClusterCatalogItem', () => { + expect(catalogItemDetailKind(publicClusterItem)).toBe('cluster'); + expect(catalogItemDetailKind(privateClusterItem)).toBe('cluster'); + }); + + it('returns compute-instance for public and private ComputeInstanceCatalogItem', () => { + expect(catalogItemDetailKind(publicComputeInstanceItem)).toBe('compute-instance'); + expect(catalogItemDetailKind(privateComputeInstanceItem)).toBe('compute-instance'); + }); + + it('returns baremetal-instance for public and private BareMetalInstanceCatalogItem', () => { + expect(catalogItemDetailKind(publicBareMetalInstanceItem)).toBe('baremetal-instance'); + expect(catalogItemDetailKind(privateBareMetalInstanceItem)).toBe('baremetal-instance'); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/catalogItemDetailKind.ts b/libs/ui-components/src/components/catalogManagement/catalogItemDetailKind.ts new file mode 100644 index 00000000..d6f53a9b --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/catalogItemDetailKind.ts @@ -0,0 +1,22 @@ +import type { CatalogItem } from '../catalog/catalogItemDisplay'; + +export type CatalogItemDetailKind = 'cluster' | 'compute-instance' | 'baremetal-instance'; + +export const catalogItemDetailKind = (item: CatalogItem): CatalogItemDetailKind => { + switch (item.$typeName) { + case 'osac.public.v1.ClusterCatalogItem': + case 'osac.private.v1.ClusterCatalogItem': + return 'cluster'; + case 'osac.public.v1.ComputeInstanceCatalogItem': + case 'osac.private.v1.ComputeInstanceCatalogItem': + return 'compute-instance'; + case 'osac.public.v1.BareMetalInstanceCatalogItem': + case 'osac.private.v1.BareMetalInstanceCatalogItem': + return 'baremetal-instance'; + default: { + const exhaustiveCheck: never = item; + void exhaustiveCheck; + return 'compute-instance'; + } + } +}; diff --git a/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx b/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx index db11fb72..ea757d8e 100644 --- a/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx +++ b/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx @@ -21,7 +21,6 @@ describe('CatalogItemDetailPageShell', () => { renderWithProviders( { renderWithProviders( { renderWithProviders( { renderWithProviders( - ); + return ; }; export default CatalogItemDetailPageShell; diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx index 0be4a2bd..65cf1f36 100644 --- a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx @@ -26,7 +26,6 @@ const BareMetalInstanceCatalogItemDetailPage = () => { return ( { return ( { return ( Date: Wed, 29 Jul 2026 13:46:12 +0300 Subject: [PATCH 17/22] OSAC-2934: extract WithTooltip to remove repeated disabled-tooltip ternary CatalogItemDetailActionButtons duplicated the same "isDisabled ? ... : " branch for the publish toggle and the Delete button. Extracts a shared WithTooltip primitive (mirroring flightctl-ui's component of the same name) that conditionally wraps children in a Tooltip, with an opt-in span wrapper for controls that don't fire hover/focus events while disabled (e.g. a disabled Switch). Addresses review feedback from rawagner on PR #106. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../Primitives/WithTooltip.test.tsx | 46 +++++++++++++++++++ .../src/components/Primitives/WithTooltip.tsx | 26 +++++++++++ .../CatalogItemDetailActionButtons.tsx | 40 +++++++--------- 3 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 libs/ui-components/src/components/Primitives/WithTooltip.test.tsx create mode 100644 libs/ui-components/src/components/Primitives/WithTooltip.tsx diff --git a/libs/ui-components/src/components/Primitives/WithTooltip.test.tsx b/libs/ui-components/src/components/Primitives/WithTooltip.test.tsx new file mode 100644 index 00000000..f6df991b --- /dev/null +++ b/libs/ui-components/src/components/Primitives/WithTooltip.test.tsx @@ -0,0 +1,46 @@ +import { Button } from '@patternfly/react-core'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import WithTooltip from './WithTooltip'; + +describe('WithTooltip', () => { + it('renders children unwrapped when showTooltip is false', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument(); + expect(screen.queryByText('Not shown')).not.toBeInTheDocument(); + }); + + it('shows the tooltip content on hover when showTooltip is true', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.hover(screen.getByRole('button', { name: 'Click me' })); + expect(await screen.findAllByText('Disabled reason')).not.toHaveLength(0); + }); + + it('wraps children in a focusable span when wrapWithSpan is set', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const wrapperSpan = screen.getByRole('button', { name: 'Click me' }).closest('span'); + expect(wrapperSpan).toHaveAttribute('tabindex', '0'); + + await user.hover(wrapperSpan as HTMLElement); + expect(await screen.findAllByText('Disabled reason')).not.toHaveLength(0); + }); +}); diff --git a/libs/ui-components/src/components/Primitives/WithTooltip.tsx b/libs/ui-components/src/components/Primitives/WithTooltip.tsx new file mode 100644 index 00000000..5f8460d0 --- /dev/null +++ b/libs/ui-components/src/components/Primitives/WithTooltip.tsx @@ -0,0 +1,26 @@ +import type { ReactElement, ReactNode } from 'react'; +import { Tooltip } from '@patternfly/react-core'; + +interface WithTooltipProps { + showTooltip: boolean; + content: ReactNode; + children: ReactElement; + /** Wrap children in a focusable/hoverable span before passing them to Tooltip — required when + * the child itself won't fire hover/focus events while disabled (e.g. a disabled PatternFly + * Switch), unlike `isAriaDisabled` buttons which remain hoverable on their own. */ + wrapWithSpan?: boolean; +} + +const WithTooltip = ({ showTooltip, content, children, wrapWithSpan }: WithTooltipProps) => { + if (!showTooltip) { + return children; + } + + return ( + + {wrapWithSpan ? {children} : children} + + ); +}; + +export default WithTooltip; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx index 07672edf..1484c96f 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { Button, Flex, FlexItem, Tooltip } from '@patternfly/react-core'; +import { Button, Flex, FlexItem } from '@patternfly/react-core'; import PencilAltIcon from '@patternfly/react-icons/dist/esm/icons/pencil-alt-icon'; import TrashIcon from '@patternfly/react-icons/dist/esm/icons/trash-icon'; @@ -7,6 +7,7 @@ import CatalogItemPublishToggle from './CatalogItemPublishToggle'; import { useTranslation } from '../../hooks/useTranslation'; import type { DemoShellRole } from '../../shellTypes'; import { type CatalogItem, catalogItemScope } from '../catalog/catalogItemDisplay'; +import WithTooltip from '../Primitives/WithTooltip'; interface CatalogItemDetailActionButtonsProps { catalogItem: CatalogItem; @@ -45,19 +46,13 @@ const CatalogItemDetailActionButtons = ({ flexWrap={{ default: 'wrap' }} > - {isDisabled ? ( - - - - - - ) : ( - - )} + + + - {isDisabled ? ( - - - - ) : ( - - )} + ); From f557254697becf09417b5eb3e1301bac29ee6333 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 29 Jul 2026 17:20:12 +0300 Subject: [PATCH 18/22] OSAC-2934: trim id before gating and querying in template hooks useComputeInstanceTemplate and useBareMetalInstanceTemplate checked Boolean(id) directly instead of trimming first, so a whitespace-only id slipped past the enabled guard and reached the Get RPC. Addresses CodeRabbit review feedback on PR #106. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../api/v1/baremetal-instance-templates.test.ts | 15 +++++++++++++++ .../src/api/v1/baremetal-instance-templates.ts | 7 ++++--- .../src/api/v1/compute-instance-templates.test.ts | 15 +++++++++++++++ .../src/api/v1/compute-instance-templates.ts | 7 ++++--- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts b/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts index 94a9de62..f0d76847 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts @@ -52,4 +52,19 @@ describe('useBareMetalInstanceTemplate', () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(getCalled).toBe(false); }); + + it('does not fetch when id is whitespace-only', async () => { + let getCalled = false; + const transport = createTestTransport(() => { + getCalled = true; + }); + + renderHookWithProviders(() => useBareMetalInstanceTemplate(' '), { + role: 'tenantAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getCalled).toBe(false); + }); }); diff --git a/libs/ui-components/src/api/v1/baremetal-instance-templates.ts b/libs/ui-components/src/api/v1/baremetal-instance-templates.ts index 20a58bc6..8a24671a 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance-templates.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance-templates.ts @@ -6,10 +6,11 @@ import { useApiQuery } from '../use-api-query'; export const useBareMetalInstanceTemplate = (id: string | undefined) => { const client = useApiFetch(BareMetalInstanceTemplates); + const trimmedId = id?.trim() ?? ''; return useApiQuery({ - queryKey: apiQueryKey('v1/baremetal_instance_templates', id ? [id] : undefined), - queryFn: () => client.get({ id: id ?? '' }), + queryKey: apiQueryKey('v1/baremetal_instance_templates', trimmedId ? [trimmedId] : undefined), + queryFn: () => client.get({ id: trimmedId }), select: (data) => data.object, - enabled: Boolean(id), + enabled: Boolean(trimmedId), }); }; diff --git a/libs/ui-components/src/api/v1/compute-instance-templates.test.ts b/libs/ui-components/src/api/v1/compute-instance-templates.test.ts index 8e378bbb..f8530560 100644 --- a/libs/ui-components/src/api/v1/compute-instance-templates.test.ts +++ b/libs/ui-components/src/api/v1/compute-instance-templates.test.ts @@ -52,4 +52,19 @@ describe('useComputeInstanceTemplate', () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(getCalled).toBe(false); }); + + it('does not fetch when id is whitespace-only', async () => { + let getCalled = false; + const transport = createTestTransport(() => { + getCalled = true; + }); + + renderHookWithProviders(() => useComputeInstanceTemplate(' '), { + role: 'tenantAdmin', + transport, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getCalled).toBe(false); + }); }); diff --git a/libs/ui-components/src/api/v1/compute-instance-templates.ts b/libs/ui-components/src/api/v1/compute-instance-templates.ts index f495768a..07f6e85c 100644 --- a/libs/ui-components/src/api/v1/compute-instance-templates.ts +++ b/libs/ui-components/src/api/v1/compute-instance-templates.ts @@ -6,10 +6,11 @@ import { useApiQuery } from '../use-api-query'; export const useComputeInstanceTemplate = (id: string | undefined) => { const client = useApiFetch(ComputeInstanceTemplates); + const trimmedId = id?.trim() ?? ''; return useApiQuery({ - queryKey: apiQueryKey('v1/compute_instance_templates', id ? [id] : undefined), - queryFn: () => client.get({ id: id ?? '' }), + queryKey: apiQueryKey('v1/compute_instance_templates', trimmedId ? [trimmedId] : undefined), + queryFn: () => client.get({ id: trimmedId }), select: (data) => data.object, - enabled: Boolean(id), + enabled: Boolean(trimmedId), }); }; From 489917d6dfc31deff67641918588663949378c24 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 29 Jul 2026 17:20:18 +0300 Subject: [PATCH 19/22] OSAC-2934: keep whole-number hint alongside integer min/max bounds formatCatalogFieldValidationSummary suppressed the "whole number" hint whenever minimum/maximum was also present, silently dropping the integer constraint from the summary shown next to a bounded integer field. Addresses CodeRabbit review feedback on PR #106. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/components/catalog/catalogItemDisplay.test.ts | 4 ++-- .../src/components/catalog/catalogItemDisplay.ts | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts index 62b27c51..ae0c4e54 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts @@ -448,7 +448,7 @@ describe('formatCatalogFieldValidationSummary', () => { ).toBe('whole number'); }); - it('omits the whole-number label when integer bounds are already present', () => { + it('keeps the whole-number label alongside integer bounds', () => { expect( formatCatalogFieldValidationSummary( { @@ -457,7 +457,7 @@ describe('formatCatalogFieldValidationSummary', () => { }, t, ), - ).toBe('min: 1'); + ).toBe('whole number, min: 1'); }); }); diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts index 5147d9aa..bedc9524 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts @@ -187,11 +187,7 @@ export const formatCatalogFieldValidationSummary = ( } const parts: string[] = []; - if ( - schema.type === 'integer' && - typeof schema.minimum !== 'number' && - typeof schema.maximum !== 'number' - ) { + if (schema.type === 'integer') { parts.push(t('whole number')); } if (typeof schema.minimum === 'number') { From a9d97e8f428b015f85fc8ca413448a6d264aa3b8 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 29 Jul 2026 17:20:23 +0300 Subject: [PATCH 20/22] OSAC-2934: use full Metadata fixtures instead of 'as never' casts in tests CatalogItemDetailActionButtons.test.tsx cast partial metadata objects to never to satisfy the public Metadata type, bypassing type checking on the fixtures entirely. Adds a publicMetadata() builder providing the full required shape (name, creator, labels, annotations, version) instead. Addresses CodeRabbit review feedback on PR #106. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../CatalogItemDetailActionButtons.test.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx index 82dca5ce..e86f75d8 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx @@ -2,12 +2,24 @@ import { Route, Routes } from 'react-router-dom'; import { screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { ClusterCatalogItem } from '@osac/types'; +import type { ClusterCatalogItem, Metadata } from '@osac/types'; import type { ClusterCatalogItem as PrivateClusterCatalogItem } from '@osac/types/private'; import CatalogItemDetailActionButtons from './CatalogItemDetailActionButtons'; import { renderWithProviders } from '../../test-utils/TestProviders'; +const publicMetadata = (overrides: Partial = {}): Metadata => ({ + $typeName: 'osac.public.v1.Metadata', + name: 'catalog-1', + annotations: {}, + creator: 'test-user', + labels: {}, + project: '', + tenant: '', + version: 1, + ...overrides, +}); + const publicItem = (overrides: Partial = {}): ClusterCatalogItem => ({ $typeName: 'osac.public.v1.ClusterCatalogItem', id: 'catalog-1', @@ -69,7 +81,7 @@ describe('CatalogItemDetailActionButtons', () => { it('renders all actions for tenantAdmin on an organization-scoped item', () => { renderWithProviders( { it('renders all actions for tenantAdmin on a project-scoped item', () => { renderWithProviders( Date: Wed, 29 Jul 2026 17:20:29 +0300 Subject: [PATCH 21/22] OSAC-2934: preserve Markdown-significant whitespace in item description CatalogItemOverviewTab trimmed the description before handing it to SanitizedMarkdown, which strips leading indentation needed for indented code blocks and trailing hard-line-break spaces. Trim only to decide whether a description is present; render the original string. Addresses CodeRabbit review feedback on PR #106. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../catalogManagement/CatalogItemOverviewTab.test.tsx | 10 ++++++++++ .../catalogManagement/CatalogItemOverviewTab.tsx | 9 +++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx index 1960fbdc..90de9f10 100644 --- a/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx @@ -58,6 +58,16 @@ describe('CatalogItemOverviewTab', () => { expect(screen.getByText('standard').tagName).toBe('STRONG'); }); + it('preserves a leading indented code block in the description', () => { + const { container } = renderWithProviders( + , + ); + expect(container.querySelector('pre code')).toHaveTextContent('codeBlockLine'); + }); + it('renders a fallback when description is empty', () => { renderWithProviders( { const { t } = useTranslation(); - const description = catalogItem.description?.trim(); + const description = catalogItem.description; + const hasDescription = Boolean(description?.trim()); return ( @@ -63,7 +64,11 @@ const CatalogItemOverviewTab = ({ {t('Description')} - {description ? {description} : displayValue()} + {hasDescription && description ? ( + {description} + ) : ( + displayValue() + )} From 00485b84b9a35e3a66ba6982a6dea92ab1641da9 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 29 Jul 2026 17:20:36 +0300 Subject: [PATCH 22/22] OSAC-2934: extract shared useCatalogItemDetailData hook The three kind-specific catalog item detail pages duplicated identical role-gated public/private hook selection plus template resolution, with only the hook triplet swapped per kind. Collapses that into a single generic useCatalogItemDetailData hook so the role-gating logic lives in one place. Addresses CodeRabbit review feedback on PR #106. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- ...BareMetalInstanceCatalogItemDetailPage.tsx | 15 +++--- .../cluster/ClusterCatalogItemDetailPage.tsx | 15 +++--- .../ComputeInstanceCatalogItemDetailPage.tsx | 15 +++--- .../admin/useCatalogItemDetailData.test.ts | 52 +++++++++++++++++++ .../pages/admin/useCatalogItemDetailData.ts | 38 ++++++++++++++ 5 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 libs/ui-components/src/pages/admin/useCatalogItemDetailData.test.ts create mode 100644 libs/ui-components/src/pages/admin/useCatalogItemDetailData.ts diff --git a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx index 65cf1f36..03bbbb6e 100644 --- a/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx @@ -5,23 +5,26 @@ import { useBareMetalInstanceTemplate } from '../../../api/v1/baremetal-instance import { usePrivateBareMetalInstanceCatalogItem } from '../../../api/v1/private/baremetal-instance-catalog-item'; import { useSession } from '../../../hooks/use-session'; import CatalogItemDetailPageShell from '../CatalogItemDetailPageShell'; +import { useCatalogItemDetailData } from '../useCatalogItemDetailData'; const BareMetalInstanceCatalogItemDetailPage = () => { const { id } = useParams<{ id: string }>(); const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicResult = useBareMetalInstanceCatalogItem(!isProviderAdmin ? id : undefined); - const privateResult = usePrivateBareMetalInstanceCatalogItem(isProviderAdmin ? id : undefined); const { data: catalogItem, isLoading, isError, error, refetch, - } = isProviderAdmin ? privateResult : publicResult; - - const { data: template } = useBareMetalInstanceTemplate(catalogItem?.template); + template, + } = useCatalogItemDetailData({ + id, + role, + usePublicItem: useBareMetalInstanceCatalogItem, + usePrivateItem: usePrivateBareMetalInstanceCatalogItem, + useTemplate: useBareMetalInstanceTemplate, + }); return ( { const { id } = useParams<{ id: string }>(); const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicResult = useClusterCatalogItem(!isProviderAdmin ? id : undefined); - const privateResult = usePrivateClusterCatalogItem(isProviderAdmin ? id : undefined); const { data: catalogItem, isLoading, isError, error, refetch, - } = isProviderAdmin ? privateResult : publicResult; - - const { data: template } = useClusterTemplate(catalogItem?.template); + template, + } = useCatalogItemDetailData({ + id, + role, + usePublicItem: useClusterCatalogItem, + usePrivateItem: usePrivateClusterCatalogItem, + useTemplate: useClusterTemplate, + }); return ( { const { id } = useParams<{ id: string }>(); const { role } = useSession(); - const isProviderAdmin = role === 'providerAdmin'; - const publicResult = useComputeInstanceCatalogItem(!isProviderAdmin ? id : undefined); - const privateResult = usePrivateComputeInstanceCatalogItem(isProviderAdmin ? id : undefined); const { data: catalogItem, isLoading, isError, error, refetch, - } = isProviderAdmin ? privateResult : publicResult; - - const { data: template } = useComputeInstanceTemplate(catalogItem?.template); + template, + } = useCatalogItemDetailData({ + id, + role, + usePublicItem: useComputeInstanceCatalogItem, + usePrivateItem: usePrivateComputeInstanceCatalogItem, + useTemplate: useComputeInstanceTemplate, + }); return ( { + it('uses the public hook and skips the private hook for a non-providerAdmin role', () => { + const usePublicItem = vi.fn().mockReturnValue(mockQueryResult({ data: { template: 'tpl-1' } })); + const usePrivateItem = vi.fn().mockReturnValue(mockQueryResult({ data: undefined })); + const useTemplate = vi.fn().mockReturnValue(mockQueryResult({ data: { title: 'Template 1' } })); + + const { result } = renderHook(() => + useCatalogItemDetailData({ + id: 'catalog-1', + role: 'tenantAdmin', + usePublicItem, + usePrivateItem, + useTemplate, + }), + ); + + expect(usePublicItem).toHaveBeenCalledWith('catalog-1'); + expect(usePrivateItem).toHaveBeenCalledWith(undefined); + expect(useTemplate).toHaveBeenCalledWith('tpl-1'); + expect(result.current.data).toEqual({ template: 'tpl-1' }); + expect(result.current.template).toEqual({ title: 'Template 1' }); + }); + + it('uses the private hook and skips the public hook for providerAdmin', () => { + const usePublicItem = vi.fn().mockReturnValue(mockQueryResult({ data: undefined })); + const usePrivateItem = vi + .fn() + .mockReturnValue(mockQueryResult({ data: { template: 'tpl-2' } })); + const useTemplate = vi.fn().mockReturnValue(mockQueryResult({ data: undefined })); + + const { result } = renderHook(() => + useCatalogItemDetailData({ + id: 'catalog-2', + role: 'providerAdmin', + usePublicItem, + usePrivateItem, + useTemplate, + }), + ); + + expect(usePublicItem).toHaveBeenCalledWith(undefined); + expect(usePrivateItem).toHaveBeenCalledWith('catalog-2'); + expect(useTemplate).toHaveBeenCalledWith('tpl-2'); + expect(result.current.data).toEqual({ template: 'tpl-2' }); + }); +}); diff --git a/libs/ui-components/src/pages/admin/useCatalogItemDetailData.ts b/libs/ui-components/src/pages/admin/useCatalogItemDetailData.ts new file mode 100644 index 00000000..bb9c0054 --- /dev/null +++ b/libs/ui-components/src/pages/admin/useCatalogItemDetailData.ts @@ -0,0 +1,38 @@ +import type { UseQueryResult } from '@tanstack/react-query'; + +import type { DemoShellRole } from '../../shellTypes'; + +interface CatalogItemWithTemplate { + template: string; +} + +interface UseCatalogItemDetailDataParams { + id: string | undefined; + role: DemoShellRole; + usePublicItem: (id: string | undefined) => UseQueryResult; + usePrivateItem: (id: string | undefined) => UseQueryResult; + useTemplate: (templateId: string | undefined) => UseQueryResult; +} + +/** Shared role-gating + template resolution for the three kind-specific catalog item detail + * pages: pick the public or private hook based on role, then resolve the item's template. */ +export const useCatalogItemDetailData = < + TPublic extends CatalogItemWithTemplate, + TPrivate extends CatalogItemWithTemplate, + TTemplate, +>({ + id, + role, + usePublicItem, + usePrivateItem, + useTemplate, +}: UseCatalogItemDetailDataParams) => { + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = usePublicItem(!isProviderAdmin ? id : undefined); + const privateResult = usePrivateItem(isProviderAdmin ? id : undefined); + const active = isProviderAdmin ? privateResult : publicResult; + + const { data: template } = useTemplate(active.data?.template); + + return { ...active, template }; +};