Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4ee9480
OSAC-2932: widen CatalogItem type and add scope derivation for admin …
ElayAharoni Jul 23, 2026
ed41374
OSAC-2932: support private-v1 catalog item kinds in CatalogItemIcon
ElayAharoni Jul 23, 2026
1c34988
OSAC-2932: add role-aware admin catalog item hooks (list + publish to…
ElayAharoni Jul 23, 2026
192cb3f
OSAC-2932: add CatalogItemScopeBadge, CatalogItemStatusLabel, Catalog…
ElayAharoni Jul 23, 2026
2218b09
OSAC-2932: add scope badge, status label, and publish toggle slots to…
ElayAharoni Jul 23, 2026
3d28c4c
OSAC-2932: add CatalogManagementListPage with resource type tabs
ElayAharoni Jul 23, 2026
c71dc95
OSAC-2932: fix prettier formatting in admin catalog hook tests
ElayAharoni Jul 23, 2026
9402235
OSAC-2932: address cross-cutting review findings
ElayAharoni Jul 26, 2026
9fb3e1e
OSAC-2932: Address review feedback — shared test harness, param order…
ElayAharoni Jul 26, 2026
390f0cc
OSAC-2932: address rawagner PR review — remove publish toggle, split …
ElayAharoni Jul 27, 2026
11357d0
Merge remote-tracking branch 'origin/main' into OSAC-2932-catalog-man…
ElayAharoni Jul 27, 2026
ba422a1
OSAC-2932: fix formatting after merge with main
ElayAharoni Jul 27, 2026
f3fcf0b
OSAC-2932: extract shared catalog hook test factory
ElayAharoni Jul 27, 2026
5c3d592
Merge remote-tracking branch 'origin/main' into OSAC-2932-catalog-man…
ElayAharoni Jul 27, 2026
dfacec5
Merge remote-tracking branch 'origin/main' into OSAC-2932-catalog-man…
ElayAharoni Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions apps/app-frontend/src/shell/AdminCatalogRoutes.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
import { Route, Routes } from 'react-router-dom';

import ListPage from '@osac/ui-components/components/Page/ListPage';
import { useTranslation } from '@osac/ui-components/hooks/useTranslation';
import CatalogManagementListPage from '@osac/ui-components/pages/admin/CatalogManagementListPage';

export const AdminCatalogRoutes = () => {
const { t } = useTranslation();

return (
<Routes>
<Route
index
element={
<ListPage title={t('Catalog management')}>
<div />
</ListPage>
}
/>
<Route index element={<CatalogManagementListPage />} />
<Route path=":type/create" element={<div />} />
<Route path=":type/:id" element={<div />} />
<Route path=":type/:id/edit" element={<div />} />
Expand Down
9 changes: 9 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"Catalog configuration is unavailable for this virtual machine.": "Catalog configuration is unavailable for this virtual machine.",
"Catalog item": "Catalog item",
"Catalog management": "Catalog management",
"Catalog management resource type tabs": "Catalog management resource type tabs",
"catalogProvision.actions.back": "Back",
"catalogProvision.actions.cancel": "Cancel",
"catalogProvision.actions.create": "Create",
Expand Down Expand Up @@ -146,6 +147,7 @@
"Failed to load security groups": "Failed to load security groups",
"Failed to load subnets": "Failed to load subnets",
"Filter bare metal instances by name": "Filter bare metal instances by name",
"Filter by publication status": "Filter by publication status",
"Filter catalog by keyword": "Filter catalog by keyword",
"Filter catalog by resource type": "Filter catalog by resource type",
"Fixed": "Fixed",
Expand Down Expand Up @@ -184,6 +186,8 @@
"No bare metal instances match your search.": "No bare metal instances match your search.",
"No bare metal instances yet.": "No bare metal instances yet.",
"No catalog items found": "No catalog items found",
"No catalog items have been created yet.": "No catalog items have been created yet.",
"No catalog items match your search or filter.": "No catalog items match your search or filter.",
"No catalog items match your search.": "No catalog items match your search.",
"No inbound rules yet. Add one to allow incoming traffic.": "No inbound rules yet. Add one to allow incoming traffic.",
"No node sets added yet.": "No node sets added yet.",
Expand All @@ -202,6 +206,8 @@
"Nodes": "Nodes",
"Open catalog item details for {{title}}": "Open catalog item details for {{title}}",
"Optional cloud-init user data (max 64 KB).": "Optional cloud-init user data (max 64 KB).",
"Organization": "Organization",
"Organization: {{name}}": "Organization: {{name}}",
"Outbound Rules": "Outbound Rules",
"Overview": "Overview",
"Parent virtual network": "Parent virtual network",
Expand All @@ -218,6 +224,7 @@
"Port To": "Port To",
"Port To is required for TCP/UDP": "Port To is required for TCP/UDP",
"Port To must be >= Port From": "Port To must be >= Port From",
"Project: {{name}}": "Project: {{name}}",
"Protocol": "Protocol",
"Protocol is required": "Protocol is required",
"Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.",
Expand All @@ -226,6 +233,7 @@
"Provisioning failed": "Provisioning failed",
"Public IP": "Public IP",
"Public SSH key is required": "Public SSH key is required",
"Published": "Published",
"Pull secret": "Pull secret",
"Pull secret is required": "Pull secret is required",
"Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).": "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).",
Expand Down Expand Up @@ -270,6 +278,7 @@
"UDP": "UDP",
"Unauthorized": "Unauthorized",
"Unknown": "Unknown",
"Unpublished": "Unpublished",
"Use IPv4 CIDR notation (for example 10.128.0.0/14).": "Use IPv4 CIDR notation (for example 10.128.0.0/14).",
"Use IPv4 CIDR notation (for example 172.30.0.0/16).": "Use IPv4 CIDR notation (for example 172.30.0.0/16).",
"User data": "User data",
Expand Down
5 changes: 4 additions & 1 deletion libs/ui-components/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export type ApiRoute =
| 'v1/baremetal_instance_catalog_items'
| 'v1/baremetal_instances'
| 'v1/public_ips'
| 'v1/public_ip_attachments';
| 'v1/public_ip_attachments'
| 'v1/compute_instance_catalog_items_private'
| 'v1/cluster_catalog_items_private'
| 'v1/baremetal_instance_catalog_items_private';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe

Suggested change
| 'v1/compute_instance_catalog_items_private'
| 'v1/cluster_catalog_items_private'
| 'v1/baremetal_instance_catalog_items_private';
| 'v1/private/compute_instance_catalog_items
| 'v1/private/cluster_catalog_items'
| 'v1/private/baremetal_instance_catalog_items';

to mirror our proposed api/v1 hooks structure

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and applied (fixed the missing closing quote from the suggested diff) — 390f0cc.


/**
* Strict 3-part tuple that encodes an API address.
Expand Down
201 changes: 201 additions & 0 deletions libs/ui-components/src/api/v1/baremetal-instance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { createRouterTransport } from '@connectrpc/connect';
import { act, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import type { BareMetalInstanceCatalogItem } from '@osac/types';
import { BareMetalInstanceCatalogItems } from '@osac/types';
import type { BareMetalInstanceCatalogItem as PrivateBareMetalInstanceCatalogItem } from '@osac/types/private';
import { BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems } from '@osac/types/private';

import {
useAdminBareMetalInstanceCatalogItems,
useAdminSetBareMetalInstanceCatalogItemPublished,
} from './baremetal-instance';
import { renderHookWithProviders } from '../../test-utils/TestProviders';

const publicItem: BareMetalInstanceCatalogItem = {
$typeName: 'osac.public.v1.BareMetalInstanceCatalogItem',
id: 'public-1',
title: 'Public bare metal item',
description: '',
template: '',
published: true,
fieldDefinitions: [],
};

const privateItem: PrivateBareMetalInstanceCatalogItem = {
$typeName: 'osac.private.v1.BareMetalInstanceCatalogItem',
id: 'private-1',
title: 'Private bare metal item',
description: '',
template: '',
published: true,
tenant: '',
fieldDefinitions: [],
};

const createTestTransport = (options: {
onPublicList?: () => void;
onPrivateList?: () => void;
onPublicUpdate?: (req: unknown) => void;
onPrivateUpdate?: (req: unknown) => void;
}) =>
createRouterTransport((router) => {
router.service(BareMetalInstanceCatalogItems, {
list: () => {
options.onPublicList?.();
return { items: [publicItem] };
},
update: (req) => {
options.onPublicUpdate?.(req);
return { object: publicItem };
},
});

router.service(PrivateBareMetalInstanceCatalogItems, {
list: () => {
options.onPrivateList?.();
return { items: [privateItem] };
},
update: (req) => {
options.onPrivateUpdate?.(req);
return { object: privateItem };
},
});
});

const renderWithSession = <TResult>(
hook: () => TResult,
role: 'providerAdmin' | 'tenantAdmin',
transport: ReturnType<typeof createRouterTransport>,
) => renderHookWithProviders(hook, { role, transport });

describe('useAdminBareMetalInstanceCatalogItems', () => {
it('calls the private List endpoint for providerAdmin', async () => {
let privateListCalled = false;
let publicListCalled = false;
const transport = createTestTransport({
onPrivateList: () => {
privateListCalled = true;
},
onPublicList: () => {
publicListCalled = true;
},
});

const { result } = renderWithSession(
() => useAdminBareMetalInstanceCatalogItems(),
'providerAdmin',
transport,
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([privateItem]);
expect(privateListCalled).toBe(true);
expect(publicListCalled).toBe(false);
});

it('calls the public List endpoint for tenantAdmin', async () => {
let privateListCalled = false;
let publicListCalled = false;
const transport = createTestTransport({
onPrivateList: () => {
privateListCalled = true;
},
onPublicList: () => {
publicListCalled = true;
},
});

const { result } = renderWithSession(
() => useAdminBareMetalInstanceCatalogItems(),
'tenantAdmin',
transport,
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([publicItem]);
expect(publicListCalled).toBe(true);
expect(privateListCalled).toBe(false);
});

it('does not call either endpoint when disabled', async () => {
let privateListCalled = false;
let publicListCalled = false;
const transport = createTestTransport({
onPrivateList: () => {
privateListCalled = true;
},
onPublicList: () => {
publicListCalled = true;
},
});

renderWithSession(
() => useAdminBareMetalInstanceCatalogItems({}, false),
'providerAdmin',
transport,
);

await new Promise((resolve) => setTimeout(resolve, 10));
expect(privateListCalled).toBe(false);
expect(publicListCalled).toBe(false);
});
});

describe('useAdminSetBareMetalInstanceCatalogItemPublished', () => {
it('sends the update with a published field mask to the private client for providerAdmin', async () => {
let lastReq: unknown;
const transport = createTestTransport({
onPrivateUpdate: (req) => {
lastReq = req;
},
});

const { result } = renderWithSession(
() => useAdminSetBareMetalInstanceCatalogItemPublished(),
'providerAdmin',
transport,
);

act(() => {
result.current.mutate({ id: 'private-1', published: false });
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(lastReq).toMatchObject({
object: { id: 'private-1', published: false },
updateMask: { paths: ['published'] },
});
});

it('sends the update to the public client for tenantAdmin', async () => {
let lastReq: unknown;
let privateCalled = false;
const transport = createTestTransport({
onPublicUpdate: (req) => {
lastReq = req;
},
onPrivateUpdate: () => {
privateCalled = true;
},
});

const { result } = renderWithSession(
() => useAdminSetBareMetalInstanceCatalogItemPublished(),
'tenantAdmin',
transport,
);

act(() => {
result.current.mutate({ id: 'public-1', published: true });
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(lastReq).toMatchObject({
object: { id: 'public-1', published: true },
updateMask: { paths: ['published'] },
});
expect(privateCalled).toBe(false);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
54 changes: 50 additions & 4 deletions libs/ui-components/src/api/v1/baremetal-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
BareMetalInstanceSchema,
BareMetalInstances,
} from '@osac/types';
import { BareMetalInstanceCatalogItems as PrivateBareMetalInstanceCatalogItems } from '@osac/types/private';

import { useSession } from '../../hooks/use-session';
import { useApiFetch } from '../api-context';
import { apiQueryKey } from '../types';
import { type ListParams, apiQueryKey } from '../types';
import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query';

export const useBareMetalInstances = () => {
Expand All @@ -31,16 +33,60 @@ export const useBareMetalInstance = (id: string) => {
});
};

export const useBareMetalInstanceCatalogItems = (enabled = true) => {
export const useBareMetalInstanceCatalogItems = (params: ListParams = {}, enabled = true) => {
const client = useApiFetch(BareMetalInstanceCatalogItems);
return useApiQuery({
queryKey: apiQueryKey('v1/baremetal_instance_catalog_items'),
queryFn: () => client.list({}),
queryKey: apiQueryKey('v1/baremetal_instance_catalog_items', undefined, params),
queryFn: () => client.list(params),
select: (data) => data.items,
enabled,
});
};

/**
* Admin list hook for the catalog management pages. CSP Admin (`providerAdmin`) sees all items via
* the private API (including unpublished). Tenant Admin sees their tenant's items via the public API —
* this currently returns only published items regardless of caller role; unpublished items scoped to
* the Tenant Admin's own tenant are not visible through this hook (tracked as a backend limitation in
* OSAC-3121).
*/
export const useAdminBareMetalInstanceCatalogItems = (params: ListParams = {}, enabled = true) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets not have private and public hooks in one file.

Lets update the structure to

  • api/v1 - what we already have, for public APIs
  • api/v1/private - for private APIs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and done — split into a plain public hook in api/v1/ and a plain private hook in the new api/v1/private/ for each of the three catalog item kinds (390f0cc).

const { role } = useSession();
const isProviderAdmin = role === 'providerAdmin';
const publicResult = useBareMetalInstanceCatalogItems(params, enabled && !isProviderAdmin);
const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this hooks should always work only with PrivateBareMetalInstanceCatalogItems

lets not check role here. The caller should be resposible for calling the right hook to get the expected resources back.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — deleted useAdminBareMetalInstanceCatalogItems (and the equivalent for cluster/compute-instance). The new admin panel components call the plain public or private hook directly based on role instead (390f0cc).

const privateResult = useApiQuery({
queryKey: apiQueryKey('v1/baremetal_instance_catalog_items_private', undefined, params),
queryFn: () => privateClient.list(params),
select: (data) => data.items,
enabled: enabled && isProviderAdmin,
});
return isProviderAdmin ? privateResult : publicResult;
};

export const useAdminSetBareMetalInstanceCatalogItemPublished = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should have a single hook for updating BM. Not for every operation - ie usePatchBareMetalInstance that is below. The caller then defines what gets updated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook is gone now — see the publish-toggle removal below, there's no longer a BM catalog-item mutation to consolidate.

const { role } = useSession();
const isProviderAdmin = role === 'providerAdmin';
const publicClient = useApiFetch(BareMetalInstanceCatalogItems);
const privateClient = useApiFetch(PrivateBareMetalInstanceCatalogItems);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here - lets not mix role + private/public

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applies for all other catalog item hooks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix applied here — see the reply on the sibling comment (390f0cc).

const qc = useApiQueryClient();
return useMutation({
mutationFn: ({ id, published }: { id: string; published: boolean }): Promise<void> =>
(isProviderAdmin
? privateClient.update({ object: { id, published }, updateMask: { paths: ['published'] } })
: publicClient.update({ object: { id, published }, updateMask: { paths: ['published'] } })
).then(() => undefined),
onSuccess: () =>
qc.invalidateQueries({
queryKey: apiQueryKey(
isProviderAdmin
? 'v1/baremetal_instance_catalog_items_private'
: 'v1/baremetal_instance_catalog_items',
),
}),
});
};

export const invalidateBareMetalInstancesQueries = async (qc: ApiQueryClient) => {
await qc.invalidateQueries({ queryKey: apiQueryKey('v1/baremetal_instances') });
};
Expand Down
Loading
Loading