diff --git a/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx b/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx index 3ab8d232..fad49d9f 100644 --- a/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx +++ b/apps/app-frontend/src/shell/AdminCatalogRoutes.tsx @@ -1,21 +1,11 @@ import { Route, Routes } from 'react-router-dom'; -import ListPage from '@osac/ui-components/components/Page/ListPage'; -import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import CatalogManagementListPage from '@osac/ui-components/pages/admin/CatalogManagementListPage'; export const AdminCatalogRoutes = () => { - const { t } = useTranslation(); - return ( - -
- - } - /> + } /> } /> } /> } /> diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index a2dbe98d..8ed870df 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -23,6 +23,7 @@ "Catalog configuration is unavailable for this virtual machine.": "Catalog configuration is unavailable for this virtual machine.", "Catalog item": "Catalog item", "Catalog management": "Catalog management", + "Catalog management resource type tabs": "Catalog management resource type tabs", "catalogProvision.actions.back": "Back", "catalogProvision.actions.cancel": "Cancel", "catalogProvision.actions.create": "Create", @@ -157,6 +158,7 @@ "Failed to load security groups": "Failed to load security groups", "Failed to load subnets": "Failed to load subnets", "Filter bare metal instances by name": "Filter bare metal instances by name", + "Filter by publication status": "Filter by publication status", "Filter catalog by keyword": "Filter catalog by keyword", "Filter catalog by resource type": "Filter catalog by resource type", "Fixed": "Fixed", @@ -199,6 +201,8 @@ "No bare metal instances match your search.": "No bare metal instances match your search.", "No bare metal instances yet.": "No bare metal instances yet.", "No catalog items found": "No catalog items found", + "No catalog items have been created yet.": "No catalog items have been created yet.", + "No catalog items match your search or filter.": "No catalog items match your search or filter.", "No catalog items match your search.": "No catalog items match your search.", "No inbound rules yet. Add one to allow incoming traffic.": "No inbound rules yet. Add one to allow incoming traffic.", "No node sets added yet.": "No node sets added yet.", @@ -217,6 +221,8 @@ "Nodes": "Nodes", "Open catalog item details for {{title}}": "Open catalog item details for {{title}}", "Optional cloud-init user data (max 64 KB).": "Optional cloud-init user data (max 64 KB).", + "Organization": "Organization", + "Organization: {{name}}": "Organization: {{name}}", "Outbound Rules": "Outbound Rules", "Overview": "Overview", "Parent virtual network": "Parent virtual network", @@ -234,6 +240,7 @@ "Port To": "Port To", "Port To is required for TCP/UDP": "Port To is required for TCP/UDP", "Port To must be >= Port From": "Port To must be >= Port From", + "Project: {{name}}": "Project: {{name}}", "Protocol": "Protocol", "Protocol is required": "Protocol is required", "Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.", @@ -242,6 +249,7 @@ "Provisioning failed": "Provisioning failed", "Public IP": "Public IP", "Public SSH key is required": "Public SSH key is required", + "Published": "Published", "Pull secret": "Pull secret", "Pull secret is required": "Pull secret is required", "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).": "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).", @@ -291,6 +299,7 @@ "UDP": "UDP", "Unauthorized": "Unauthorized", "Unknown": "Unknown", + "Unpublished": "Unpublished", "Use IPv4 CIDR notation (for example 10.128.0.0/14).": "Use IPv4 CIDR notation (for example 10.128.0.0/14).", "Use IPv4 CIDR notation (for example 172.30.0.0/16).": "Use IPv4 CIDR notation (for example 172.30.0.0/16).", "User data": "User data", diff --git a/libs/ui-components/src/api/types.ts b/libs/ui-components/src/api/types.ts index 0356a7c5..ccd54911 100644 --- a/libs/ui-components/src/api/types.ts +++ b/libs/ui-components/src/api/types.ts @@ -25,6 +25,9 @@ export type ApiRoute = | 'v1/baremetal_instances' | 'v1/public_ips' | 'v1/public_ip_attachments' + | 'v1/private/compute_instance_catalog_items' + | 'v1/private/cluster_catalog_items' + | 'v1/private/baremetal_instance_catalog_items' | 'v1/console_sessions'; /** 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 2201a48f..fe0e21ca 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.test.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.test.ts @@ -4,11 +4,47 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { BareMetalInstanceRunStrategy, BareMetalInstances } from '@osac/types'; +import type { BareMetalInstanceCatalogItem } from '@osac/types'; +import { + BareMetalInstanceCatalogItems, + BareMetalInstanceRunStrategy, + BareMetalInstances, +} from '@osac/types'; -import { type PatchBareMetalInstanceInput, usePatchBareMetalInstance } from './baremetal-instance'; +import { + type PatchBareMetalInstanceInput, + useBareMetalInstanceCatalogItems, + usePatchBareMetalInstance, +} from './baremetal-instance'; +import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers'; import { ApiProvider } from '../api-context'; +const item: BareMetalInstanceCatalogItem = { + $typeName: 'osac.public.v1.BareMetalInstanceCatalogItem', + id: 'public-1', + title: 'Public bare metal item', + description: '', + template: '', + published: true, + fieldDefinitions: [], +}; + +describe('useBareMetalInstanceCatalogItems', () => { + createCatalogHookTests({ + endpointDescription: 'public BareMetalInstanceCatalogItems', + useHook: useBareMetalInstanceCatalogItems, + role: 'tenantAdmin', + item, + registerList: (router, onList) => + router.service(BareMetalInstanceCatalogItems, { + list: () => { + onList?.(); + return { items: [item] }; + }, + }), + }); +}); + 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 00b5d1ac..7cd547e3 100644 --- a/libs/ui-components/src/api/v1/baremetal-instance.ts +++ b/libs/ui-components/src/api/v1/baremetal-instance.ts @@ -9,9 +9,9 @@ import { } from '@osac/types'; import { useApiFetch } from '../api-context'; -import { apiQueryKey } from '../types'; -import { buildUpdateMaskPaths } from './update-mask'; +import { type ListParams, apiQueryKey } from '../types'; import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query'; +import { buildUpdateMaskPaths } from './update-mask'; export const useBareMetalInstances = () => { const client = useApiFetch(BareMetalInstances); @@ -32,11 +32,11 @@ export const useBareMetalInstance = (id: string) => { }); }; -export const useBareMetalInstanceCatalogItems = (enabled = true) => { +export const useBareMetalInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { const client = useApiFetch(BareMetalInstanceCatalogItems); return useApiQuery({ - queryKey: apiQueryKey('v1/baremetal_instance_catalog_items'), - queryFn: () => client.list({}), + queryKey: apiQueryKey('v1/baremetal_instance_catalog_items', undefined, params), + queryFn: () => client.list(params), select: (data) => data.items, enabled, }); diff --git a/libs/ui-components/src/api/v1/cluster-catalog-item.test.ts b/libs/ui-components/src/api/v1/cluster-catalog-item.test.ts new file mode 100644 index 00000000..43cb0967 --- /dev/null +++ b/libs/ui-components/src/api/v1/cluster-catalog-item.test.ts @@ -0,0 +1,33 @@ +import { describe } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; +import { ClusterCatalogItems } from '@osac/types'; + +import { useClusterCatalogItems } from './cluster-catalog-item'; +import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers'; + +const item: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'public-1', + title: 'Public cluster item', + description: '', + template: '', + published: true, + fieldDefinitions: [], +}; + +describe('useClusterCatalogItems', () => { + createCatalogHookTests({ + endpointDescription: 'public ClusterCatalogItems', + useHook: useClusterCatalogItems, + role: 'tenantAdmin', + item, + registerList: (router, onList) => + router.service(ClusterCatalogItems, { + list: () => { + onList?.(); + return { items: [item] }; + }, + }), + }); +}); diff --git a/libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts b/libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts new file mode 100644 index 00000000..4af1d13b --- /dev/null +++ b/libs/ui-components/src/api/v1/compute-instance-catalog-item.test.ts @@ -0,0 +1,33 @@ +import { describe } from 'vitest'; + +import type { ComputeInstanceCatalogItem } from '@osac/types'; +import { ComputeInstanceCatalogItems } from '@osac/types'; + +import { useComputeInstanceCatalogItems } from './compute-instance-catalog-item'; +import { createCatalogHookTests } from '../../test-utils/catalogHookTestHelpers'; + +const item: ComputeInstanceCatalogItem = { + $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', + id: 'public-1', + title: 'Public VM item', + description: '', + template: '', + published: true, + fieldDefinitions: [], +}; + +describe('useComputeInstanceCatalogItems', () => { + createCatalogHookTests({ + endpointDescription: 'public ComputeInstanceCatalogItems', + useHook: useComputeInstanceCatalogItems, + role: 'tenantAdmin', + item, + registerList: (router, onList) => + router.service(ComputeInstanceCatalogItems, { + list: () => { + onList?.(); + return { items: [item] }; + }, + }), + }); +}); diff --git a/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts new file mode 100644 index 00000000..1f0b0b89 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.test.ts @@ -0,0 +1,34 @@ +import { describe } from 'vitest'; + +import type { BareMetalInstanceCatalogItem } from '@osac/types/private'; +import { BareMetalInstanceCatalogItems } from '@osac/types/private'; + +import { usePrivateBareMetalInstanceCatalogItems } from './baremetal-instance-catalog-item'; +import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; + +const item: BareMetalInstanceCatalogItem = { + $typeName: 'osac.private.v1.BareMetalInstanceCatalogItem', + id: 'private-1', + title: 'Private bare metal item', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +describe('usePrivateBareMetalInstanceCatalogItems', () => { + createCatalogHookTests({ + endpointDescription: 'private BareMetalInstanceCatalogItems', + useHook: usePrivateBareMetalInstanceCatalogItems, + role: 'providerAdmin', + item, + registerList: (router, onList) => + router.service(BareMetalInstanceCatalogItems, { + list: () => { + onList?.(); + return { items: [item] }; + }, + }), + }); +}); diff --git a/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.ts b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.ts new file mode 100644 index 00000000..fcb209c2 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/baremetal-instance-catalog-item.ts @@ -0,0 +1,18 @@ +import { BareMetalInstanceCatalogItems } from '@osac/types/private'; + +import { useApiFetch } from '../../api-context'; +import { type ListParams, apiQueryKey } from '../../types'; +import { useApiQuery } from '../../use-api-query'; + +export const usePrivateBareMetalInstanceCatalogItems = ( + params: ListParams = {}, + enabled = true, +) => { + const client = useApiFetch(BareMetalInstanceCatalogItems); + return useApiQuery({ + queryKey: apiQueryKey('v1/private/baremetal_instance_catalog_items', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + enabled, + }); +}; diff --git a/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts new file mode 100644 index 00000000..52da0d79 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/cluster-catalog-item.test.ts @@ -0,0 +1,34 @@ +import { describe } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types/private'; +import { ClusterCatalogItems } from '@osac/types/private'; + +import { usePrivateClusterCatalogItems } from './cluster-catalog-item'; +import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; + +const item: ClusterCatalogItem = { + $typeName: 'osac.private.v1.ClusterCatalogItem', + id: 'private-1', + title: 'Private cluster item', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +describe('usePrivateClusterCatalogItems', () => { + createCatalogHookTests({ + endpointDescription: 'private ClusterCatalogItems', + useHook: usePrivateClusterCatalogItems, + role: 'providerAdmin', + item, + registerList: (router, onList) => + router.service(ClusterCatalogItems, { + list: () => { + onList?.(); + return { items: [item] }; + }, + }), + }); +}); diff --git a/libs/ui-components/src/api/v1/private/cluster-catalog-item.ts b/libs/ui-components/src/api/v1/private/cluster-catalog-item.ts new file mode 100644 index 00000000..b02f2035 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/cluster-catalog-item.ts @@ -0,0 +1,15 @@ +import { ClusterCatalogItems } from '@osac/types/private'; + +import { useApiFetch } from '../../api-context'; +import { type ListParams, apiQueryKey } from '../../types'; +import { useApiQuery } from '../../use-api-query'; + +export const usePrivateClusterCatalogItems = (params: ListParams = {}, enabled = true) => { + const client = useApiFetch(ClusterCatalogItems); + return useApiQuery({ + queryKey: apiQueryKey('v1/private/cluster_catalog_items', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + enabled, + }); +}; diff --git a/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts new file mode 100644 index 00000000..cbdad046 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.test.ts @@ -0,0 +1,34 @@ +import { describe } from 'vitest'; + +import type { ComputeInstanceCatalogItem } from '@osac/types/private'; +import { ComputeInstanceCatalogItems } from '@osac/types/private'; + +import { usePrivateComputeInstanceCatalogItems } from './compute-instance-catalog-item'; +import { createCatalogHookTests } from '../../../test-utils/catalogHookTestHelpers'; + +const item: ComputeInstanceCatalogItem = { + $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', + id: 'private-1', + title: 'Private VM item', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +describe('usePrivateComputeInstanceCatalogItems', () => { + createCatalogHookTests({ + endpointDescription: 'private ComputeInstanceCatalogItems', + useHook: usePrivateComputeInstanceCatalogItems, + role: 'providerAdmin', + item, + registerList: (router, onList) => + router.service(ComputeInstanceCatalogItems, { + list: () => { + onList?.(); + return { items: [item] }; + }, + }), + }); +}); diff --git a/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.ts b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.ts new file mode 100644 index 00000000..70741ca2 --- /dev/null +++ b/libs/ui-components/src/api/v1/private/compute-instance-catalog-item.ts @@ -0,0 +1,15 @@ +import { ComputeInstanceCatalogItems } from '@osac/types/private'; + +import { useApiFetch } from '../../api-context'; +import { type ListParams, apiQueryKey } from '../../types'; +import { useApiQuery } from '../../use-api-query'; + +export const usePrivateComputeInstanceCatalogItems = (params: ListParams = {}, enabled = true) => { + const client = useApiFetch(ComputeInstanceCatalogItems); + return useApiQuery({ + queryKey: apiQueryKey('v1/private/compute_instance_catalog_items', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + enabled, + }); +}; diff --git a/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx b/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx new file mode 100644 index 00000000..14c2d2e5 --- /dev/null +++ b/libs/ui-components/src/components/catalog/CatalogItemCard.test.tsx @@ -0,0 +1,53 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ClusterCatalogItem } from '@osac/types'; + +import CatalogItemCard from './CatalogItemCard'; +import { renderWithProviders } from '../../test-utils/TestProviders'; +import CatalogItemScopeBadge from '../catalogManagement/CatalogItemScopeBadge'; +import CatalogItemStatusLabel from '../catalogManagement/CatalogItemStatusLabel'; + +const item: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'catalog-1', + title: 'OpenShift 4 cluster', + description: 'Standard OpenShift cluster offering', + template: '', + published: true, + fieldDefinitions: [], +}; + +describe('CatalogItemCard', () => { + it('omits scope badge and status label by default (tenant mode)', () => { + renderWithProviders( {}} />); + expect(screen.queryByText('General')).not.toBeInTheDocument(); + expect(screen.queryByText('Published')).not.toBeInTheDocument(); + }); + + it('renders scope badge and status label when provided (admin mode)', () => { + renderWithProviders( + {}} + scopeBadge={} + statusLabel={} + />, + ); + expect(screen.getByText('General')).toBeInTheDocument(); + expect(screen.getByText('Published')).toBeInTheDocument(); + }); + + it('navigates to details when the card is clicked', async () => { + const onOpenDetails = vi.fn(); + const { user } = renderWithProviders( + , + ); + + await user.click( + screen.getByRole('button', { name: `Open catalog item details for ${item.title}` }), + ); + + expect(onOpenDetails).toHaveBeenCalled(); + }); +}); diff --git a/libs/ui-components/src/components/catalog/CatalogItemCard.tsx b/libs/ui-components/src/components/catalog/CatalogItemCard.tsx index 147cb3a2..8357146e 100644 --- a/libs/ui-components/src/components/catalog/CatalogItemCard.tsx +++ b/libs/ui-components/src/components/catalog/CatalogItemCard.tsx @@ -33,6 +33,8 @@ interface CatalogItemCardProps { selection?: CatalogItemCardSelection; onOpenDetails?: () => void; isSelected?: boolean; + scopeBadge?: React.ReactNode; + statusLabel?: React.ReactNode; } const CatalogItemCard = ({ @@ -41,6 +43,8 @@ const CatalogItemCard = ({ selection, onOpenDetails, isSelected, + scopeBadge, + statusLabel, }: CatalogItemCardProps) => { const { t } = useTranslation(); const resources = catalogItemResourceParts(item); @@ -103,6 +107,14 @@ const CatalogItemCard = ({ {subtitle} + {scopeBadge || statusLabel ? ( + + + {scopeBadge ? {scopeBadge} : null} + {statusLabel ? {statusLabel} : null} + + + ) : null} {resources.length > 0 ? ( diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts index f877f9cd..a9a45dfa 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from 'vitest'; import { ClusterCatalogItem } from '@osac/types'; +import type { ClusterCatalogItem as PrivateClusterCatalogItem } from '@osac/types/private'; import { + SHARED_TENANT, catalogItemResourceLine, catalogItemResourceParts, + catalogItemScope, + catalogItemSubtitle, filterCatalogItemsBySearch, } from './catalogItemDisplay'; import { @@ -220,3 +224,119 @@ describe('filterCatalogItemsBySearch', () => { expect(filterCatalogItemsBySearch(items, 'PRODUCTION')).toEqual([items[1]]); }); }); + +const basePrivateMetadata = (): NonNullable => ({ + $typeName: 'osac.private.v1.Metadata', + finalizers: [], + creator: 'admin', + tenant: '', + name: 'catalog-item', + labels: {}, + annotations: {}, + version: 1, + project: '', +}); + +const privateClusterItem = ( + overrides: Partial = {}, +): PrivateClusterCatalogItem => ({ + $typeName: 'osac.private.v1.ClusterCatalogItem', + id: 'catalog-cluster-1', + metadata: basePrivateMetadata(), + title: 'OpenShift 4 cluster', + description: 'Standard OpenShift cluster offering', + template: 'tpl-openshift-4', + published: true, + tenant: '', + fieldDefinitions: [], + ...overrides, +}); + +const publicVmItemWithMetadata = (tenant: string, project = '') => ({ + $typeName: 'osac.public.v1.ComputeInstanceCatalogItem' as const, + id: 'catalog-rhel-9', + metadata: { + $typeName: 'osac.public.v1.Metadata' as const, + name: 'catalog-rhel-9', + annotations: {}, + creator: 'foo', + labels: {}, + project, + tenant, + version: 1, + }, + title: 'RHEL 9 catalog', + description: 'RHEL 9 base image', + template: 'tpl-rhel-9', + published: true, + fieldDefinitions: [], +}); + +describe('catalogItemScope', () => { + it('returns general for a CSP Admin item with no private tenant', () => { + const item = privateClusterItem({ tenant: '' }); + expect(catalogItemScope(item, 'providerAdmin')).toEqual({ level: 'general' }); + }); + + it('returns general for the providerAdmin role given a public-shaped item lacking a tenant field', () => { + const item = publicVmItemWithMetadata('acme-corp'); + expect(catalogItemScope(item, 'providerAdmin')).toEqual({ level: 'general' }); + }); + + it('returns organization with the tenant name for a CSP Admin item scoped to a tenant', () => { + const item = privateClusterItem({ tenant: 'acme-corp' }); + expect(catalogItemScope(item, 'providerAdmin')).toEqual({ + level: 'organization', + name: 'acme-corp', + }); + }); + + it('returns project for a CSP Admin item even when the private tenant is also set', () => { + const item = privateClusterItem({ + tenant: 'acme-corp', + metadata: { ...basePrivateMetadata(), project: 'frontend' }, + }); + expect(catalogItemScope(item, 'providerAdmin')).toEqual({ + level: 'project', + name: 'frontend', + }); + }); + + it('returns general for a Tenant Admin item whose metadata.tenant is the shared sentinel', () => { + const item = publicVmItemWithMetadata(SHARED_TENANT); + expect(catalogItemScope(item, 'tenantAdmin')).toEqual({ level: 'general' }); + }); + + it('returns general for a Tenant Admin item whose metadata.tenant is empty', () => { + const item = publicVmItemWithMetadata(''); + expect(catalogItemScope(item, 'tenantAdmin')).toEqual({ level: 'general' }); + }); + + it('returns organization for a Tenant Admin item whose metadata.tenant is not the shared sentinel', () => { + const item = publicVmItemWithMetadata('acme-corp'); + expect(catalogItemScope(item, 'tenantAdmin')).toEqual({ level: 'organization' }); + }); + + it('returns project for a Tenant Admin item with a project set, regardless of metadata.tenant', () => { + const item = publicVmItemWithMetadata(SHARED_TENANT, 'frontend'); + expect(catalogItemScope(item, 'tenantAdmin')).toEqual({ level: 'project', name: 'frontend' }); + }); +}); + +describe('existing display helpers with private-v1 items', () => { + it('catalogItemSubtitle falls back to metadata.name when description is empty', () => { + const item = privateClusterItem({ description: '' }); + expect(catalogItemSubtitle(item)).toBe('catalog-item'); + }); + + it('catalogItemSubtitle uses the description when present', () => { + const item = privateClusterItem(); + expect(catalogItemSubtitle(item)).toBe('Standard OpenShift cluster offering'); + }); + + it('filterCatalogItemsBySearch matches a private-v1 item by title', () => { + const item = privateClusterItem(); + expect(filterCatalogItemsBySearch([item], 'openshift')).toEqual([item]); + expect(filterCatalogItemsBySearch([item], 'no-such-term')).toEqual([]); + }); +}); diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts index ece6cacc..cdedf9ee 100644 --- a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts +++ b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts @@ -3,7 +3,13 @@ import type { ClusterCatalogItem, ComputeInstanceCatalogItem, } from '@osac/types'; +import type { + BareMetalInstanceCatalogItem as PrivateBareMetalInstanceCatalogItem, + ClusterCatalogItem as PrivateClusterCatalogItem, + ComputeInstanceCatalogItem as PrivateComputeInstanceCatalogItem, +} from '@osac/types/private'; +import type { DemoShellRole } from '../../shellTypes'; import { CATALOG_ITEM_RESOURCE_FIELD_PATHS, type CatalogFieldDefinition, @@ -19,7 +25,15 @@ import { export type CatalogItem = | ClusterCatalogItem | BareMetalInstanceCatalogItem - | ComputeInstanceCatalogItem; + | ComputeInstanceCatalogItem + | PrivateClusterCatalogItem + | PrivateBareMetalInstanceCatalogItem + | PrivateComputeInstanceCatalogItem; + +type PrivateCatalogItem = + | PrivateClusterCatalogItem + | PrivateBareMetalInstanceCatalogItem + | PrivateComputeInstanceCatalogItem; export type CatalogItemKind = 'vm' | 'cluster' | 'bm'; @@ -141,6 +155,18 @@ export const filterCatalogItemsBySearch = (items: CatalogItem[], search: string) return items.filter((item) => searchableCatalogItemText(item).includes(searchTerm)); }; +export type PublicationFilter = 'all' | 'published' | 'unpublished'; + +export const matchesPublicationFilter = (item: CatalogItem, filter: PublicationFilter): boolean => { + if (filter === 'published') { + return item.published; + } + if (filter === 'unpublished') { + return !item.published; + } + return true; +}; + export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string => { const defaultValue = resolvedFieldDefault(def); if (defaultValue === undefined) { @@ -148,3 +174,34 @@ export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string = } return fieldDefinitionDefaultToInputString(defaultValue) || '—'; }; + +/** + * fulfillment-service's built-in global tenant. Every object without an explicit tenant is + * auto-assigned this value server-side, and it round-trips unmasked through the public API's + * `metadata.tenant` field even though the business `tenant` field is stripped from public catalog + * item responses entirely. + */ +export const SHARED_TENANT = 'shared'; + +export type CatalogItemScope = + | { level: 'general' } + | { level: 'organization'; name?: string } + | { level: 'project'; name: string }; + +const isPrivateCatalogItem = (item: CatalogItem): item is PrivateCatalogItem => + item.$typeName.startsWith('osac.private.'); + +export const catalogItemScope = (item: CatalogItem, role: DemoShellRole): CatalogItemScope => { + const project = item.metadata?.project ?? ''; + if (project) { + return { level: 'project', name: project }; + } + if (role === 'providerAdmin') { + const tenant = isPrivateCatalogItem(item) ? item.tenant : ''; + return tenant ? { level: 'organization', name: tenant } : { level: 'general' }; + } + const metadataTenant = item.metadata?.tenant ?? ''; + return metadataTenant === SHARED_TENANT || !metadataTenant + ? { level: 'general' } + : { level: 'organization' }; +}; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.test.tsx new file mode 100644 index 00000000..e0f9d2a0 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.test.tsx @@ -0,0 +1,33 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import CatalogItemScopeBadge from './CatalogItemScopeBadge'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +describe('CatalogItemScopeBadge', () => { + it('renders "General" in blue for general scope', () => { + renderWithProviders(); + const label = screen.getByText('General'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-blue'); + }); + + it('renders "Organization: {name}" in purple when a tenant name is known', () => { + renderWithProviders( + , + ); + const label = screen.getByText('Organization: acme-corp'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-purple'); + }); + + it('renders plain "Organization" in purple when no tenant name is known', () => { + renderWithProviders(); + const label = screen.getByText('Organization'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-purple'); + }); + + it('renders "Project: {name}" in teal for project scope', () => { + renderWithProviders(); + const label = screen.getByText('Project: frontend'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-teal'); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx new file mode 100644 index 00000000..2ad04e81 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemScopeBadge.tsx @@ -0,0 +1,34 @@ +import { Label } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; +import type { CatalogItemScope } from '../catalog/catalogItemDisplay'; + +interface CatalogItemScopeBadgeProps { + scope: CatalogItemScope; +} + +const CatalogItemScopeBadge = ({ scope }: CatalogItemScopeBadgeProps) => { + const { t } = useTranslation(); + + switch (scope.level) { + case 'general': + return ; + case 'organization': + return ( + + ); + case 'project': + return ; + default: { + // Guards against a future scope level being added without updating this switch — TS flags the + // assignment below at compile time, while runtime still renders a safe fallback instead of crashing. + const exhaustiveCheck: never = scope; + void exhaustiveCheck; + return ; + } + } +}; + +export default CatalogItemScopeBadge; diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.test.tsx new file mode 100644 index 00000000..bc4b622c --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.test.tsx @@ -0,0 +1,19 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import CatalogItemStatusLabel from './CatalogItemStatusLabel'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +describe('CatalogItemStatusLabel', () => { + it('renders "Published" in green when published', () => { + renderWithProviders(); + const label = screen.getByText('Published'); + expect(label.closest('.pf-v6-c-label')).toHaveClass('pf-m-green'); + }); + + it('renders "Unpublished" in grey when not published', () => { + renderWithProviders(); + const label = screen.getByText('Unpublished'); + expect(label.closest('.pf-v6-c-label')).not.toHaveClass('pf-m-green'); + }); +}); diff --git a/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.tsx new file mode 100644 index 00000000..d721561e --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemStatusLabel.tsx @@ -0,0 +1,19 @@ +import { Label } from '@patternfly/react-core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +interface CatalogItemStatusLabelProps { + published: boolean; +} + +const CatalogItemStatusLabel = ({ published }: CatalogItemStatusLabelProps) => { + const { t } = useTranslation(); + + return published ? ( + + ) : ( + + ); +}; + +export default CatalogItemStatusLabel; diff --git a/libs/ui-components/src/icons.tsx b/libs/ui-components/src/icons.tsx index 7341104d..2f52364b 100644 --- a/libs/ui-components/src/icons.tsx +++ b/libs/ui-components/src/icons.tsx @@ -25,16 +25,21 @@ interface CatalogItemIconProps { kind: | 'osac.public.v1.ClusterCatalogItem' | 'osac.public.v1.BareMetalInstanceCatalogItem' - | 'osac.public.v1.ComputeInstanceCatalogItem'; + | 'osac.public.v1.ComputeInstanceCatalogItem' + | 'osac.private.v1.ClusterCatalogItem' + | 'osac.private.v1.BareMetalInstanceCatalogItem' + | 'osac.private.v1.ComputeInstanceCatalogItem'; } export const CatalogItemIcon = ({ kind }: CatalogItemIconProps) => { let Icon = VirtualMachineIcon; switch (kind) { case 'osac.public.v1.ClusterCatalogItem': + case 'osac.private.v1.ClusterCatalogItem': Icon = CloudIcon; break; case 'osac.public.v1.BareMetalInstanceCatalogItem': + case 'osac.private.v1.BareMetalInstanceCatalogItem': Icon = ServerIcon; break; default: diff --git a/libs/ui-components/src/pages/admin/BareMetalInstanceCatalogManagementPanel.tsx b/libs/ui-components/src/pages/admin/BareMetalInstanceCatalogManagementPanel.tsx new file mode 100644 index 00000000..b2b2edee --- /dev/null +++ b/libs/ui-components/src/pages/admin/BareMetalInstanceCatalogManagementPanel.tsx @@ -0,0 +1,162 @@ +import { useNavigate } from 'react-router-dom'; +import { + Button, + EmptyState, + EmptyStateBody, + Flex, + FlexItem, + Gallery, + GalleryItem, + SearchInput, + Stack, + StackItem, + Title, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core'; + +import { useBareMetalInstanceCatalogItems } from '@osac/ui-components/api/v1/baremetal-instance'; +import { usePrivateBareMetalInstanceCatalogItems } from '@osac/ui-components/api/v1/private/baremetal-instance-catalog-item'; +import CatalogItemCard from '@osac/ui-components/components/catalog/CatalogItemCard'; +import { + type PublicationFilter, + catalogItemScope, + filterCatalogItemsBySearch, + matchesPublicationFilter, +} from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import CatalogItemScopeBadge from '@osac/ui-components/components/catalogManagement/CatalogItemScopeBadge'; +import CatalogItemStatusLabel from '@osac/ui-components/components/catalogManagement/CatalogItemStatusLabel'; +import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import type { DemoShellRole } from '@osac/ui-components/shellTypes'; + +interface BareMetalInstanceCatalogManagementPanelProps { + isActive: boolean; + search: string; + setSearch: (value: string) => void; + publicationFilter: PublicationFilter; + setPublicationFilter: (value: PublicationFilter) => void; + role: DemoShellRole; +} + +const BareMetalInstanceCatalogManagementPanel = ({ + isActive, + search, + setSearch, + publicationFilter, + setPublicationFilter, + role, +}: BareMetalInstanceCatalogManagementPanelProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = useBareMetalInstanceCatalogItems(undefined, isActive && !isProviderAdmin); + const privateResult = usePrivateBareMetalInstanceCatalogItems( + undefined, + isActive && isProviderAdmin, + ); + const { data = [], isLoading, error, isSuccess } = isProviderAdmin ? privateResult : publicResult; + + const filteredItems = filterCatalogItemsBySearch(data, search).filter((item) => + matchesPublicationFilter(item, publicationFilter), + ); + + const publicationFilters: ReadonlyArray<{ value: PublicationFilter; label: string }> = [ + { value: 'all', label: t('All') }, + { value: 'published', label: t('Published') }, + { value: 'unpublished', label: t('Unpublished') }, + ]; + + const isFiltered = search.trim().length > 0 || publicationFilter !== 'all'; + const showEmptyState = isSuccess && !error && filteredItems.length === 0; + + return ( + + + + + + + setSearch(value)} + onClear={() => setSearch('')} + aria-label={t('Filter catalog by keyword')} + isDisabled={isLoading || !!error} + /> + + + + {publicationFilters.map((option) => ( + setPublicationFilter(option.value)} + /> + ))} + + + + + + + + + + {showEmptyState ? ( + + + + {isFiltered + ? t('No catalog items match your search or filter.') + : t('No catalog items have been created yet.')} + + + + ) : ( + + + + + {t('Bare Metal')} + + + + + {filteredItems.map((item) => ( + + navigate(`/admin/catalog/baremetal-instance/${item.id}`)} + scopeBadge={} + statusLabel={} + /> + + ))} + + + + + )} + + ); +}; + +export default BareMetalInstanceCatalogManagementPanel; diff --git a/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx b/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx new file mode 100644 index 00000000..c1b55bc0 --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogManagementListPage.test.tsx @@ -0,0 +1,234 @@ +import { Route, Routes } from 'react-router-dom'; +import { createRouterTransport } from '@connectrpc/connect'; +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { ClusterCatalogItem, ComputeInstanceCatalogItem } from '@osac/types'; +import { + BareMetalInstanceCatalogItems, + ClusterCatalogItems, + ComputeInstanceCatalogItems, +} from '@osac/types'; +import type { + ClusterCatalogItem as PrivateClusterCatalogItem, + ComputeInstanceCatalogItem as PrivateComputeInstanceCatalogItem, +} from '@osac/types/private'; +import { + BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems, + ClusterCatalogItems as PrivateClusterCatalogItems, + ComputeInstanceCatalogItems as PrivateComputeInstanceCatalogItems, +} from '@osac/types/private'; + +import CatalogManagementListPage from './CatalogManagementListPage'; +import { SessionProvider } from '../../hooks/use-session'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const privateClusterItem: PrivateClusterCatalogItem = { + $typeName: 'osac.private.v1.ClusterCatalogItem', + id: 'cluster-private-1', + title: 'OpenShift 4 cluster', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +const publicClusterItem: ClusterCatalogItem = { + $typeName: 'osac.public.v1.ClusterCatalogItem', + id: 'cluster-public-1', + title: 'Shared OpenShift cluster', + description: '', + template: '', + published: true, + fieldDefinitions: [], + metadata: { + $typeName: 'osac.public.v1.Metadata', + name: 'shared-cluster', + annotations: {}, + creator: 'admin', + labels: {}, + project: '', + tenant: 'shared', + version: 1, + }, +}; + +const publicUnpublishedVmItem: ComputeInstanceCatalogItem = { + $typeName: 'osac.public.v1.ComputeInstanceCatalogItem', + id: 'vm-public-1', + title: 'Fedora workstation', + description: '', + template: '', + published: false, + fieldDefinitions: [], + metadata: { + $typeName: 'osac.public.v1.Metadata', + name: 'fedora', + annotations: {}, + creator: 'tenant-admin', + labels: {}, + project: '', + tenant: 'acme-corp', + version: 1, + }, +}; + +const privateVmItem: PrivateComputeInstanceCatalogItem = { + $typeName: 'osac.private.v1.ComputeInstanceCatalogItem', + id: 'vm-private-1', + title: 'RHEL 9 workstation', + description: '', + template: '', + published: true, + tenant: 'acme-corp', + fieldDefinitions: [], +}; + +const emptyList = () => ({ items: [] }); + +const createTestTransport = () => + createRouterTransport((router) => { + router.service(PrivateClusterCatalogItems, { list: () => ({ items: [privateClusterItem] }) }); + router.service(ClusterCatalogItems, { list: () => ({ items: [publicClusterItem] }) }); + router.service(PrivateComputeInstanceCatalogItems, { + list: () => ({ items: [privateVmItem] }), + }); + router.service(ComputeInstanceCatalogItems, { + list: () => ({ items: [publicUnpublishedVmItem] }), + }); + router.service(PrivateBareMetalInstanceCatalogItems, { list: emptyList }); + router.service(BareMetalInstanceCatalogItems, { list: emptyList }); + }); + +const renderPage = (role: 'providerAdmin' | 'tenantAdmin', transport = createTestTransport()) => + renderWithProviders( + + + } /> + create-page
} /> + detail-page} /> +
+ , + { transport, routerEntries: ['/admin/catalog'] }, + ); + +describe('CatalogManagementListPage', () => { + it('renders the three resource type tabs', () => { + renderPage('providerAdmin'); + expect(screen.getByRole('tab', { name: 'Clusters' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Virtual Machines' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Bare Metal' })).toBeInTheDocument(); + }); + + it('shows the CSP Admin (private API) items with an organization scope badge on the default tab', async () => { + renderPage('providerAdmin'); + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + expect(screen.getByText('Organization: acme-corp')).toBeInTheDocument(); + }); + + it('shows the Tenant Admin (public API) items on the default tab', async () => { + renderPage('tenantAdmin'); + await waitFor(() => { + expect(screen.getByText(publicClusterItem.title)).toBeInTheDocument(); + }); + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('switches tabs and shows the newly active tab items', async () => { + const { user } = renderPage('tenantAdmin'); + + await waitFor(() => { + expect(screen.getByText(publicClusterItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('tab', { name: 'Virtual Machines' })); + + await waitFor(() => { + expect(screen.getByText(publicUnpublishedVmItem.title)).toBeInTheDocument(); + }); + }); + + it('shows an empty state on a tab with no catalog items', async () => { + const { user } = renderPage('providerAdmin'); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('tab', { name: 'Bare Metal' })); + + await waitFor(() => { + expect( + screen.getByRole('heading', { name: 'No catalog items found', level: 2 }), + ).toBeInTheDocument(); + }); + expect(screen.getByText('No catalog items have been created yet.')).toBeInTheDocument(); + }); + + it('filters items by search keyword', async () => { + const { user } = renderPage('providerAdmin'); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.type( + screen.getByRole('textbox', { name: 'Filter catalog by keyword' }), + 'no-such-item', + ); + + await waitFor(() => { + expect(screen.queryByText(privateClusterItem.title)).not.toBeInTheDocument(); + }); + }); + + it('filters items by publication status', async () => { + const { user } = renderPage('tenantAdmin'); + + await user.click(screen.getByRole('tab', { name: 'Virtual Machines' })); + await waitFor(() => { + expect(screen.getByText(publicUnpublishedVmItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Published' })); + + await waitFor(() => { + expect(screen.queryByText(publicUnpublishedVmItem.title)).not.toBeInTheDocument(); + }); + }); + + it('navigates to the kind-specific create route when Create is clicked', async () => { + const { user } = renderPage('providerAdmin'); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('create-page')).toBeInTheDocument(); + }); + }); + + it('navigates to the detail route when a card is clicked', async () => { + const { user } = renderPage('providerAdmin'); + + await waitFor(() => { + expect(screen.getByText(privateClusterItem.title)).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole('button', { + name: `Open catalog item details for ${privateClusterItem.title}`, + }), + ); + + await waitFor(() => { + expect(screen.getByText('detail-page')).toBeInTheDocument(); + }); + }); +}); diff --git a/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx b/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx new file mode 100644 index 00000000..79e2a508 --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogManagementListPage.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import { Tab, TabTitleText, Tabs } from '@patternfly/react-core'; + +import { type PublicationFilter } from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import ListPage from '@osac/ui-components/components/Page/ListPage'; +import { useSession } from '@osac/ui-components/hooks/use-session'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; + +import BareMetalInstanceCatalogManagementPanel from './BareMetalInstanceCatalogManagementPanel'; +import ClusterCatalogManagementPanel from './ClusterCatalogManagementPanel'; +import ComputeInstanceCatalogManagementPanel from './ComputeInstanceCatalogManagementPanel'; + +type CatalogManagementTabKey = 'cluster' | 'compute-instance' | 'baremetal-instance'; + +const CatalogManagementListPage = () => { + const { t } = useTranslation(); + const { role } = useSession(); + const [activeTab, setActiveTab] = useState('cluster'); + const [search, setSearch] = useState(''); + const [publicationFilter, setPublicationFilter] = useState('all'); + + const sharedPanelProps = { search, setSearch, publicationFilter, setPublicationFilter, role }; + + return ( + + setActiveTab(eventKey as CatalogManagementTabKey)} + aria-label={t('Catalog management resource type tabs')} + > + {t('Clusters')}}> + + + {t('Virtual Machines')}} + > + + + {t('Bare Metal')}}> + + + + + ); +}; + +export default CatalogManagementListPage; diff --git a/libs/ui-components/src/pages/admin/ClusterCatalogManagementPanel.tsx b/libs/ui-components/src/pages/admin/ClusterCatalogManagementPanel.tsx new file mode 100644 index 00000000..30212044 --- /dev/null +++ b/libs/ui-components/src/pages/admin/ClusterCatalogManagementPanel.tsx @@ -0,0 +1,159 @@ +import { useNavigate } from 'react-router-dom'; +import { + Button, + EmptyState, + EmptyStateBody, + Flex, + FlexItem, + Gallery, + GalleryItem, + SearchInput, + Stack, + StackItem, + Title, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core'; + +import { useClusterCatalogItems } from '@osac/ui-components/api/v1/cluster-catalog-item'; +import { usePrivateClusterCatalogItems } from '@osac/ui-components/api/v1/private/cluster-catalog-item'; +import CatalogItemCard from '@osac/ui-components/components/catalog/CatalogItemCard'; +import { + type PublicationFilter, + catalogItemScope, + filterCatalogItemsBySearch, + matchesPublicationFilter, +} from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import CatalogItemScopeBadge from '@osac/ui-components/components/catalogManagement/CatalogItemScopeBadge'; +import CatalogItemStatusLabel from '@osac/ui-components/components/catalogManagement/CatalogItemStatusLabel'; +import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import type { DemoShellRole } from '@osac/ui-components/shellTypes'; + +interface ClusterCatalogManagementPanelProps { + isActive: boolean; + search: string; + setSearch: (value: string) => void; + publicationFilter: PublicationFilter; + setPublicationFilter: (value: PublicationFilter) => void; + role: DemoShellRole; +} + +const ClusterCatalogManagementPanel = ({ + isActive, + search, + setSearch, + publicationFilter, + setPublicationFilter, + role, +}: ClusterCatalogManagementPanelProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = useClusterCatalogItems(undefined, isActive && !isProviderAdmin); + const privateResult = usePrivateClusterCatalogItems(undefined, isActive && isProviderAdmin); + const { data = [], isLoading, error, isSuccess } = isProviderAdmin ? privateResult : publicResult; + + const filteredItems = filterCatalogItemsBySearch(data, search).filter((item) => + matchesPublicationFilter(item, publicationFilter), + ); + + const publicationFilters: ReadonlyArray<{ value: PublicationFilter; label: string }> = [ + { value: 'all', label: t('All') }, + { value: 'published', label: t('Published') }, + { value: 'unpublished', label: t('Unpublished') }, + ]; + + const isFiltered = search.trim().length > 0 || publicationFilter !== 'all'; + // `isSuccess` (not just `!isLoading`) guards against a disabled, not-yet-fetched query on an + // inactive tab — those report `isLoading: false` with no data, which would otherwise show this + // tab as empty before it has ever actually fetched. + const showEmptyState = isSuccess && !error && filteredItems.length === 0; + + return ( + + + + + + + setSearch(value)} + onClear={() => setSearch('')} + aria-label={t('Filter catalog by keyword')} + isDisabled={isLoading || !!error} + /> + + + + {publicationFilters.map((option) => ( + setPublicationFilter(option.value)} + /> + ))} + + + + + + + + + + {showEmptyState ? ( + + + + {isFiltered + ? t('No catalog items match your search or filter.') + : t('No catalog items have been created yet.')} + + + + ) : ( + + + + + {t('Clusters')} + + + + + {filteredItems.map((item) => ( + + navigate(`/admin/catalog/cluster/${item.id}`)} + scopeBadge={} + statusLabel={} + /> + + ))} + + + + + )} + + ); +}; + +export default ClusterCatalogManagementPanel; diff --git a/libs/ui-components/src/pages/admin/ComputeInstanceCatalogManagementPanel.tsx b/libs/ui-components/src/pages/admin/ComputeInstanceCatalogManagementPanel.tsx new file mode 100644 index 00000000..16d2e9b4 --- /dev/null +++ b/libs/ui-components/src/pages/admin/ComputeInstanceCatalogManagementPanel.tsx @@ -0,0 +1,162 @@ +import { useNavigate } from 'react-router-dom'; +import { + Button, + EmptyState, + EmptyStateBody, + Flex, + FlexItem, + Gallery, + GalleryItem, + SearchInput, + Stack, + StackItem, + Title, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core'; + +import { useComputeInstanceCatalogItems } from '@osac/ui-components/api/v1/compute-instance-catalog-item'; +import { usePrivateComputeInstanceCatalogItems } from '@osac/ui-components/api/v1/private/compute-instance-catalog-item'; +import CatalogItemCard from '@osac/ui-components/components/catalog/CatalogItemCard'; +import { + type PublicationFilter, + catalogItemScope, + filterCatalogItemsBySearch, + matchesPublicationFilter, +} from '@osac/ui-components/components/catalog/catalogItemDisplay'; +import CatalogItemScopeBadge from '@osac/ui-components/components/catalogManagement/CatalogItemScopeBadge'; +import CatalogItemStatusLabel from '@osac/ui-components/components/catalogManagement/CatalogItemStatusLabel'; +import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import type { DemoShellRole } from '@osac/ui-components/shellTypes'; + +interface ComputeInstanceCatalogManagementPanelProps { + isActive: boolean; + search: string; + setSearch: (value: string) => void; + publicationFilter: PublicationFilter; + setPublicationFilter: (value: PublicationFilter) => void; + role: DemoShellRole; +} + +const ComputeInstanceCatalogManagementPanel = ({ + isActive, + search, + setSearch, + publicationFilter, + setPublicationFilter, + role, +}: ComputeInstanceCatalogManagementPanelProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isProviderAdmin = role === 'providerAdmin'; + const publicResult = useComputeInstanceCatalogItems(undefined, isActive && !isProviderAdmin); + const privateResult = usePrivateComputeInstanceCatalogItems( + undefined, + isActive && isProviderAdmin, + ); + const { data = [], isLoading, error, isSuccess } = isProviderAdmin ? privateResult : publicResult; + + const filteredItems = filterCatalogItemsBySearch(data, search).filter((item) => + matchesPublicationFilter(item, publicationFilter), + ); + + const publicationFilters: ReadonlyArray<{ value: PublicationFilter; label: string }> = [ + { value: 'all', label: t('All') }, + { value: 'published', label: t('Published') }, + { value: 'unpublished', label: t('Unpublished') }, + ]; + + const isFiltered = search.trim().length > 0 || publicationFilter !== 'all'; + const showEmptyState = isSuccess && !error && filteredItems.length === 0; + + return ( + + + + + + + setSearch(value)} + onClear={() => setSearch('')} + aria-label={t('Filter catalog by keyword')} + isDisabled={isLoading || !!error} + /> + + + + {publicationFilters.map((option) => ( + setPublicationFilter(option.value)} + /> + ))} + + + + + + + + + + {showEmptyState ? ( + + + + {isFiltered + ? t('No catalog items match your search or filter.') + : t('No catalog items have been created yet.')} + + + + ) : ( + + + + + {t('Virtual Machines')} + + + + + {filteredItems.map((item) => ( + + navigate(`/admin/catalog/compute-instance/${item.id}`)} + scopeBadge={} + statusLabel={} + /> + + ))} + + + + + )} + + ); +}; + +export default ComputeInstanceCatalogManagementPanel; diff --git a/libs/ui-components/src/pages/tenant/CatalogPage.tsx b/libs/ui-components/src/pages/tenant/CatalogPage.tsx index a817fa7e..de610e71 100644 --- a/libs/ui-components/src/pages/tenant/CatalogPage.tsx +++ b/libs/ui-components/src/pages/tenant/CatalogPage.tsx @@ -48,7 +48,7 @@ const getTypeLabel = (typeFilter: CatalogTypeFilter, t: TFunction) => { const useCatalogItems = (typeFilter: CatalogTypeFilter) => { const vms = useComputeInstanceCatalogItems(undefined, typeFilter === 'vm'); const clusters = useClusterCatalogItems(undefined, typeFilter === 'cluster'); - const bms = useBareMetalInstanceCatalogItems(typeFilter === 'bm'); + const bms = useBareMetalInstanceCatalogItems(undefined, typeFilter === 'bm'); switch (typeFilter) { case 'vm': diff --git a/libs/ui-components/src/test-utils/TestProviders.tsx b/libs/ui-components/src/test-utils/TestProviders.tsx index 404d4338..3163a3dd 100644 --- a/libs/ui-components/src/test-utils/TestProviders.tsx +++ b/libs/ui-components/src/test-utils/TestProviders.tsx @@ -3,7 +3,7 @@ import { I18nextProvider } from 'react-i18next'; import { MemoryRouter } from 'react-router-dom'; import type { Transport } from '@connectrpc/connect'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { type RenderOptions, type RenderResult, render } from '@testing-library/react'; +import { type RenderOptions, type RenderResult, render, renderHook } from '@testing-library/react'; import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; import i18n from 'i18next'; @@ -15,6 +15,8 @@ import { } from './createMockConnectTransport'; import en from '../../../i18n/locales/en/translation.json'; import { ApiProvider } from '../api/api-context'; +import { SessionProvider } from '../hooks/use-session'; +import type { DemoShellRole } from '../shellTypes'; const createTestI18n = () => { const instance = i18n.createInstance(); @@ -96,3 +98,28 @@ export const renderWithProviders = ( return { ...view, user: userEvent.setup() }; }; + +export type RenderHookWithProvidersOptions = { + role: DemoShellRole; + transport: Transport; + username?: string; +}; + +/** Renders a hook wrapped in `SessionProvider` + `ApiProvider` + a fresh `QueryClient` — for testing + * role-aware hooks (e.g. admin catalog-item hooks) against a mock Connect transport. */ +export const renderHookWithProviders = ( + hook: () => TResult, + { role, transport, username = 'test-user' }: RenderHookWithProvidersOptions, +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + return { ...renderHook(hook, { wrapper }), queryClient }; +}; diff --git a/libs/ui-components/src/test-utils/catalogHookTestHelpers.ts b/libs/ui-components/src/test-utils/catalogHookTestHelpers.ts new file mode 100644 index 00000000..7a29f934 --- /dev/null +++ b/libs/ui-components/src/test-utils/catalogHookTestHelpers.ts @@ -0,0 +1,56 @@ +import { createRouterTransport } from '@connectrpc/connect'; +import type { ConnectRouter } from '@connectrpc/connect'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { waitFor } from '@testing-library/react'; +import { expect, it } from 'vitest'; + +import { renderHookWithProviders } from './TestProviders'; +import type { ListParams } from '../api/types'; +import type { DemoShellRole } from '../shellTypes'; + +interface CatalogHookTestConfig { + /** Human-readable endpoint description used in the generated test name, e.g. "public ClusterCatalogItems". */ + endpointDescription: string; + useHook: (params?: ListParams, enabled?: boolean) => UseQueryResult; + role: DemoShellRole; + item: TItem; + /** Registers the mock service on the router; call `onList` when the List RPC is invoked. */ + registerList: (router: ConnectRouter, onList?: () => void) => void; +} + +/** + * Shared "fetches items from the List endpoint" + "does not fetch when disabled" test pair for the + * per-kind catalog-item list hooks (public and private). Each call site keeps its own concretely-typed + * `registerList` callback so the mock service registration stays fully type-checked against the real + * Connect service descriptor. + */ +export const createCatalogHookTests = ({ + endpointDescription, + useHook, + role, + item, + registerList, +}: CatalogHookTestConfig) => { + it(`fetches items from the ${endpointDescription} List endpoint`, async () => { + const transport = createRouterTransport((router) => registerList(router)); + + const { result } = renderHookWithProviders(() => useHook(), { role, transport }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([item]); + }); + + it('does not fetch when disabled', async () => { + let listCalled = false; + const transport = createRouterTransport((router) => + registerList(router, () => { + listCalled = true; + }), + ); + + renderHookWithProviders(() => useHook({}, false), { role, transport }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listCalled).toBe(false); + }); +};