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/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 8ed870df..2eec4434 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", @@ -117,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", @@ -124,15 +126,19 @@ "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", "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", "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...", @@ -157,6 +163,8 @@ "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", + "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", @@ -189,7 +197,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", @@ -198,17 +210,20 @@ "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.", "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.", @@ -228,6 +243,8 @@ "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", + "pattern: {{value}}": "pattern: {{value}}", "Paused": "Paused", "Pod CIDR": "Pod CIDR", "Pool size is required": "Pool size is required", @@ -245,10 +262,13 @@ "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", + "Provisioned Resources": "Provisioned Resources", "Provisioning": "Provisioning", "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 +276,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 +309,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", @@ -305,8 +328,10 @@ "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", "Virtual machine conditions": "Virtual machine conditions", "Virtual machine summary": "Virtual machine summary", "Virtual machines": "Virtual machines", @@ -315,6 +340,8 @@ "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/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/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/api/cel.test.ts b/libs/ui-components/src/api/cel.test.ts new file mode 100644 index 00000000..9d6c3604 --- /dev/null +++ b/libs/ui-components/src/api/cel.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { catalogItemProvisionedResourcesFilter, 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'); + }); +}); + +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 new file mode 100644 index 00000000..e496cdb5 --- /dev/null +++ b/libs/ui-components/src/api/cel.ts @@ -0,0 +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/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..f0d76847 --- /dev/null +++ b/libs/ui-components/src/api/v1/baremetal-instance-templates.test.ts @@ -0,0 +1,70 @@ +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); + }); + + 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 new file mode 100644 index 00000000..8a24671a --- /dev/null +++ b/libs/ui-components/src/api/v1/baremetal-instance-templates.ts @@ -0,0 +1,16 @@ +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); + const trimmedId = id?.trim() ?? ''; + return useApiQuery({ + queryKey: apiQueryKey('v1/baremetal_instance_templates', trimmedId ? [trimmedId] : undefined), + queryFn: () => client.get({ id: trimmedId }), + select: (data) => data.object, + enabled: Boolean(trimmedId), + }); +}; 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..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,10 +13,13 @@ import { import { type PatchBareMetalInstanceInput, + useBareMetalInstanceCatalogItem, 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 = { @@ -45,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}` }, @@ -140,3 +181,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..eb8e8b82 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({ @@ -42,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/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-templates.test.ts b/libs/ui-components/src/api/v1/compute-instance-templates.test.ts new file mode 100644 index 00000000..f8530560 --- /dev/null +++ b/libs/ui-components/src/api/v1/compute-instance-templates.test.ts @@ -0,0 +1,70 @@ +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); + }); + + 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 new file mode 100644 index 00000000..07f6e85c --- /dev/null +++ b/libs/ui-components/src/api/v1/compute-instance-templates.ts @@ -0,0 +1,16 @@ +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); + const trimmedId = id?.trim() ?? ''; + return useApiQuery({ + queryKey: apiQueryKey('v1/compute_instance_templates', trimmedId ? [trimmedId] : undefined), + queryFn: () => client.get({ id: trimmedId }), + select: (data) => data.object, + enabled: Boolean(trimmedId), + }); +}; 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() ?? ''; 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)}"`; 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), + }); +}; 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..482fa881 --- /dev/null +++ b/libs/ui-components/src/components/Primitives/SanitizedMarkdown.tsx @@ -0,0 +1,19 @@ +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. +// 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} +); + +export default SanitizedMarkdown; 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/catalog/catalogItemDisplay.test.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.test.ts index a9a45dfa..ae0c4e54 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'; @@ -10,8 +11,10 @@ import { catalogItemScope, catalogItemSubtitle, filterCatalogItemsBySearch, + formatCatalogFieldValidationSummary, } from './catalogItemDisplay'; import { + type CatalogFieldDefinition, catalogItemFieldDefinitions, readCatalogItemFieldDefinitions, } from '../catalogProvision/catalogFieldDefinition'; @@ -323,6 +326,141 @@ describe('catalogItemScope', () => { }); }); +describe('formatCatalogFieldValidationSummary', () => { + const baseDef: CatalogFieldDefinition = { + path: 'cores', + displayName: 'vCPUs', + 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, t)).toBe('—'); + }); + + it('returns an em dash for an empty validation schema', () => { + expect(formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: {} }, t)).toBe('—'); + }); + + it('summarizes minimum and maximum together', () => { + expect( + 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 } }, t), + ).toBe('min: 2'); + }); + + it('summarizes string length constraints', () => { + expect( + 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]+$' }, + }, + t, + ), + ).toBe('pattern: ^[a-z]+$'); + }); + + it('summarizes an enum constraint', () => { + expect( + 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 }, + }, + 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' }] }, + }, + t, + ), + ).toBe('—'); + }); + + it('includes a zero minimum (falsy but valid)', () => { + expect( + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { minimum: 0 } }, t), + ).toBe('min: 0'); + }); + + it('ignores an empty enum array', () => { + expect( + 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' } }, t), + ).toBe('—'); + }); + + it('summarizes an integer type with no explicit bounds as a whole-number constraint', () => { + expect( + formatCatalogFieldValidationSummary({ ...baseDef, validationSchema: { type: 'integer' } }, t), + ).toBe('whole number'); + }); + + it('keeps the whole-number label alongside integer bounds', () => { + expect( + formatCatalogFieldValidationSummary( + { + ...baseDef, + validationSchema: { type: 'integer', minimum: 1 }, + }, + t, + ), + ).toBe('whole number, 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..bedc9524 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,6 +177,41 @@ export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string = return fieldDefinitionDefaultToInputString(defaultValue) || '—'; }; +export const formatCatalogFieldValidationSummary = ( + def: CatalogFieldDefinition, + t: TFunction, +): string => { + const schema = def.validationSchema; + if (!schema || !Object.keys(schema).length) { + return '—'; + } + + const parts: string[] = []; + if (schema.type === 'integer') { + parts.push(t('whole number')); + } + if (typeof schema.minimum === 'number') { + parts.push(t('min: {{value}}', { value: schema.minimum })); + } + if (typeof schema.maximum === 'number') { + parts.push(t('max: {{value}}', { value: schema.maximum })); + } + if (typeof schema.minLength === 'number') { + parts.push(t('min length: {{value}}', { value: schema.minLength })); + } + if (typeof schema.maxLength === 'number') { + parts.push(t('max length: {{value}}', { value: schema.maxLength })); + } + if (typeof schema.pattern === 'string' && schema.pattern) { + parts.push(t('pattern: {{value}}', { value: schema.pattern })); + } + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + parts.push(t('enum: [{{value}}]', { value: 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 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/CatalogItemDetailActionButtons.test.tsx b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx new file mode 100644 index 00000000..e86f75d8 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.test.tsx @@ -0,0 +1,231 @@ +import { Route, Routes } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +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', + 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(); + expect(screen.getByRole('switch')).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(); + }); + }); + + 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 new file mode 100644 index 00000000..1484c96f --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetailActionButtons.tsx @@ -0,0 +1,78 @@ +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'; +import WithTooltip from '../Primitives/WithTooltip'; + +interface CatalogItemDetailActionButtonsProps { + catalogItem: CatalogItem; + role: DemoShellRole; + 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 = ({ + catalogItem, + role, + editHref, + onDeleteClick, + onTogglePublish, + disabledReason, +}: CatalogItemDetailActionButtonsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const scope = catalogItemScope(catalogItem, role); + const isHiddenForTenantAdmin = role === 'tenantAdmin' && scope.level === 'general'; + + if (isHiddenForTenantAdmin) { + return null; + } + + const isDisabled = Boolean(disabledReason); + + return ( + + + + + + + + + + + + + + + + ); +}; + +export default CatalogItemDetailActionButtons; 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..3e230ea8 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.test.tsx @@ -0,0 +1,92 @@ +import { Route, Routes } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { BareMetalInstanceCatalogItem, 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, with Delete and the publish toggle disabled', () => { + renderWithProviders(); + + expect(screen.getByRole('button', { name: 'Edit' })).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 () => { + 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(); + }); + + 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 new file mode 100644 index 00000000..b5b1c3df --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemDetails.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react'; +import { + Flex, + FlexItem, + PageSection, + Stack, + StackItem, + Tab, + TabContent, + TabContentBody, + TabTitleText, + 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 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'; +import { ResourceDetailHeader } from '../Resource/ResourceDetailHeader'; + +interface CatalogItemDetailsProps { + catalogItem: CatalogItem; + 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, role, templateName }: CatalogItemDetailsProps) => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState(0); + const kind = catalogItemDetailKind(catalogItem); + const editHref = `/admin/catalog/${kind}/${catalogItem.id}/edit`; + + return ( + <> + + + + + + } + /> + + + {}} + onTogglePublish={() => {}} + disabledReason={t('Deleting and publishing catalog items is not yet available.')} + /> + + + + + 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/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..7025edc8 --- /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, t)} +
+ ); +}; + +export default CatalogItemFieldDefinitionsTab; 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..90de9f10 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.test.tsx @@ -0,0 +1,117 @@ +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('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( + , + ); + 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..271da3b0 --- /dev/null +++ b/libs/ui-components/src/components/catalogManagement/CatalogItemOverviewTab.tsx @@ -0,0 +1,108 @@ +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; + const hasDescription = Boolean(description?.trim()); + + return ( + + + + + {t('Name')} + + {displayValue(catalogItem.title)} + + + + {t('Description')} + + {hasDescription && description ? ( + {description} + ) : ( + displayValue() + )} + + + + {t('Resource type')} + + {catalogItemResourceTypeLabel(catalogItem, t)} + + + + {t('Scope')} + + + + + + {t('Template')} + {displayValue(templateName)} + + + {t('Publication status')} + + + + + + {t('Created')} + + + + + + + + ); +}; + +export default CatalogItemOverviewTab; 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; 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 new file mode 100644 index 00000000..ea757d8e --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.test.tsx @@ -0,0 +1,82 @@ +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..582c1368 --- /dev/null +++ b/libs/ui-components/src/pages/admin/CatalogItemDetailPageShell.tsx @@ -0,0 +1,69 @@ +import type { CatalogItem } from '../../components/catalog/catalogItemDisplay'; +import CatalogItemDetails from '../../components/catalogManagement/CatalogItemDetails'; +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; + 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, + 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.test.tsx b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx new file mode 100644 index 00000000..4709d6f5 --- /dev/null +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.test.tsx @@ -0,0 +1,131 @@ +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(), +})); +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') +).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 + >, + ); + vi.mocked(useBareMetalInstanceTemplate).mockReturnValue( + mockQueryResult({ data: undefined }) as ReturnType, + ); + }); + + 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('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(() => { + 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..03bbbb6e --- /dev/null +++ b/libs/ui-components/src/pages/admin/baremetal-instance/BareMetalInstanceCatalogItemDetailPage.tsx @@ -0,0 +1,42 @@ +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 { useSession } from '../../../hooks/use-session'; +import CatalogItemDetailPageShell from '../CatalogItemDetailPageShell'; +import { useCatalogItemDetailData } from '../useCatalogItemDetailData'; + +const BareMetalInstanceCatalogItemDetailPage = () => { + const { id } = useParams<{ id: string }>(); + const { role } = useSession(); + + const { + data: catalogItem, + isLoading, + isError, + error, + refetch, + template, + } = useCatalogItemDetailData({ + id, + role, + usePublicItem: useBareMetalInstanceCatalogItem, + usePrivateItem: usePrivateBareMetalInstanceCatalogItem, + useTemplate: useBareMetalInstanceTemplate, + }); + + return ( + void refetch()} + /> + ); +}; + +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..830d9a61 --- /dev/null +++ b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.test.tsx @@ -0,0 +1,137 @@ +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('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(() => { + 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..a9692534 --- /dev/null +++ b/libs/ui-components/src/pages/admin/cluster/ClusterCatalogItemDetailPage.tsx @@ -0,0 +1,42 @@ +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 { useSession } from '../../../hooks/use-session'; +import CatalogItemDetailPageShell from '../CatalogItemDetailPageShell'; +import { useCatalogItemDetailData } from '../useCatalogItemDetailData'; + +const ClusterCatalogItemDetailPage = () => { + const { id } = useParams<{ id: string }>(); + const { role } = useSession(); + + const { + data: catalogItem, + isLoading, + isError, + error, + refetch, + template, + } = useCatalogItemDetailData({ + id, + role, + usePublicItem: useClusterCatalogItem, + usePrivateItem: usePrivateClusterCatalogItem, + useTemplate: useClusterTemplate, + }); + + return ( + void refetch()} + /> + ); +}; + +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..b56b0f06 --- /dev/null +++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.test.tsx @@ -0,0 +1,127 @@ +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(), +})); +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') +).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 + >, + ); + vi.mocked(useComputeInstanceTemplate).mockReturnValue( + mockQueryResult({ data: undefined }) as ReturnType, + ); + }); + + 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('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(() => { + 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..20c57341 --- /dev/null +++ b/libs/ui-components/src/pages/admin/compute-instance/ComputeInstanceCatalogItemDetailPage.tsx @@ -0,0 +1,42 @@ +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 { useSession } from '../../../hooks/use-session'; +import CatalogItemDetailPageShell from '../CatalogItemDetailPageShell'; +import { useCatalogItemDetailData } from '../useCatalogItemDetailData'; + +const ComputeInstanceCatalogItemDetailPage = () => { + const { id } = useParams<{ id: string }>(); + const { role } = useSession(); + + const { + data: catalogItem, + isLoading, + isError, + error, + refetch, + template, + } = useCatalogItemDetailData({ + id, + role, + usePublicItem: useComputeInstanceCatalogItem, + usePrivateItem: usePrivateComputeInstanceCatalogItem, + useTemplate: useComputeInstanceTemplate, + }); + + return ( + void refetch()} + /> + ); +}; + +export default ComputeInstanceCatalogItemDetailPage; diff --git a/libs/ui-components/src/pages/admin/useCatalogItemDetailData.test.ts b/libs/ui-components/src/pages/admin/useCatalogItemDetailData.test.ts new file mode 100644 index 00000000..ce0fe6ad --- /dev/null +++ b/libs/ui-components/src/pages/admin/useCatalogItemDetailData.test.ts @@ -0,0 +1,52 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useCatalogItemDetailData } from './useCatalogItemDetailData'; +import { mockQueryResult } from '../../test-utils/query'; + +describe('useCatalogItemDetailData', () => { + 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 }; +}; 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: {}