diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html
index 5239fe4c..2e4e75ad 100644
--- a/apps/app-frontend/index.html
+++ b/apps/app-frontend/index.html
@@ -5,6 +5,8 @@
OSAC
+
+
diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json
index e74e48da..088d5c06 100644
--- a/apps/app-frontend/package.json
+++ b/apps/app-frontend/package.json
@@ -15,11 +15,11 @@
"@connectrpc/connect-web": "^2.1.2",
"@osac/types": "workspace:*",
"@osac/ui-components": "workspace:*",
- "@patternfly/patternfly": "^6.4.0",
- "@patternfly/react-charts": "^8.0.0",
- "@patternfly/react-core": "^6.4.1",
- "@patternfly/react-icons": "^6.4.0",
- "@patternfly/react-table": "^6.4.0",
+ "@patternfly/patternfly": "^6.6.1",
+ "@patternfly/react-charts": "^8.6.1",
+ "@patternfly/react-core": "^6.6.1",
+ "@patternfly/react-icons": "^6.6.1",
+ "@patternfly/react-table": "^6.6.1",
"@tanstack/react-query": "^5.0.0",
"i18next": "^23.0.0",
"i18next-browser-languagedetector": "^8.0.0",
@@ -28,6 +28,7 @@
"react-dom": "^19.2.4",
"react-i18next": "^15.0.0",
"react-router-dom": "^7.0.0",
+ "react-svg": "^16.2.0",
"victory": "^37.3.6"
},
"devDependencies": {
diff --git a/apps/app-frontend/public/theme-boot.js b/apps/app-frontend/public/theme-boot.js
new file mode 100644
index 00000000..0785b455
--- /dev/null
+++ b/apps/app-frontend/public/theme-boot.js
@@ -0,0 +1,19 @@
+// Apply PatternFly theme/contrast before React boots to avoid a flash of the wrong theme.
+(function () {
+ const html = document.documentElement;
+ // Felt brand tokens — always on
+ html.classList.add('pf-v6-theme-felt');
+
+ let theme = localStorage.getItem('osac/theme') || 'system';
+ if (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ theme = 'dark';
+ }
+ html.classList.toggle('pf-v6-theme-dark', theme === 'dark');
+
+ let contrast = localStorage.getItem('osac/contrast') || 'system';
+ if (contrast === 'system') {
+ contrast = window.matchMedia('(prefers-contrast: more)').matches ? 'contrast' : 'glass';
+ }
+ html.classList.toggle('pf-v6-theme-high-contrast', contrast === 'contrast');
+ html.classList.toggle('pf-v6-theme-glass', contrast === 'glass');
+})();
diff --git a/apps/app-frontend/src/shell/AppShell.tsx b/apps/app-frontend/src/shell/AppShell.tsx
index 541029b5..3bdad4ed 100644
--- a/apps/app-frontend/src/shell/AppShell.tsx
+++ b/apps/app-frontend/src/shell/AppShell.tsx
@@ -1,13 +1,15 @@
import { type ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
-import { Page } from '@patternfly/react-core';
+import { Page, SkipToContent } from '@patternfly/react-core';
+import { CatalogItemDetailPage } from '@osac/ui-components/components/catalog/details/CatalogItemDetailPage.tsx';
import ErrorBoundary from '@osac/ui-components/components/ErrorBoundary/ErrorBoundary';
import IdentityProviderRoutes from '@osac/ui-components/components/IdentityProvider/IdentityProviderRoutes';
import ProjectRoutes from '@osac/ui-components/components/Project/ProjectRoutes';
import RoleBindingRoutes from '@osac/ui-components/components/RoleBinding/RoleBindingRoutes';
import { VmDetailsPage } from '@osac/ui-components/components/vm/VmDetailsPage';
import { useSession } from '@osac/ui-components/hooks/use-session';
+import { useTranslation } from '@osac/ui-components/hooks/useTranslation';
import { SecurityGroupDetailPage } from '@osac/ui-components/pages/networking/SecurityGroupDetailPage';
import { SecurityGroupsListPage } from '@osac/ui-components/pages/networking/SecurityGroupsListPage';
import { VirtualNetworkDetailPage } from '@osac/ui-components/pages/networking/VirtualNetworkDetailPage';
@@ -25,6 +27,8 @@ import { ShellSidebar } from './ShellSidebar';
import { StorageRoutes } from './StorageRoutes';
import { TenantRoutes } from './TenantRoutes';
+const MAIN_CONTENT_ID = 'osac-main-content';
+
const ShellRoute = ({ children }: { children: ReactNode }) => {
const { pathname } = useLocation();
@@ -33,6 +37,7 @@ const ShellRoute = ({ children }: { children: ReactNode }) => {
export const AppShell = ({ logout }: { logout: () => Promise }) => {
const { role } = useSession();
+ const { t } = useTranslation();
const defaultRoute = defaultRouteForRole(role);
@@ -41,6 +46,11 @@ export const AppShell = ({ logout }: { logout: () => Promise }) => {
masthead={}
sidebar={}
isManagedSidebar
+ mainContainerId={MAIN_CONTENT_ID}
+ skipToContent={
+ {t('Skip to content')}
+ }
+ isContentFilled
>
Promise }) => {
}
/>
+
+
+
+ }
+ />
{
const [logoutError, setLogoutError] = React.useState();
const navigate = useNavigate();
const { role, username, tenantId } = useSession();
- const displayName = username || 'User';
+ const displayName = username || t('User');
return (
<>
{logoutError && (
setLogoutError(undefined)}>
-
+
@@ -68,22 +67,23 @@ export const ShellMasthead = ({ onLogout }: ShellMastheadProps) => {
-
-
-
+
-
+
-
-
- Red Hat OSAC
-
- {tenantId && (
- {t('Tenant: {{ tenantId }}', { tenantId })}
- )}
-
+ }
+ >
+
+
-
+ {tenantId ? (
+
+ {t('Tenant: {{ tenantId }}', { tenantId })}
+
+ ) : null}
+
@@ -106,7 +106,7 @@ export const ShellMasthead = ({ onLogout }: ShellMastheadProps) => {
isExpanded={isUserMenuOpen}
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
icon={}
- aria-label="Account menu"
+ aria-label={t('Account menu')}
>
{displayName}{' '}
+ ) : null,
+ }}
selectableActions={
isWizardMode && selection
? {
@@ -116,27 +137,38 @@ const CatalogItemCard = ({
) : null}
+
+
+
{metadataLabels.length > 0 ? (
- <>
- {resources.length > 0 ? (
-
-
-
- ) : null}
-
-
- {metadataLabels.map(({ key, value }) => (
-
-
-
- ))}
-
-
- >
+
+
+ {metadataLabels.map(({ key, value }) => (
+
+
+
+ ))}
+
+
+ ) : null}
+ {type ? (
+
+ }
+ onClick={(event) => {
+ event.stopPropagation();
+ handleLaunch();
+ }}
+ >
+ {t('Launch instance')}
+
+
) : null}
diff --git a/libs/ui-components/src/components/catalog/CatalogItemDetailContent.tsx b/libs/ui-components/src/components/catalog/CatalogItemDetailContent.tsx
deleted file mode 100644
index 7664ca6d..00000000
--- a/libs/ui-components/src/components/catalog/CatalogItemDetailContent.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import {
- Content,
- DescriptionList,
- DescriptionListDescription,
- DescriptionListGroup,
- DescriptionListTerm,
- Flex,
- FlexItem,
- Label,
- Stack,
- StackItem,
- Title,
-} from '@patternfly/react-core';
-
-import { CatalogFieldEditabilityLabel } from './CatalogFieldEditabilityLabel';
-import {
- type CatalogItem,
- catalogItemMetadataLabelEntries,
- catalogItemResourceParts,
- formatCatalogFieldDefault,
-} from './catalogItemDisplay';
-import { useTranslation } from '../../hooks/useTranslation';
-import { catalogItemFieldDefinitions } from '../catalogProvision/catalogFieldDefinition';
-import { SubtleContent } from '../SubtleContent/SubtleContent';
-
-interface CatalogItemDetailContentProps {
- item: CatalogItem;
-}
-
-export const CatalogItemDetailContent = ({ item }: CatalogItemDetailContentProps) => {
- const { t } = useTranslation();
- const resources = catalogItemResourceParts(item);
- const metadataLabels = catalogItemMetadataLabelEntries(item);
- const fieldDefinitions = catalogItemFieldDefinitions(item);
-
- return (
-
-
-
-
- Catalog name
- {item.metadata?.name ?? '—'}
-
-
-
-
- {item.description?.trim() ? (
-
-
- Description
-
- {item.description}
-
- ) : null}
-
- {resources.length > 0 ? (
-
-
- Default resources
-
-
- {resources.map((resource, index) => (
-
-
-
- ))}
-
-
- ) : null}
-
- {metadataLabels.length > 0 ? (
-
-
- Labels
-
-
- {metadataLabels.map(({ key, value }) => (
-
-
-
- ))}
-
-
- ) : null}
-
- {fieldDefinitions.length > 0 ? (
-
-
-
-
- {t('Configuration defaults')}
-
-
-
-
- {t(
- 'Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.',
- )}
-
-
-
-
- {fieldDefinitions.map((def) => (
-
-
-
- {def.displayName}
-
-
-
-
-
-
- {formatCatalogFieldDefault(def)}
-
-
- ))}
-
-
- ) : null}
-
- );
-};
diff --git a/libs/ui-components/src/components/catalog/CatalogItemDetailDrawer.css b/libs/ui-components/src/components/catalog/CatalogItemDetailDrawer.css
deleted file mode 100644
index f52fcef7..00000000
--- a/libs/ui-components/src/components/catalog/CatalogItemDetailDrawer.css
+++ /dev/null
@@ -1,79 +0,0 @@
-.catalog-item-detail-drawer-host {
- position: relative;
- flex: 1 1 auto;
- display: flex;
- flex-direction: column;
- min-height: 0;
- width: 100%;
-}
-
-.catalog-item-detail-drawer {
- position: relative;
- z-index: 160;
- flex: 1 1 auto;
- min-height: 0;
- width: 100%;
- height: 100%;
-}
-
-.catalog-item-detail-drawer__main {
- min-height: 0;
-}
-
-/* Overlay drawer: keep cards full width underneath; panel slides in from the right. */
-.catalog-item-detail-drawer:not(.pf-m-inline) > .pf-v6-c-drawer__main > .pf-v6-c-drawer__content {
- flex-basis: 100%;
- flex-shrink: 0;
-}
-
-.catalog-item-detail-drawer.pf-m-expanded > .pf-v6-c-drawer__main > .pf-v6-c-drawer__panel {
- height: 100%;
- max-height: 100%;
-}
-
-.catalog-item-detail-drawer__head {
- align-items: flex-start;
-}
-
-.catalog-item-detail-drawer__subtitle {
- margin: 0;
- color: var(--pf-t--global--text--color--subtle);
- line-height: 1.35;
-}
-
-.catalog-item-detail-drawer__body {
- overflow: auto;
-}
-
-.catalog-item-detail-content {
- display: flex;
- flex-direction: column;
- gap: var(--pf-t--global--spacer--lg);
-}
-
-.catalog-item-detail-content__section-title {
- margin-bottom: var(--pf-t--global--spacer--sm);
-}
-
-.catalog-item-detail-content__label-key {
- font-weight: var(--pf-t--global--font--weight--body--bold);
-}
-
-.catalog-item-detail-content .pf-v6-c-description-list__group {
- margin-bottom: var(--pf-t--global--spacer--sm);
-}
-
-.catalog-item-detail-content .pf-v6-c-description-list__group:last-child {
- margin-bottom: 0;
-}
-
-.catalog-provision-wizard-fullpage .catalog-item-detail-drawer-host {
- flex: 1 1 auto;
- min-height: 0;
-}
-
-.catalog-provision-wizard-fullpage .catalog-item-detail-drawer {
- flex: 1 1 auto;
- min-height: 0;
- z-index: 350;
-}
diff --git a/libs/ui-components/src/components/catalog/CatalogItemDetailDrawer.tsx b/libs/ui-components/src/components/catalog/CatalogItemDetailDrawer.tsx
deleted file mode 100644
index cd8f105b..00000000
--- a/libs/ui-components/src/components/catalog/CatalogItemDetailDrawer.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { type ReactNode, useEffect, useId, useRef } from 'react';
-import {
- Content,
- Drawer,
- DrawerActions,
- DrawerCloseButton,
- DrawerContent,
- DrawerContentBody,
- DrawerHead,
- DrawerPanelBody,
- DrawerPanelContent,
- Stack,
- StackItem,
- Title,
-} from '@patternfly/react-core';
-
-import { CatalogItemDetailContent } from './CatalogItemDetailContent';
-import type { CatalogItem } from './catalogItemDisplay';
-import { catalogItemSubtitle } from './catalogItemDisplay';
-
-import './CatalogItemDetailDrawer.css';
-
-interface CatalogItemDetailDrawerProps {
- item: CatalogItem | undefined;
- onClose: () => void;
- actions?: ReactNode;
- children: ReactNode;
-}
-
-export const CatalogItemDetailDrawer = ({
- item,
- onClose,
- actions,
- children,
-}: CatalogItemDetailDrawerProps) => {
- const titleId = useId();
- const drawerTitleRef = useRef(null);
-
- useEffect(() => {
- if (item) {
- drawerTitleRef.current?.focus();
- }
- }, [item]);
-
- if (!item) {
- return {children}
;
- }
-
- return (
-
-
-
-
-
-
-
- {item.title}
-
-
-
-
- {catalogItemSubtitle(item)}
-
-
-
-
- {actions}
-
-
-
-
-
-
-
- }
- >
-
- {children}
-
-
-
-
- );
-};
diff --git a/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx b/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx
index 1ca46a11..d21f1b38 100644
--- a/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx
+++ b/libs/ui-components/src/components/catalog/CatalogItemListSection.tsx
@@ -9,15 +9,14 @@ import {
} from '@patternfly/react-core';
import CatalogItemCard from './CatalogItemCard';
-import type { CatalogItem } from './catalogItemDisplay';
+import type { CatalogItemWithType } from './catalogItemDisplay';
import { getErrorMessage } from '../../utils/error';
import QueryErrorState from '../Resource/QueryErrorState';
interface CatalogItemListSectionProps {
- title: string;
- items: CatalogItem[];
- selectedItemId?: string | null;
- onSelectItem: (item: CatalogItem) => void;
+ title?: string;
+ items: CatalogItemWithType[];
+ onSelectItem: (item: CatalogItemWithType) => void;
isLoading?: boolean;
error?: unknown;
}
@@ -25,7 +24,6 @@ interface CatalogItemListSectionProps {
export const CatalogItemListSection = ({
title,
items,
- selectedItemId = null,
onSelectItem,
isLoading = false,
error = null,
@@ -37,11 +35,13 @@ export const CatalogItemListSection = ({
return (
-
-
- {title}
-
-
+ {title ? (
+
+
+ {title}
+
+
+ ) : null}
{isLoading ? (
@@ -56,12 +56,12 @@ export const CatalogItemListSection = ({
) : null}
{items.length > 0 ? (
-
+
{items.map((item) => (
onSelectItem(item)}
/>
diff --git a/libs/ui-components/src/components/catalog/catalogItemDisplay.ts b/libs/ui-components/src/components/catalog/catalogItemDisplay.ts
index ece6cacc..3c34576c 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,
@@ -13,8 +15,10 @@ import {
isCatalogCardResourceFieldPath,
isCatalogItemResourceFieldPath,
isClusterCatalogItemResourceFieldPath,
+ normalizeCatalogFieldPath,
resolvedFieldDefault,
} from '../catalogProvision/catalogFieldDefinition';
+import { findCatalogFieldDefinition } from '../catalogProvision/wizard/catalogOverlay';
export type CatalogItem =
| ClusterCatalogItem
@@ -23,8 +27,21 @@ export type CatalogItem =
export type CatalogItemKind = 'vm' | 'cluster' | 'bm';
+export type CatalogItemWithType = CatalogItem & { type: CatalogItemKind };
+
+export const catalogItemTypeBadgeLabel = (kind: CatalogItemKind, t: TFunction): string => {
+ switch (kind) {
+ case 'vm':
+ return t('Virtual Machine');
+ case 'bm':
+ return t('Bare Metal');
+ default:
+ return t('Cluster');
+ }
+};
+
export const catalogFieldDefault = (item: CatalogItem, path: string): unknown => {
- const def = catalogItemFieldDefinitions(item).find((entry) => entry.path === path);
+ const def = findCatalogFieldDefinition(path, catalogItemFieldDefinitions(item));
return def ? resolvedFieldDefault(def) : undefined;
};
@@ -53,7 +70,7 @@ export const catalogFieldDefinitionForPath = (
item: CatalogItem,
path: string,
): CatalogFieldDefinition | undefined => {
- return catalogItemFieldDefinitions(item).find((def) => def.path === path);
+ return findCatalogFieldDefinition(path, catalogItemFieldDefinitions(item));
};
const FALLBACK_RESOURCE_LABELS: Record = {
@@ -67,10 +84,10 @@ export const catalogItemResourceFieldDefinitions = (
item: CatalogItem,
): CatalogFieldDefinition[] => {
const defs = catalogItemFieldDefinitions(item);
- const byPath = new Map(defs.map((def) => [def.path, def]));
+ const byNormalizedPath = new Map(defs.map((def) => [normalizeCatalogFieldPath(def.path), def]));
const vmResourceDefs = CATALOG_ITEM_RESOURCE_FIELD_PATHS.flatMap((path) => {
- const def = byPath.get(path);
+ const def = byNormalizedPath.get(path);
return def ? [def] : [];
});
if (vmResourceDefs.length > 0) {
@@ -80,6 +97,15 @@ export const catalogItemResourceFieldDefinitions = (
return defs.filter((def) => isClusterCatalogItemResourceFieldPath(def.path));
};
+/** Configuration defaults shown on the detail page (excludes resource chips already listed above). */
+export const catalogItemConfigurationFieldDefinitions = (
+ item: CatalogItem,
+): CatalogFieldDefinition[] => {
+ return catalogItemFieldDefinitions(item).filter(
+ (def) => !isCatalogCardResourceFieldPath(def.path),
+ );
+};
+
const formatCatalogResourcePart = (def: CatalogFieldDefinition): string | null => {
if (!isCatalogCardResourceFieldPath(def.path)) {
return null;
@@ -92,8 +118,9 @@ const formatCatalogResourcePart = (def: CatalogFieldDefinition): string | null =
if (!value) {
return null;
}
+ const normalizedPath = normalizeCatalogFieldPath(def.path);
const label = isCatalogItemResourceFieldPath(def.path)
- ? def.displayName || FALLBACK_RESOURCE_LABELS[def.path]
+ ? def.displayName || FALLBACK_RESOURCE_LABELS[normalizedPath as CatalogItemResourceFieldPath]
: def.displayName;
if (!label) {
return null;
@@ -141,6 +168,16 @@ export const filterCatalogItemsBySearch = (items: CatalogItem[], search: string)
return items.filter((item) => searchableCatalogItemText(item).includes(searchTerm));
};
+export const filterCatalogItemsByTypes = (
+ items: CatalogItemWithType[],
+ types: CatalogItemKind[],
+): CatalogItemWithType[] => {
+ if (!types?.length) {
+ return [];
+ }
+ return items.filter((item) => types.includes(item.type));
+};
+
export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string => {
const defaultValue = resolvedFieldDefault(def);
if (defaultValue === undefined) {
@@ -148,3 +185,28 @@ export const formatCatalogFieldDefault = (def: CatalogFieldDefinition): string =
}
return fieldDefinitionDefaultToInputString(defaultValue) || '—';
};
+
+export const getCatalogCreateAction = (kind: CatalogItemKind, id: string, t: TFunction) => {
+ switch (kind) {
+ case 'vm':
+ return {
+ label: t('Create virtual machine'),
+ path: `/vms/create/${id}`,
+ };
+ case 'cluster':
+ return {
+ label: t('Create cluster'),
+ path: `/clusters/create/${id}`,
+ };
+ case 'bm':
+ return {
+ label: t('Provision bare metal'),
+ path: `/bare-metal/create/${id}`,
+ };
+ default:
+ return {
+ label: '',
+ path: '#',
+ };
+ }
+};
diff --git a/libs/ui-components/src/components/catalog/CatalogFieldEditabilityLabel.tsx b/libs/ui-components/src/components/catalog/details/CatalogFieldEditabilityLabel.tsx
similarity index 87%
rename from libs/ui-components/src/components/catalog/CatalogFieldEditabilityLabel.tsx
rename to libs/ui-components/src/components/catalog/details/CatalogFieldEditabilityLabel.tsx
index 10747f1b..7c99cb77 100644
--- a/libs/ui-components/src/components/catalog/CatalogFieldEditabilityLabel.tsx
+++ b/libs/ui-components/src/components/catalog/details/CatalogFieldEditabilityLabel.tsx
@@ -1,6 +1,6 @@
import { Label } from '@patternfly/react-core';
-import { useTranslation } from '../../hooks/useTranslation';
+import { useTranslation } from '../../../hooks/useTranslation.ts';
interface CatalogFieldEditabilityLabelProps {
editable: boolean;
diff --git a/libs/ui-components/src/components/catalog/details/CatalogItemDetailContent.test.tsx b/libs/ui-components/src/components/catalog/details/CatalogItemDetailContent.test.tsx
new file mode 100644
index 00000000..c69c075f
--- /dev/null
+++ b/libs/ui-components/src/components/catalog/details/CatalogItemDetailContent.test.tsx
@@ -0,0 +1,100 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { ComputeInstanceCatalogItem } from '@osac/types';
+
+import { CatalogItemDetailContent } from './CatalogItemDetailContent';
+
+vi.mock('../../../hooks/useTranslation', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+const vmItem: ComputeInstanceCatalogItem = {
+ $typeName: 'osac.public.v1.ComputeInstanceCatalogItem',
+ id: 'catalog-rhel-9',
+ metadata: {
+ $typeName: 'osac.public.v1.Metadata',
+ displayName: '',
+ description: '',
+ name: 'catalog-rhel-9',
+ annotations: {},
+ creator: 'foo',
+ labels: { 'run-strategy': 'Always' },
+ project: 'foo',
+ tenant: 'foo',
+ version: 1,
+ },
+ title: 'RHEL 9 catalog',
+ description: 'RHEL 9 base image',
+ published: true,
+ template: undefined,
+ fieldDefinitions: [
+ {
+ $typeName: 'osac.public.v1.FieldDefinition',
+ path: 'cores',
+ displayName: 'vCPUs',
+ editable: false,
+ validationSchema: '',
+ default: {
+ $typeName: 'google.protobuf.Value',
+ kind: { case: 'numberValue', value: 4 },
+ },
+ },
+ {
+ $typeName: 'osac.public.v1.FieldDefinition',
+ path: 'memory_gib',
+ displayName: 'RAM (GiB)',
+ editable: false,
+ validationSchema: '',
+ default: {
+ $typeName: 'google.protobuf.Value',
+ kind: { case: 'numberValue', value: 8 },
+ },
+ },
+ {
+ $typeName: 'osac.public.v1.FieldDefinition',
+ path: 'run_strategy',
+ displayName: 'run-strategy',
+ editable: false,
+ validationSchema: '',
+ default: {
+ $typeName: 'google.protobuf.Value',
+ kind: { case: 'stringValue', value: 'Always' },
+ },
+ },
+ {
+ $typeName: 'osac.public.v1.FieldDefinition',
+ path: 'image.source_ref',
+ displayName: 'VM image',
+ editable: true,
+ validationSchema: '',
+ default: {
+ $typeName: 'google.protobuf.Value',
+ kind: { case: 'stringValue', value: 'quay.io/example/rhel9' },
+ },
+ },
+ ],
+};
+
+describe('CatalogItemDetailContent', () => {
+ it('shows drawer-era details including run-strategy configuration and labels', () => {
+ render();
+
+ expect(screen.getByText('catalog-rhel-9')).toBeInTheDocument();
+ expect(screen.getByText('RHEL 9 base image')).toBeInTheDocument();
+ expect(screen.getByText('4 vCPUs')).toBeInTheDocument();
+ expect(screen.getByText('8 RAM (GiB)')).toBeInTheDocument();
+
+ // Label key and configuration field display name both surface run-strategy
+ expect(screen.getAllByText(/run-strategy/).length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('Always').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('VM image')).toBeInTheDocument();
+ expect(screen.getByText('quay.io/example/rhel9')).toBeInTheDocument();
+
+ // Resource field display names are not repeated under Configuration defaults
+ expect(screen.queryByText('vCPUs')).not.toBeInTheDocument();
+ expect(screen.queryByText('RAM (GiB)')).not.toBeInTheDocument();
+ });
+});
diff --git a/libs/ui-components/src/components/catalog/details/CatalogItemDetailContent.tsx b/libs/ui-components/src/components/catalog/details/CatalogItemDetailContent.tsx
new file mode 100644
index 00000000..d3d544bf
--- /dev/null
+++ b/libs/ui-components/src/components/catalog/details/CatalogItemDetailContent.tsx
@@ -0,0 +1,133 @@
+import {
+ Card,
+ CardBody,
+ CardTitle,
+ DescriptionList,
+ DescriptionListDescription,
+ DescriptionListGroup,
+ DescriptionListTerm,
+ Flex,
+ FlexItem,
+ Label,
+ Stack,
+ StackItem,
+} from '@patternfly/react-core';
+
+import { CatalogFieldEditabilityLabel } from './CatalogFieldEditabilityLabel.tsx';
+import { useTranslation } from '../../../hooks/useTranslation.ts';
+import { SubtleContent } from '../../SubtleContent/SubtleContent.tsx';
+import {
+ type CatalogItem,
+ catalogItemConfigurationFieldDefinitions,
+ catalogItemMetadataLabelEntries,
+ catalogItemResourceParts,
+ formatCatalogFieldDefault,
+} from '../catalogItemDisplay.ts';
+
+interface CatalogItemDetailContentProps {
+ item: CatalogItem;
+}
+
+export const CatalogItemDetailContent = ({ item }: CatalogItemDetailContentProps) => {
+ const { t } = useTranslation();
+ const resources = catalogItemResourceParts(item);
+ const metadataLabels = catalogItemMetadataLabelEntries(item);
+ const configurationFields = catalogItemConfigurationFieldDefinitions(item);
+ const description = item.description?.trim();
+
+ return (
+
+
+
+ {t('Details')}
+
+
+
+ {t('Catalog name')}
+
+ {item.metadata?.name ?? '—'}
+
+
+
+ {description ? (
+
+ {t('Description')}
+ {description}
+
+ ) : null}
+
+ {resources.length > 0 ? (
+
+ {t('Default resources')}
+
+
+ {resources.map((resource, index) => (
+
+
+
+ ))}
+
+
+
+ ) : null}
+
+ {metadataLabels.length > 0 ? (
+
+ {t('Labels')}
+
+
+ {metadataLabels.map(({ key, value }) => (
+
+
+
+ ))}
+
+
+
+ ) : null}
+
+
+
+
+
+ {configurationFields.length > 0 ? (
+
+
+ {t('Configuration defaults')}
+
+
+
+
+ {t(
+ 'Editable fields can be changed when creating from this catalog item. Fixed fields use the default value shown.',
+ )}
+
+
+
+
+ {configurationFields.map((def) => (
+
+
+ {def.displayName}
+
+
+ {formatCatalogFieldDefault(def)}
+
+
+ ))}
+
+
+
+
+
+
+ ) : null}
+
+ );
+};
diff --git a/libs/ui-components/src/components/catalog/details/CatalogItemDetailPage.tsx b/libs/ui-components/src/components/catalog/details/CatalogItemDetailPage.tsx
new file mode 100644
index 00000000..ee00c9f7
--- /dev/null
+++ b/libs/ui-components/src/components/catalog/details/CatalogItemDetailPage.tsx
@@ -0,0 +1,85 @@
+import { useParams } from 'react-router-dom';
+
+import CatalogItemDetails from './CatalogItemDetails.tsx';
+import { useBareMetalInstanceCatalogItem } from '../../../api/v1/baremetal-instance.ts';
+import { useClusterCatalogItem } from '../../../api/v1/cluster-catalog-item.ts';
+import { useComputeInstanceCatalogItem } from '../../../api/v1/compute-instance-catalog-item.ts';
+import { useTranslation } from '../../../hooks/useTranslation.ts';
+import { ResourceDetailsPageError } from '../../Resource/ResourceDetailsPageError.tsx';
+import { ResourceDetailsPageLoading } from '../../Resource/ResourceDetailsPageLoading.tsx';
+import type { CatalogItemKind } from '../catalogItemDisplay.ts';
+
+const isCatalogItemKind = (value: string | undefined): value is CatalogItemKind =>
+ value === 'vm' || value === 'cluster' || value === 'bm';
+
+const useCatalogItemByKind = (kind: CatalogItemKind | undefined, id: string | undefined) => {
+ const vm = useComputeInstanceCatalogItem(kind === 'vm' ? id : undefined);
+ const cluster = useClusterCatalogItem(kind === 'cluster' ? id?.trim() : undefined);
+ const bm = useBareMetalInstanceCatalogItem(kind === 'bm' ? id : undefined);
+
+ switch (kind) {
+ case 'vm':
+ return vm;
+ case 'cluster':
+ return cluster;
+ case 'bm':
+ return bm;
+ default:
+ return {
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ error: undefined,
+ refetch: () => undefined,
+ };
+ }
+};
+
+export const CatalogItemDetailPage = () => {
+ const { t } = useTranslation();
+ const { kind: kindParam, id } = useParams() as { kind?: string; id?: string };
+ const kind = isCatalogItemKind(kindParam) ? kindParam : undefined;
+ const { data: item, isLoading, isError, error, refetch } = useCatalogItemByKind(kind, id);
+
+ if (!kind || !id?.trim()) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (isError) {
+ return (
+ void refetch()}
+ />
+ );
+ }
+
+ if (!item) {
+ return (
+
+ );
+ }
+
+ return ;
+};
diff --git a/libs/ui-components/src/components/catalog/details/CatalogItemDetails.tsx b/libs/ui-components/src/components/catalog/details/CatalogItemDetails.tsx
new file mode 100644
index 00000000..661fd0fa
--- /dev/null
+++ b/libs/ui-components/src/components/catalog/details/CatalogItemDetails.tsx
@@ -0,0 +1,53 @@
+import { useNavigate } from 'react-router-dom';
+import { Button, Flex, FlexItem, PageSection, Stack, StackItem } from '@patternfly/react-core';
+
+import { useTranslation } from '../../../hooks/useTranslation';
+import { ResourceDetailHeader } from '../../Resource/ResourceDetailHeader';
+import { CatalogItem, CatalogItemKind, getCatalogCreateAction } from '../catalogItemDisplay';
+import { CatalogItemDetailContent } from './CatalogItemDetailContent.tsx';
+
+interface CatalogItemDetailsProps {
+ kind: CatalogItemKind;
+ item: CatalogItem;
+}
+
+const CatalogItemDetails = ({ kind, item }: CatalogItemDetailsProps) => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const createAction = getCatalogCreateAction(kind, item.id, t);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CatalogItemDetails;
diff --git a/libs/ui-components/src/components/catalogProvision/catalogFieldDefinition.ts b/libs/ui-components/src/components/catalogProvision/catalogFieldDefinition.ts
index 1dbb7524..e0e04e37 100644
--- a/libs/ui-components/src/components/catalogProvision/catalogFieldDefinition.ts
+++ b/libs/ui-components/src/components/catalogProvision/catalogFieldDefinition.ts
@@ -152,10 +152,13 @@ export type CatalogItemResourceFieldPath = (typeof CATALOG_ITEM_RESOURCE_FIELD_P
const catalogItemResourceFieldPathSet = new Set(CATALOG_ITEM_RESOURCE_FIELD_PATHS);
+/** Catalog field_definitions paths are often spec-relative; accept optional `spec.` prefix. */
+export const normalizeCatalogFieldPath = (path: string): string => path.replace(/^spec\./, '');
+
export const isCatalogItemResourceFieldPath = (
path: string,
): path is CatalogItemResourceFieldPath => {
- return catalogItemResourceFieldPathSet.has(path);
+ return catalogItemResourceFieldPathSet.has(normalizeCatalogFieldPath(path));
};
/** Node-set host type and worker count paths on cluster catalog cards (node set id varies). */
@@ -163,7 +166,7 @@ export const CLUSTER_CATALOG_ITEM_RESOURCE_FIELD_PATH_PATTERN =
/^node_sets\.[^.]+\.(host_type|size)$/;
export const isClusterCatalogItemResourceFieldPath = (path: string): boolean => {
- return CLUSTER_CATALOG_ITEM_RESOURCE_FIELD_PATH_PATTERN.test(path);
+ return CLUSTER_CATALOG_ITEM_RESOURCE_FIELD_PATH_PATTERN.test(normalizeCatalogFieldPath(path));
};
export const isCatalogCardResourceFieldPath = (path: string): boolean => {
diff --git a/libs/ui-components/src/hooks/use-session.tsx b/libs/ui-components/src/hooks/use-session.tsx
index 8cc00bc4..5dca1682 100644
--- a/libs/ui-components/src/hooks/use-session.tsx
+++ b/libs/ui-components/src/hooks/use-session.tsx
@@ -1,7 +1,13 @@
import { createContext, useContext } from 'react';
import type { UserRole } from '../shellTypes';
-import { type ResolvedTheme, type Theme, useTheme } from './use-theme';
+import {
+ type Contrast,
+ type ResolvedContrast,
+ type ResolvedTheme,
+ type Theme,
+ useTheme,
+} from './use-theme';
interface SessionContextValue {
role: UserRole;
@@ -10,6 +16,9 @@ interface SessionContextValue {
userTheme: Theme;
resolvedTheme: ResolvedTheme;
setUserTheme: (theme: Theme) => void;
+ userContrast: Contrast;
+ resolvedContrast: ResolvedContrast;
+ setUserContrast: (contrast: Contrast) => void;
}
const SessionContext = createContext(null);
diff --git a/libs/ui-components/src/hooks/use-theme.ts b/libs/ui-components/src/hooks/use-theme.ts
index 376ac288..22f942dd 100644
--- a/libs/ui-components/src/hooks/use-theme.ts
+++ b/libs/ui-components/src/hooks/use-theme.ts
@@ -3,12 +3,21 @@ import * as React from 'react';
import { useUserPreferences } from './use-user-preferences';
export const THEME_LOCAL_STORAGE_KEY = 'osac/theme';
+export const CONTRAST_LOCAL_STORAGE_KEY = 'osac/contrast';
const THEME_DARK_CLASS = 'pf-v6-theme-dark';
+const THEME_GLASS_CLASS = 'pf-v6-theme-glass';
+const THEME_CONTRAST_CLASS = 'pf-v6-theme-high-contrast';
+/** OpenShift Felt brand tokens — always on, matching Console (non-OKD). */
+const THEME_FELT_CLASS = 'pf-v6-theme-felt';
export type Theme = 'dark' | 'light' | 'system';
export type ResolvedTheme = Exclude;
+/** Contrast preference values mirror OpenShift Console (system / glass / traditional / high contrast). */
+export type Contrast = 'system' | 'glass' | 'default' | 'contrast';
+export type ResolvedContrast = Exclude;
+
const getTheme = (storageTheme: string | null): Theme => {
switch (storageTheme) {
case 'dark': {
@@ -23,62 +32,135 @@ const getTheme = (storageTheme: string | null): Theme => {
}
};
+const getContrast = (storageContrast: string | null): Contrast => {
+ switch (storageContrast) {
+ case 'glass':
+ case 'default':
+ case 'contrast': {
+ return storageContrast;
+ }
+ default: {
+ return 'system';
+ }
+ }
+};
+
const getDarkThemeMq = () => window.matchMedia('(prefers-color-scheme: dark)');
+const getHighContrastMq = () => window.matchMedia('(prefers-contrast: more)');
+
const getResolvedTheme = (darkThemeMq: MediaQueryList, theme: string | null): ResolvedTheme => {
const isDarkPreferred = darkThemeMq.matches;
return theme === 'dark' || (isDarkPreferred && getTheme(theme) === 'system') ? 'dark' : 'light';
};
-export const updateThemeClass = (htmlTagElement: HTMLElement, resolvedTheme: ResolvedTheme) => {
- if (resolvedTheme === 'dark') {
- htmlTagElement.classList.add(THEME_DARK_CLASS);
- } else {
- htmlTagElement.classList.remove(THEME_DARK_CLASS);
+const getResolvedContrast = (
+ highContrastMq: MediaQueryList,
+ contrast: string | null,
+): ResolvedContrast => {
+ const preference = getContrast(contrast);
+ if (preference === 'system') {
+ return highContrastMq.matches ? 'contrast' : 'glass';
}
+ return preference;
+};
+
+export const updateThemeClass = (
+ htmlTagElement: HTMLElement,
+ resolvedTheme: ResolvedTheme,
+ resolvedContrast: ResolvedContrast,
+) => {
+ htmlTagElement.classList.add(THEME_FELT_CLASS);
+ htmlTagElement.classList.toggle(THEME_DARK_CLASS, resolvedTheme === 'dark');
+ htmlTagElement.classList.toggle(THEME_GLASS_CLASS, resolvedContrast === 'glass');
+ htmlTagElement.classList.toggle(THEME_CONTRAST_CLASS, resolvedContrast === 'contrast');
};
export const useTheme = () => {
const htmlTagElement = document.documentElement;
const [userTheme, setUserTheme] = useUserPreferences(THEME_LOCAL_STORAGE_KEY);
+ const [userContrast, setUserContrast] = useUserPreferences(CONTRAST_LOCAL_STORAGE_KEY);
const [resolvedTheme, setResolvedTheme] = React.useState('light');
+ const [resolvedContrast, setResolvedContrast] = React.useState('glass');
React.useEffect(() => {
const currentTheme = getTheme(userTheme);
- const mqListener = (e: MediaQueryListEvent) => {
- const newResolvedTheme: ResolvedTheme = e.matches ? 'dark' : 'light';
- updateThemeClass(htmlTagElement, newResolvedTheme);
- setResolvedTheme(newResolvedTheme);
- };
+ const currentContrast = getContrast(userContrast);
const darkThemeMq = getDarkThemeMq();
- const actualTheme = getResolvedTheme(darkThemeMq, userTheme);
- updateThemeClass(htmlTagElement, actualTheme);
+ const highContrastMq = getHighContrastMq();
+
+ const applyTheme = (
+ nextResolvedTheme: ResolvedTheme,
+ nextResolvedContrast: ResolvedContrast,
+ ) => {
+ updateThemeClass(htmlTagElement, nextResolvedTheme, nextResolvedContrast);
+ setResolvedTheme(nextResolvedTheme);
+ setResolvedContrast(nextResolvedContrast);
+ };
+
+ const darkMqListener = (e: MediaQueryListEvent) => {
+ applyTheme(e.matches ? 'dark' : 'light', getResolvedContrast(highContrastMq, userContrast));
+ };
+
+ const contrastMqListener = () => {
+ applyTheme(
+ getResolvedTheme(darkThemeMq, userTheme),
+ getResolvedContrast(highContrastMq, userContrast),
+ );
+ };
+
+ applyTheme(
+ getResolvedTheme(darkThemeMq, userTheme),
+ getResolvedContrast(highContrastMq, userContrast),
+ );
+
if (currentTheme === 'system') {
- darkThemeMq.addEventListener('change', mqListener);
+ darkThemeMq.addEventListener('change', darkMqListener);
+ }
+ if (currentContrast === 'system') {
+ highContrastMq.addEventListener('change', contrastMqListener);
}
- setResolvedTheme(actualTheme);
return () => {
if (currentTheme === 'system') {
- darkThemeMq.removeEventListener('change', mqListener);
+ darkThemeMq.removeEventListener('change', darkMqListener);
+ }
+ if (currentContrast === 'system') {
+ highContrastMq.removeEventListener('change', contrastMqListener);
}
};
- }, [htmlTagElement, userTheme]);
+ }, [htmlTagElement, userTheme, userContrast]);
const setThemeState = React.useCallback(
(theme: Theme) => {
- const darkTheme = getDarkThemeMq();
- const actualTheme = getResolvedTheme(darkTheme, theme);
- updateThemeClass(htmlTagElement, actualTheme);
+ const darkThemeMq = getDarkThemeMq();
+ const highContrastMq = getHighContrastMq();
+ const nextResolvedTheme = getResolvedTheme(darkThemeMq, theme);
+ const nextResolvedContrast = getResolvedContrast(highContrastMq, userContrast);
+ updateThemeClass(htmlTagElement, nextResolvedTheme, nextResolvedContrast);
setUserTheme(theme);
- setResolvedTheme(actualTheme);
},
- [htmlTagElement, setUserTheme],
+ [htmlTagElement, setUserTheme, userContrast],
+ );
+
+ const setContrastState = React.useCallback(
+ (contrast: Contrast) => {
+ const darkThemeMq = getDarkThemeMq();
+ const highContrastMq = getHighContrastMq();
+ const nextResolvedTheme = getResolvedTheme(darkThemeMq, userTheme);
+ const nextResolvedContrast = getResolvedContrast(highContrastMq, contrast);
+ updateThemeClass(htmlTagElement, nextResolvedTheme, nextResolvedContrast);
+ setUserContrast(contrast);
+ },
+ [htmlTagElement, setUserContrast, userTheme],
);
return {
userTheme: getTheme(userTheme),
setUserTheme: setThemeState,
resolvedTheme,
+ userContrast: getContrast(userContrast),
+ setUserContrast: setContrastState,
+ resolvedContrast,
};
};
diff --git a/libs/ui-components/src/pages/networking/SecurityGroupsListPage.tsx b/libs/ui-components/src/pages/networking/SecurityGroupsListPage.tsx
index cd98131f..19ad84e9 100644
--- a/libs/ui-components/src/pages/networking/SecurityGroupsListPage.tsx
+++ b/libs/ui-components/src/pages/networking/SecurityGroupsListPage.tsx
@@ -1,6 +1,13 @@
import { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { Button, SearchInput, Stack, StackItem } from '@patternfly/react-core';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import {
+ Button,
+ SearchInput,
+ Toolbar,
+ ToolbarContent,
+ ToolbarGroup,
+ ToolbarItem,
+} from '@patternfly/react-core';
import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import {
@@ -15,15 +22,34 @@ import ListPageBody from '../../components/Page/ListPageBody';
import { SubtleContent } from '../../components/SubtleContent/SubtleContent';
import { useTranslation } from '../../hooks/useTranslation';
+const SEARCH_PARAM = 'search';
+
export const SecurityGroupsListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
- const [search, setSearch] = useState('');
+ const [searchParams, setSearchParams] = useSearchParams();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const search = searchParams.get(SEARCH_PARAM) ?? '';
+
const { data: securityGroups = [], isLoading, error } = useSecurityGroups();
const { data: virtualNetworks = [] } = useVirtualNetworks();
+ const setSearch = (value: string) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (!value.trim()) {
+ next.delete(SEARCH_PARAM);
+ } else {
+ next.set(SEARCH_PARAM, value);
+ }
+ return next;
+ },
+ { replace: true },
+ );
+ };
+
const filteredSGs = securityGroups.filter((sg) => {
const name = sg.metadata?.name ?? '';
return !search || name.toLowerCase().includes(search.toLowerCase());
@@ -41,79 +67,81 @@ export const SecurityGroupsListPage = () => {
}
>
-
-
- setSearch(v)}
- onClear={() => setSearch('')}
- />
-
-
- {filteredSGs.length === 0 ? (
-
- {search
- ? t('No security groups match your search.')
- : t('No security groups yet. Create one to get started.')}
-
- ) : (
-
-
-
- | {t('Name')} |
- {t('Virtual Network')} |
- {t('Inbound Rules')} |
- {t('Outbound Rules')} |
- {t('Status')} |
-
-
-
- {filteredSGs.map((sg) => {
- const name = sg.metadata?.name ?? sg.id;
- const vnId = sg.spec?.virtualNetwork?.id ?? '';
- const vn = virtualNetworks.find((v) => v.id === vnId);
- const vnName = resourceDisplayName(vn?.metadata, vnId);
- const ingressCount = sg.spec?.ingress?.length ?? 0;
- const egressCount = sg.spec?.egress?.length ?? 0;
+
+
+
+
+ setSearch(v)}
+ onClear={() => setSearch('')}
+ />
+
+
+
+
+ {filteredSGs.length === 0 ? (
+
+ {search
+ ? t('No security groups match your search.')
+ : t('No security groups yet. Create one to get started.')}
+
+ ) : (
+
+
+
+ | {t('Name')} |
+ {t('Virtual Network')} |
+ {t('Inbound Rules')} |
+ {t('Outbound Rules')} |
+ {t('Status')} |
+
+
+
+ {filteredSGs.map((sg) => {
+ const name = sg.metadata?.name ?? sg.id;
+ const vnId = sg.spec?.virtualNetwork?.id ?? '';
+ const vn = virtualNetworks.find((v) => v.id === vnId);
+ const vnName = resourceDisplayName(vn?.metadata, vnId);
+ const ingressCount = sg.spec?.ingress?.length ?? 0;
+ const egressCount = sg.spec?.egress?.length ?? 0;
- return (
-
- |
-
- |
-
- {vnId ? (
-
- ) : (
- vnName
- )}
- |
- {ingressCount} |
- {egressCount} |
-
-
- |
-
- );
- })}
-
-
- )}
-
-
+ return (
+
+ |
+
+ |
+
+ {vnId ? (
+
+ ) : (
+ vnName
+ )}
+ |
+ {ingressCount} |
+ {egressCount} |
+
+
+ |
+
+ );
+ })}
+
+
+ )}
diff --git a/libs/ui-components/src/pages/networking/VirtualNetworksListPage.tsx b/libs/ui-components/src/pages/networking/VirtualNetworksListPage.tsx
index 21f71b18..e9cc2183 100644
--- a/libs/ui-components/src/pages/networking/VirtualNetworksListPage.tsx
+++ b/libs/ui-components/src/pages/networking/VirtualNetworksListPage.tsx
@@ -1,6 +1,13 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { Button, SearchInput } from '@patternfly/react-core';
+import {
+ Button,
+ SearchInput,
+ Toolbar,
+ ToolbarContent,
+ ToolbarGroup,
+ ToolbarItem,
+} from '@patternfly/react-core';
import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import { useSubnets, useVirtualNetworks } from '../../api/v1/networking';
@@ -50,13 +57,20 @@ export const VirtualNetworksListPage = () => {
}
>
- setSearch(v)}
- onClear={() => setSearch('')}
- style={{ marginBottom: '1rem' }}
- />
+
+
+
+
+ setSearch(v)}
+ onClear={() => setSearch('')}
+ />
+
+
+
+
{filteredVNs.length === 0 ? (
{search
diff --git a/libs/ui-components/src/pages/tenant/CatalogPage.test.tsx b/libs/ui-components/src/pages/tenant/CatalogPage.test.tsx
index 0cb7f24e..4df10d86 100644
--- a/libs/ui-components/src/pages/tenant/CatalogPage.test.tsx
+++ b/libs/ui-components/src/pages/tenant/CatalogPage.test.tsx
@@ -4,15 +4,19 @@ import { Code, ConnectError, createRouterTransport } from '@connectrpc/connect';
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
-import type { ClusterCatalogItem, ComputeInstanceCatalogItem } from '@osac/types';
import {
+ BareMetalInstanceCatalogItem,
+ BareMetalInstanceCatalogItems,
+ ClusterCatalogItem,
ClusterCatalogItems,
ClusterTemplateReferenceSchema,
+ ComputeInstanceCatalogItem,
ComputeInstanceCatalogItems,
ComputeInstanceTemplateReferenceSchema,
} from '@osac/types';
import CatalogPage from './CatalogPage';
+import { CatalogItemDetailPage } from '../../components/catalog/details/CatalogItemDetailPage.tsx';
import { wrapWithAuthInterceptor } from '../../test-utils/createMockConnectTransport';
import { renderWithProviders } from '../../test-utils/TestProviders';
@@ -50,15 +54,6 @@ const vmCatalogItem: ComputeInstanceCatalogItem = {
],
};
-const unpublishedCatalogItem: ClusterCatalogItem = {
- ...vmCatalogItem,
- $typeName: 'osac.public.v1.ClusterCatalogItem',
- id: 'catalog-unpublished',
- title: 'Unpublished catalog',
- template: create(ClusterTemplateReferenceSchema, { id: 'tpl-rhel-9' }),
- published: false,
-};
-
const clusterCatalogItem: ClusterCatalogItem = {
$typeName: 'osac.public.v1.ClusterCatalogItem',
id: 'catalog-openshift-4',
@@ -86,10 +81,13 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
type CatalogTransportOptions = {
vmItems?: ComputeInstanceCatalogItem[];
clusterItems?: ClusterCatalogItem[];
+ bmItems?: BareMetalInstanceCatalogItem[];
vmError?: Error;
clusterError?: Error;
+ bmError?: Error;
vmDelayMs?: number;
clusterDelayMs?: number;
+ bmDelayMs?: number;
};
const toConnectError = (error: Error) => {
@@ -102,10 +100,13 @@ const toConnectError = (error: Error) => {
const createCatalogPageTransport = ({
vmItems = [vmCatalogItem],
clusterItems = [clusterCatalogItem],
+ bmItems = [],
vmError,
clusterError,
+ bmError,
vmDelayMs = 0,
clusterDelayMs = 0,
+ bmDelayMs = 0,
}: CatalogTransportOptions = {}) =>
wrapWithAuthInterceptor(
createRouterTransport((router) => {
@@ -138,21 +139,38 @@ const createCatalogPageTransport = ({
object: clusterItems.find((i) => i.id === req.id),
}),
});
+
+ router.service(BareMetalInstanceCatalogItems, {
+ list: async () => {
+ if (bmDelayMs) {
+ await delay(bmDelayMs);
+ }
+ if (bmError) {
+ throw toConnectError(bmError);
+ }
+ return { items: bmItems };
+ },
+ get: (req) => ({
+ object: bmItems.find((i) => i.id === req.id),
+ }),
+ });
}),
);
const unauthorizedTransport = createCatalogPageTransport({
vmError: Object.assign(new Error(), { name: 'UnauthorizedError' }),
clusterError: Object.assign(new Error(), { name: 'UnauthorizedError' }),
+ bmError: Object.assign(new Error(), { name: 'UnauthorizedError' }),
});
const renderCatalogPage = (transport = unauthorizedTransport) =>
renderWithProviders(, { transport });
-const renderCatalogPageWithCreateRoutes = (transport = createCatalogPageTransport()) =>
+const renderCatalogPageWithDetailRoutes = (transport = createCatalogPageTransport()) =>
renderWithProviders(
} />
+ } />
Create cluster page} />
Create virtual machine page} />
,
@@ -175,24 +193,23 @@ describe('CatalogPage', () => {
expect(screen.getByRole('textbox', { name: 'Filter catalog by keyword' })).toBeInTheDocument();
});
- it('lets users switch tabs after a 401 on the default VM tab', async () => {
+ it('lets users toggle type filters after a 401', async () => {
const { user } = renderCatalogPage();
await waitFor(() => {
expect(screen.getByText('Unauthorized')).toBeInTheDocument();
});
- await user.click(screen.getByRole('button', { name: 'Clusters' }));
+ await user.click(screen.getByRole('button', { name: /Clusters/ }));
- expect(screen.getByRole('button', { name: 'Clusters' })).toHaveAttribute(
+ expect(screen.getByRole('button', { name: /Clusters/ })).toHaveAttribute(
'aria-pressed',
'true',
);
- expect(screen.getByRole('heading', { name: 'Clusters', level: 2 })).toBeInTheDocument();
expect(screen.getByText('Unauthorized')).toBeInTheDocument();
});
- it('disables search while the active tab query is loading', async () => {
+ it('disables search while catalog queries are loading', async () => {
renderCatalogPage(createCatalogPageTransport({ vmDelayMs: 250, clusterItems: [] }));
const searchInput = screen.getByRole('textbox', { name: 'Filter catalog by keyword' });
@@ -204,7 +221,7 @@ describe('CatalogPage', () => {
expect(searchInput).toBeEnabled();
});
- it('disables search when the active tab query is in error', async () => {
+ it('disables search when a catalog query is in error', async () => {
const { user } = renderCatalogPage();
await waitFor(() => {
@@ -213,62 +230,48 @@ describe('CatalogPage', () => {
expect(screen.getByRole('textbox', { name: 'Filter catalog by keyword' })).toBeDisabled();
- await user.click(screen.getByRole('button', { name: 'Clusters' }));
+ await user.click(screen.getByRole('button', { name: /Clusters/ }));
expect(screen.getByRole('textbox', { name: 'Filter catalog by keyword' })).toBeDisabled();
});
- it('shows VM catalog items on the default tab when the VM query succeeds', async () => {
+ it('shows catalog items for all types by default when queries succeed', async () => {
renderCatalogPage(createCatalogPageTransport());
await waitFor(() => {
expect(screen.getByText(vmCatalogItem.title)).toBeInTheDocument();
+ expect(screen.getByText(clusterCatalogItem.title)).toBeInTheDocument();
});
-
- expect(screen.getByRole('heading', { name: 'Virtual Machines', level: 2 })).toBeInTheDocument();
- expect(screen.queryByText(clusterCatalogItem.title)).not.toBeInTheDocument();
});
- it('shows cluster catalog items after switching to the cluster tab', async () => {
+ it('filters to cluster catalog items when the cluster type toggle is selected', async () => {
const { user } = renderCatalogPage(createCatalogPageTransport());
await waitFor(() => {
expect(screen.getByText(vmCatalogItem.title)).toBeInTheDocument();
+ expect(screen.getByText(clusterCatalogItem.title)).toBeInTheDocument();
});
- await user.click(screen.getByRole('button', { name: 'Clusters' }));
+ // Default auto-selects every type that has items; deselect VMs to leave clusters only.
+ await user.click(screen.getByRole('button', { name: /Virtual Machines/ }));
await waitFor(() => {
expect(screen.getByText(clusterCatalogItem.title)).toBeInTheDocument();
});
- expect(screen.getByRole('heading', { name: 'Clusters', level: 2 })).toBeInTheDocument();
expect(screen.queryByText(vmCatalogItem.title)).not.toBeInTheDocument();
});
- it('shows tab-specific errors without blocking the other tab', async () => {
- const { user } = renderCatalogPage(
+ it('shows an error from any loaded catalog type', async () => {
+ renderCatalogPage(
createCatalogPageTransport({
clusterError: Object.assign(new Error(), { name: 'UnauthorizedError' }),
}),
);
- await waitFor(() => {
- expect(screen.getByText(vmCatalogItem.title)).toBeInTheDocument();
- });
- expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument();
-
- await user.click(screen.getByRole('button', { name: 'Clusters' }));
-
await waitFor(() => {
expect(screen.getByText('Unauthorized')).toBeInTheDocument();
});
+ expect(screen.queryByText(vmCatalogItem.title)).not.toBeInTheDocument();
expect(screen.queryByText(clusterCatalogItem.title)).not.toBeInTheDocument();
-
- await user.click(screen.getByRole('button', { name: 'Virtual Machines' }));
-
- await waitFor(() => {
- expect(screen.getByText(vmCatalogItem.title)).toBeInTheDocument();
- });
- expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument();
});
it('shows a generic section error for non-401 failures', async () => {
@@ -284,10 +287,8 @@ describe('CatalogPage', () => {
expect(screen.getByRole('textbox', { name: 'Filter catalog by keyword' })).toBeDisabled();
});
- it('shows an empty state when no published catalog items are returned', async () => {
- renderCatalogPage(
- createCatalogPageTransport({ vmItems: [], clusterItems: [unpublishedCatalogItem] }),
- );
+ it('shows an empty state when no catalog items are returned', async () => {
+ renderCatalogPage(createCatalogPageTransport({ vmItems: [], clusterItems: [], bmItems: [] }));
await waitFor(() => {
expect(
@@ -298,7 +299,7 @@ describe('CatalogPage', () => {
expect(screen.getByText('No published catalog items are available yet.')).toBeInTheDocument();
});
- it('filters catalog items by the search keyword on the active tab', async () => {
+ it('filters catalog items by the search keyword', async () => {
const secondVmItem: ComputeInstanceCatalogItem = {
...vmCatalogItem,
id: 'catalog-fedora-40',
@@ -319,7 +320,7 @@ describe('CatalogPage', () => {
};
const { user } = renderCatalogPage(
- createCatalogPageTransport({ vmItems: [vmCatalogItem, secondVmItem] }),
+ createCatalogPageTransport({ vmItems: [vmCatalogItem, secondVmItem], clusterItems: [] }),
);
await waitFor(() => {
@@ -336,7 +337,7 @@ describe('CatalogPage', () => {
});
it('shows a search-specific empty state when the filter matches nothing', async () => {
- const { user } = renderCatalogPage(createCatalogPageTransport());
+ const { user } = renderCatalogPage(createCatalogPageTransport({ clusterItems: [] }));
await waitFor(() => {
expect(screen.getByText(vmCatalogItem.title)).toBeInTheDocument();
@@ -352,17 +353,11 @@ describe('CatalogPage', () => {
screen.getByRole('heading', { name: 'No catalog items found', level: 2 }),
).toBeInTheDocument();
});
- expect(screen.getByText('No catalog items match your search.')).toBeInTheDocument();
+ expect(screen.getByText('No catalog items match your filters.')).toBeInTheDocument();
});
- it('navigates to cluster create from the catalog item drawer', async () => {
- const { user } = renderCatalogPageWithCreateRoutes();
-
- await waitFor(() => {
- expect(screen.getByText(vmCatalogItem.title)).toBeInTheDocument();
- });
-
- await user.click(screen.getByRole('button', { name: 'Clusters' }));
+ it('navigates to cluster create from the catalog item detail page', async () => {
+ const { user } = renderCatalogPageWithDetailRoutes();
await waitFor(() => {
expect(screen.getByText(clusterCatalogItem.title)).toBeInTheDocument();
@@ -373,6 +368,12 @@ describe('CatalogPage', () => {
name: `Open catalog item details for ${clusterCatalogItem.title}`,
}),
);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('heading', { name: clusterCatalogItem.title, level: 1 }),
+ ).toBeInTheDocument();
+ });
await user.click(await screen.findByRole('button', { name: 'Create cluster' }));
await waitFor(() => {
@@ -380,8 +381,8 @@ describe('CatalogPage', () => {
});
});
- it('navigates to VM create from the catalog item drawer', async () => {
- const { user } = renderCatalogPageWithCreateRoutes();
+ it('navigates to VM create from the catalog item detail page', async () => {
+ const { user } = renderCatalogPageWithDetailRoutes();
await waitFor(() => {
expect(screen.getByText(vmCatalogItem.title)).toBeInTheDocument();
@@ -392,6 +393,13 @@ describe('CatalogPage', () => {
name: `Open catalog item details for ${vmCatalogItem.title}`,
}),
);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('heading', { name: vmCatalogItem.title, level: 1 }),
+ ).toBeInTheDocument();
+ });
+
await user.click(await screen.findByRole('button', { name: 'Create virtual machine' }));
await waitFor(() => {
diff --git a/libs/ui-components/src/pages/tenant/CatalogPage.tsx b/libs/ui-components/src/pages/tenant/CatalogPage.tsx
index a817fa7e..8c048e55 100644
--- a/libs/ui-components/src/pages/tenant/CatalogPage.tsx
+++ b/libs/ui-components/src/pages/tenant/CatalogPage.tsx
@@ -1,71 +1,168 @@
-import { useMemo, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useEffect, useMemo, useRef } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
import {
- Button,
EmptyState,
EmptyStateBody,
Flex,
FlexItem,
+ Label,
SearchInput,
Stack,
StackItem,
ToggleGroup,
ToggleGroupItem,
} from '@patternfly/react-core';
-import type { TFunction } from 'i18next';
import { useBareMetalInstanceCatalogItems } from '@osac/ui-components/api/v1/baremetal-instance';
import { useClusterCatalogItems } from '@osac/ui-components/api/v1/cluster-catalog-item';
import { useComputeInstanceCatalogItems } from '@osac/ui-components/api/v1/compute-instance-catalog-item';
-import { CatalogItemDetailDrawer } from '@osac/ui-components/components/catalog/CatalogItemDetailDrawer';
-import type {
+import {
CatalogItem,
CatalogItemKind,
+ CatalogItemWithType,
+ filterCatalogItemsBySearch,
+ filterCatalogItemsByTypes,
} from '@osac/ui-components/components/catalog/catalogItemDisplay';
-import { filterCatalogItemsBySearch } from '@osac/ui-components/components/catalog/catalogItemDisplay';
import { CatalogItemListSection } from '@osac/ui-components/components/catalog/CatalogItemListSection';
import ListPage from '@osac/ui-components/components/Page/ListPage';
import { useTranslation } from '@osac/ui-components/hooks/useTranslation';
-type CatalogTypeFilter = 'vm' | 'cluster' | 'bm';
-
-interface SelectedCatalogItem {
- kind: CatalogItemKind;
- item: CatalogItem;
-}
-
-const getTypeLabel = (typeFilter: CatalogTypeFilter, t: TFunction) => {
- switch (typeFilter) {
- case 'vm':
- return t('Virtual Machines');
- case 'bm':
- return t('Bare Metal Machines');
- default:
- return t('Clusters');
+type CatalogTypeFilter = CatalogItemKind;
+
+const TYPE_FILTER_PARAM = 'types';
+const SEARCH_PARAM = 'search';
+const CATALOG_TYPE_FILTER_VALUES: readonly CatalogTypeFilter[] = ['vm', 'cluster', 'bm'];
+
+const isCatalogTypeFilter = (value: string): value is CatalogTypeFilter =>
+ value === 'vm' || value === 'cluster' || value === 'bm';
+
+const parseTypeFilters = (searchParams: URLSearchParams): CatalogTypeFilter[] => {
+ const raw = searchParams.get(TYPE_FILTER_PARAM);
+ if (!raw) {
+ return [];
+ }
+ const seen = new Set();
+ const filters: CatalogTypeFilter[] = [];
+ for (const value of raw.split(',')) {
+ const trimmed = value.trim();
+ if (isCatalogTypeFilter(trimmed) && !seen.has(trimmed)) {
+ seen.add(trimmed);
+ filters.push(trimmed);
+ }
}
+ return filters;
};
-const useCatalogItems = (typeFilter: CatalogTypeFilter) => {
- const vms = useComputeInstanceCatalogItems(undefined, typeFilter === 'vm');
- const clusters = useClusterCatalogItems(undefined, typeFilter === 'cluster');
- const bms = useBareMetalInstanceCatalogItems(typeFilter === 'bm');
-
- switch (typeFilter) {
- case 'vm':
- return vms;
- case 'bm':
- return bms;
- default:
- return clusters;
+const serializeTypeFilters = (filters: CatalogTypeFilter[]): string | null =>
+ filters.length > 0 ? filters.join(',') : null;
+
+const parseSearch = (searchParams: URLSearchParams): string => searchParams.get(SEARCH_PARAM) ?? '';
+
+const typesWithItems = (items: CatalogItemWithType[]): CatalogTypeFilter[] =>
+ CATALOG_TYPE_FILTER_VALUES.filter((type) => items.some((item) => item.type === type));
+
+const mapToItemWithType = (
+ items: CatalogItem[] | undefined,
+ itemType: CatalogTypeFilter,
+): CatalogItemWithType[] => {
+ if (!items || !items.length) {
+ return [];
}
+ return items.map((item: CatalogItem) => ({ ...item, type: itemType }));
+};
+
+const useCatalogItems = () => {
+ const vms = useComputeInstanceCatalogItems(undefined);
+ const clusters = useClusterCatalogItems(undefined);
+ const bms = useBareMetalInstanceCatalogItems();
+
+ const isLoading = vms.isLoading || clusters.isLoading || bms.isLoading;
+ const error = vms.error || clusters.error || bms.error;
+
+ const data: CatalogItemWithType[] = useMemo(() => {
+ if (error || isLoading) {
+ return [];
+ }
+ return [
+ ...mapToItemWithType(vms.data, 'vm'),
+ ...mapToItemWithType(clusters.data, 'cluster'),
+ ...mapToItemWithType(bms.data, 'bm'),
+ ];
+ }, [isLoading, error, vms.data, clusters.data, bms.data]);
+
+ return { error, isLoading, data };
};
const CatalogPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
- const [search, setSearch] = useState('');
- const [typeFilter, setTypeFilter] = useState('vm');
- const [selectedCatalogItem, setSelectedCatalogItem] = useState();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const hasInitializedTypeFilters = useRef(false);
+
+ const typeFilters = useMemo(() => parseTypeFilters(searchParams), [searchParams]);
+ const search = useMemo(() => parseSearch(searchParams), [searchParams]);
+ const { data = [], isLoading, error } = useCatalogItems();
+
+ useEffect(() => {
+ if (hasInitializedTypeFilters.current || isLoading || error) {
+ return;
+ }
+ hasInitializedTypeFilters.current = true;
+
+ if (searchParams.has(TYPE_FILTER_PARAM)) {
+ return;
+ }
+
+ const serialized = serializeTypeFilters(typesWithItems(data));
+ if (!serialized) {
+ return;
+ }
+
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ next.set(TYPE_FILTER_PARAM, serialized);
+ return next;
+ },
+ { replace: true },
+ );
+ }, [data, error, isLoading, searchParams, setSearchParams]);
+
+ const toggleTypeFilter = (value: CatalogTypeFilter) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ const current = parseTypeFilters(next);
+ const updated = current.includes(value)
+ ? current.filter((option) => option !== value)
+ : [...current, value];
+ const serialized = serializeTypeFilters(updated);
+ if (serialized) {
+ next.set(TYPE_FILTER_PARAM, serialized);
+ } else {
+ next.set(TYPE_FILTER_PARAM, '');
+ }
+ return next;
+ },
+ { replace: true },
+ );
+ };
+
+ const setSearch = (value: string) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ const trimmed = value.trim();
+ if (!trimmed) {
+ next.delete(SEARCH_PARAM);
+ } else {
+ next.set(SEARCH_PARAM, value);
+ }
+ return next;
+ },
+ { replace: true },
+ );
+ };
const catalogTypeFilters = useMemo>(
() => [
@@ -76,115 +173,102 @@ const CatalogPage = () => {
[t],
);
- const { data = [], isLoading, error } = useCatalogItems(typeFilter);
+ const typeCounts = useMemo(
+ () => ({
+ vm: data.filter((d) => d.type === 'vm').length,
+ cluster: data.filter((d) => d.type === 'cluster').length,
+ bm: data.filter((d) => d.type === 'bm').length,
+ }),
+ [data],
+ );
- const filteredItems = useMemo(() => filterCatalogItemsBySearch(data, search), [search, data]);
+ const filteredItems = useMemo(
+ () =>
+ filterCatalogItemsBySearch(
+ filterCatalogItemsByTypes(data, typeFilters),
+ search,
+ ) as CatalogItemWithType[],
+ [search, data, typeFilters],
+ );
- const searchTerm = search.trim();
const showEmptyState = !isLoading && !error && filteredItems.length === 0;
const pageDescription = t(
'Browse catalog items and launch virtual machines, clusters, or bare metal machines from published offerings.',
);
- const handleTypeFilterChange = (value: CatalogTypeFilter) => {
- setTypeFilter(value);
- setSelectedCatalogItem(undefined);
- };
-
- const catalogCreateAction = useMemo(() => {
- if (!selectedCatalogItem) {
- return null;
- }
- if (selectedCatalogItem.kind === 'vm') {
- return {
- label: t('Create virtual machine'),
- path: `/vms/create/${selectedCatalogItem.item.id}`,
- };
- }
- if (selectedCatalogItem.kind === 'cluster') {
- return {
- label: t('Create cluster'),
- path: `/clusters/create/${selectedCatalogItem.item.id}`,
- };
- }
- if (selectedCatalogItem.kind === 'bm') {
- return {
- label: t('Provision bare metal'),
- path: `/bare-metal/create/${selectedCatalogItem.item.id}`,
- };
- }
- return null;
- }, [selectedCatalogItem, t]);
-
return (
- setSelectedCatalogItem(undefined)}
- actions={
- catalogCreateAction ? (
-
- ) : null
- }
- >
-
-
-
-
- setSearch(value)}
- onClear={() => setSearch('')}
- aria-label={t('Filter catalog by keyword')}
- isDisabled={isLoading || !!error}
- />
-
-
-
- {catalogTypeFilters.map((option) => (
- handleTypeFilterChange(option.value)}
- />
- ))}
-
-
-
-
+
+
+
+
+
+ {catalogTypeFilters.map((option) => (
+
+ {option.label}
+
+
+
+
+ }
+ buttonId={`catalog-type-filter-${option.value}`}
+ isSelected={typeFilters.includes(option.value)}
+ onChange={() => toggleTypeFilter(option.value)}
+ />
+ ))}
+
+
+
+ setSearch(value)}
+ onClear={() => setSearch('')}
+ aria-label={t('Filter catalog by keyword')}
+ isDisabled={isLoading || !!error}
+ />
+
+
+
- {showEmptyState ? (
-
+ {showEmptyState ? (
+
+ {typeFilters.length === 0 && data.length > 0 ? (
+
+
+ {t('Choose one or more services above to filter the catalog.')}
+
+
+ ) : (
- {searchTerm
- ? t('No catalog items match your search.')
+ {data?.length
+ ? t('No catalog items match your filters.')
: t('No published catalog items are available yet.')}
-
- ) : (
- setSelectedCatalogItem({ kind: typeFilter, item })}
- />
- )}
-
-
+ )}
+
+ ) : (
+ navigate(`/catalog/${item.type}/${item.id}`)}
+ />
+ )}
+
);
};
diff --git a/libs/ui-components/src/pages/tenant/VmListPage.css b/libs/ui-components/src/pages/tenant/VmListPage.css
deleted file mode 100644
index 027f4415..00000000
--- a/libs/ui-components/src/pages/tenant/VmListPage.css
+++ /dev/null
@@ -1,23 +0,0 @@
-.osac-vm-list__divider {
- margin-bottom: var(--pf-t--global--spacer--md);
-}
-
-.osac-vm-list__toolbar {
- margin-bottom: var(--pf-t--global--spacer--md);
-}
-
-.osac-vm-list__search {
- min-width: 220px;
-}
-
-.osac-vm-list__status-toggle {
- flex-wrap: wrap;
-}
-
-.osac-vm-list__loading {
- padding: var(--pf-t--global--spacer--2xl);
-}
-
-.osac-vm-list__empty {
- margin: 0;
-}
diff --git a/libs/ui-components/src/pages/tenant/VmListPage.tsx b/libs/ui-components/src/pages/tenant/VmListPage.tsx
index 708023bb..e5a8610b 100644
--- a/libs/ui-components/src/pages/tenant/VmListPage.tsx
+++ b/libs/ui-components/src/pages/tenant/VmListPage.tsx
@@ -1,10 +1,11 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Flex,
FlexItem,
+ Label,
SearchInput,
Stack,
StackItem,
@@ -12,7 +13,7 @@ import {
ToggleGroupItem,
} from '@patternfly/react-core';
-import { ComputeInstanceState } from '@osac/types';
+import { ComputeInstance, ComputeInstanceState } from '@osac/types';
import { useComputeInstances } from '@osac/ui-components/api/v1/compute-instance';
import { useInstanceTypes } from '@osac/ui-components/api/v1/instance-types';
import ListPage from '@osac/ui-components/components/Page/ListPage';
@@ -22,32 +23,56 @@ import { VmTable } from '@osac/ui-components/components/vm/VmTable';
import { useTranslation } from '@osac/ui-components/hooks/useTranslation';
import { getErrorMessage } from '@osac/ui-components/utils/error';
-import './VmListPage.css';
+type VmStatusFilter = 'running' | 'stopped';
-const POWER_FILTERS = [
- { value: 'all', label: 'All' },
- { value: 'running', label: 'Running' },
- { value: 'stopped', label: 'Stopped' },
-] as const;
+const STATUS_FILTER_PARAM = 'status';
+const SEARCH_PARAM = 'search';
+const VM_STATUS_FILTER_VALUES: readonly VmStatusFilter[] = ['running', 'stopped'];
-type VmPowerFilter = (typeof POWER_FILTERS)[number]['value'];
+const isVmStatusFilter = (value: string): value is VmStatusFilter =>
+ value === 'running' || value === 'stopped';
-const normalizePowerFilter = (value: string | null): VmPowerFilter => {
- if (!value) {
- return 'all';
+const parseStatusFilters = (searchParams: URLSearchParams): VmStatusFilter[] => {
+ const raw = searchParams.get(STATUS_FILTER_PARAM);
+ if (!raw) {
+ return [];
}
- return POWER_FILTERS.some((option) => option.value === value) ? (value as VmPowerFilter) : 'all';
+ const seen = new Set();
+ const filters: VmStatusFilter[] = [];
+ for (const value of raw.split(',')) {
+ const trimmed = value.trim();
+ if (isVmStatusFilter(trimmed) && !seen.has(trimmed)) {
+ seen.add(trimmed);
+ filters.push(trimmed);
+ }
+ }
+ return filters;
+};
+
+const serializeStatusFilters = (filters: VmStatusFilter[]): string | null =>
+ filters.length > 0 ? filters.join(',') : null;
+
+const parseSearch = (searchParams: URLSearchParams): string => searchParams.get(SEARCH_PARAM) ?? '';
+
+const vmMatchesStatusFilter = (vm: ComputeInstance, filter: VmStatusFilter): boolean => {
+ const state = vm.status?.state;
+ if (filter === 'running') {
+ return state === ComputeInstanceState.RUNNING;
+ }
+ return state === ComputeInstanceState.STOPPED;
};
+const statusesWithItems = (vms: ComputeInstance[]): VmStatusFilter[] =>
+ VM_STATUS_FILTER_VALUES.filter((status) => vms.some((vm) => vmMatchesStatusFilter(vm, status)));
+
export const VmListPage = () => {
const navigate = useNavigate();
const { t } = useTranslation();
- const [searchParams] = useSearchParams();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const hasInitializedStatusFilters = useRef(false);
- const [search, setSearch] = useState('');
- const [powerFilter, setPowerFilter] = useState(() =>
- normalizePowerFilter(searchParams.get('power')),
- );
+ const statusFilters = useMemo(() => parseStatusFilters(searchParams), [searchParams]);
+ const search = useMemo(() => parseSearch(searchParams), [searchParams]);
const { data: vms = [], isLoading, error } = useComputeInstances();
const {
@@ -56,27 +81,106 @@ export const VmListPage = () => {
error: instanceTypesError,
} = useInstanceTypes();
+ useEffect(() => {
+ if (hasInitializedStatusFilters.current || isLoading || error) {
+ return;
+ }
+ hasInitializedStatusFilters.current = true;
+
+ if (searchParams.has(STATUS_FILTER_PARAM)) {
+ return;
+ }
+
+ const serialized = serializeStatusFilters(statusesWithItems(vms));
+ if (!serialized) {
+ return;
+ }
+
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ next.set(STATUS_FILTER_PARAM, serialized);
+ return next;
+ },
+ { replace: true },
+ );
+ }, [error, isLoading, searchParams, setSearchParams, vms]);
+
+ const toggleStatusFilter = (value: VmStatusFilter) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ const current = parseStatusFilters(next);
+ const updated = current.includes(value)
+ ? current.filter((option) => option !== value)
+ : [...current, value];
+ const serialized = serializeStatusFilters(updated);
+ if (serialized) {
+ next.set(STATUS_FILTER_PARAM, serialized);
+ } else {
+ next.set(STATUS_FILTER_PARAM, '');
+ }
+ return next;
+ },
+ { replace: true },
+ );
+ };
+
+ const setSearch = (value: string) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ const trimmed = value.trim();
+ if (!trimmed) {
+ next.delete(SEARCH_PARAM);
+ } else {
+ next.set(SEARCH_PARAM, value);
+ }
+ return next;
+ },
+ { replace: true },
+ );
+ };
+
+ const statusFilterOptions = useMemo>(
+ () => [
+ { value: 'running', label: t('Running') },
+ { value: 'stopped', label: t('Stopped') },
+ ],
+ [t],
+ );
+
+ const statusCounts = useMemo(
+ () => ({
+ running: vms.filter((vm) => vmMatchesStatusFilter(vm, 'running')).length,
+ stopped: vms.filter((vm) => vmMatchesStatusFilter(vm, 'stopped')).length,
+ }),
+ [vms],
+ );
+
const filteredVms = useMemo(() => {
+ if (statusFilters.length === 0) {
+ return [];
+ }
+ const searchTerm = search.trim().toLowerCase();
return vms.filter((vm) => {
const name = vm.metadata?.name ?? '';
- const matchesSearch = !search || name.toLowerCase().includes(search.toLowerCase());
- const state = vm.status?.state;
- const matchesPower =
- powerFilter === 'all' ||
- (powerFilter === 'running' && state === ComputeInstanceState.RUNNING) ||
- (powerFilter === 'stopped' && state === ComputeInstanceState.STOPPED);
- return matchesSearch && matchesPower;
+ const matchesSearch = !searchTerm || name.toLowerCase().includes(searchTerm);
+ const matchesStatus = statusFilters.some((filter) => vmMatchesStatusFilter(vm, filter));
+ return matchesSearch && matchesStatus;
});
- }, [powerFilter, search, vms]);
+ }, [search, statusFilters, vms]);
+
+ const showEmptyState = !isLoading && !error && filteredVms.length === 0;
return (
navigate('/vms/create')}>
- Create virtual machine
+ {t('Create virtual machine')}
}
>
@@ -87,33 +191,40 @@ export const VmListPage = () => {
spaceItems={{ default: 'spaceItemsSm' }}
alignItems={{ default: 'alignItemsCenter' }}
flexWrap={{ default: 'wrap' }}
- className="osac-vm-list__toolbar"
>
- setSearch(v)}
- onClear={() => setSearch('')}
- className="osac-vm-list__search"
- />
-
-
-
- {POWER_FILTERS.map((option) => (
+
+ {statusFilterOptions.map((option) => (
+ {option.label}
+
+
+
+
+ }
buttonId={`vm-filter-status-${option.value}`}
- isSelected={powerFilter === option.value}
- onChange={() => setPowerFilter(option.value)}
+ isSelected={statusFilters.includes(option.value)}
+ onChange={() => toggleStatusFilter(option.value)}
/>
))}
+
+ setSearch(value)}
+ onClear={() => setSearch('')}
+ aria-label={t('Filter virtual machines by name')}
+ isDisabled={isLoading || !!error}
+ />
+
{instanceTypesError ? (
@@ -124,11 +235,13 @@ export const VmListPage = () => {
) : null}
- {filteredVms.length === 0 ? (
-
- {search || powerFilter !== 'all'
- ? 'No virtual machines match your filters.'
- : 'No virtual machines yet. Create one to get started.'}
+ {showEmptyState ? (
+
+ {statusFilters.length === 0
+ ? t('Choose one or more statuses above to filter virtual machines.')
+ : vms?.length
+ ? t('No virtual machines match your filters.')
+ : t('No virtual machines yet. Create one to get started.')}
) : (
=20'}
@@ -1132,6 +1211,9 @@ packages:
'@swc/types@0.1.27':
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
+ '@tanem/svg-injector@10.1.68':
+ resolution: {integrity: sha512-UkJajeR44u73ujtr5GVSbIlELDWD/mzjqWe54YMK61ljKxFcJoPd9RBSaO7xj02ISCWUqJW99GjrS+sVF0UnrA==}
+
'@tanstack/query-core@5.99.2':
resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==}
@@ -1228,6 +1310,9 @@ packages:
'@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
+ '@types/prop-types@15.7.15':
+ resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1565,6 +1650,10 @@ packages:
engines: {node: '>=22'}
hasBin: true
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -2778,6 +2867,12 @@ packages:
react-dom:
optional: true
+ react-svg@16.4.2:
+ resolution: {integrity: sha512-S33tu0R30U9p+hwMoDi6RxfRW0aC6Se5ntGCpvVfVe02FHbS2THLTKgpTaKP0oIKQnJMqEvh/NLTWuNA4Nbp6w==}
+ peerDependencies:
+ react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react@19.2.5:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
@@ -4037,6 +4132,8 @@ snapshots:
'@patternfly/patternfly@6.4.0': {}
+ '@patternfly/patternfly@6.6.1': {}
+
'@patternfly/react-charts@8.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(victory-area@37.3.6(react@19.2.5))(victory-axis@37.3.6(react@19.2.5))(victory-bar@37.3.6(react@19.2.5))(victory-box-plot@37.3.6(react@19.2.5))(victory-chart@37.3.6(react@19.2.5))(victory-core@37.3.6(react@19.2.5))(victory-create-container@37.3.6(react@19.2.5))(victory-cursor-container@37.3.6(react@19.2.5))(victory-group@37.3.6(react@19.2.5))(victory-legend@37.3.6(react@19.2.5))(victory-line@37.3.6(react@19.2.5))(victory-pie@37.3.6(react@19.2.5))(victory-scatter@37.3.6(react@19.2.5))(victory-stack@37.3.6(react@19.2.5))(victory-tooltip@37.3.6(react@19.2.5))(victory-voronoi-container@37.3.6(react@19.2.5))(victory-zoom-container@37.3.6(react@19.2.5))':
dependencies:
'@patternfly/react-styles': 6.4.0
@@ -4065,9 +4162,37 @@ snapshots:
victory-voronoi-container: 37.3.6(react@19.2.5)
victory-zoom-container: 37.3.6(react@19.2.5)
+ '@patternfly/react-charts@8.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(victory-area@37.3.6(react@19.2.5))(victory-axis@37.3.6(react@19.2.5))(victory-bar@37.3.6(react@19.2.5))(victory-box-plot@37.3.6(react@19.2.5))(victory-chart@37.3.6(react@19.2.5))(victory-core@37.3.6(react@19.2.5))(victory-create-container@37.3.6(react@19.2.5))(victory-cursor-container@37.3.6(react@19.2.5))(victory-group@37.3.6(react@19.2.5))(victory-legend@37.3.6(react@19.2.5))(victory-line@37.3.6(react@19.2.5))(victory-pie@37.3.6(react@19.2.5))(victory-scatter@37.3.6(react@19.2.5))(victory-stack@37.3.6(react@19.2.5))(victory-tooltip@37.3.6(react@19.2.5))(victory-voronoi-container@37.3.6(react@19.2.5))(victory-zoom-container@37.3.6(react@19.2.5))':
+ dependencies:
+ '@patternfly/react-styles': 6.6.1
+ '@patternfly/react-tokens': 6.6.1
+ hoist-non-react-statics: 3.3.2
+ lodash: 4.18.1
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ tslib: 2.8.1
+ optionalDependencies:
+ victory-area: 37.3.6(react@19.2.5)
+ victory-axis: 37.3.6(react@19.2.5)
+ victory-bar: 37.3.6(react@19.2.5)
+ victory-box-plot: 37.3.6(react@19.2.5)
+ victory-chart: 37.3.6(react@19.2.5)
+ victory-core: 37.3.6(react@19.2.5)
+ victory-create-container: 37.3.6(react@19.2.5)
+ victory-cursor-container: 37.3.6(react@19.2.5)
+ victory-group: 37.3.6(react@19.2.5)
+ victory-legend: 37.3.6(react@19.2.5)
+ victory-line: 37.3.6(react@19.2.5)
+ victory-pie: 37.3.6(react@19.2.5)
+ victory-scatter: 37.3.6(react@19.2.5)
+ victory-stack: 37.3.6(react@19.2.5)
+ victory-tooltip: 37.3.6(react@19.2.5)
+ victory-voronoi-container: 37.3.6(react@19.2.5)
+ victory-zoom-container: 37.3.6(react@19.2.5)
+
'@patternfly/react-core@6.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@patternfly/react-icons': 6.4.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@patternfly/react-icons': 6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@patternfly/react-styles': 6.4.0
'@patternfly/react-tokens': 6.4.0
focus-trap: 7.6.4
@@ -4076,11 +4201,11 @@ snapshots:
react-dropzone: 14.4.1(react@19.2.5)
tslib: 2.8.1
- '@patternfly/react-core@6.5.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@patternfly/react-core@6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@patternfly/react-icons': 6.5.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@patternfly/react-styles': 6.5.1
- '@patternfly/react-tokens': 6.5.1
+ '@patternfly/react-icons': 6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@patternfly/react-styles': 6.6.1
+ '@patternfly/react-tokens': 6.6.1
focus-trap: 7.6.6
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
@@ -4092,19 +4217,22 @@ snapshots:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
- '@patternfly/react-icons@6.5.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@patternfly/react-icons@6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
+ tslib: 2.8.1
'@patternfly/react-styles@6.4.0': {}
'@patternfly/react-styles@6.5.1': {}
+ '@patternfly/react-styles@6.6.1': {}
+
'@patternfly/react-table@6.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@patternfly/react-core': 6.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@patternfly/react-icons': 6.4.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@patternfly/react-core': 6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@patternfly/react-icons': 6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@patternfly/react-styles': 6.4.0
'@patternfly/react-tokens': 6.4.0
lodash: 4.18.1
@@ -4112,10 +4240,21 @@ snapshots:
react-dom: 19.2.5(react@19.2.5)
tslib: 2.8.1
+ '@patternfly/react-table@6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@patternfly/react-core': 6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@patternfly/react-icons': 6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@patternfly/react-styles': 6.6.1
+ '@patternfly/react-tokens': 6.6.1
+ lodash: 4.18.1
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ tslib: 2.8.1
+
'@patternfly/react-templates@6.5.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@patternfly/react-core': 6.5.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@patternfly/react-icons': 6.5.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@patternfly/react-core': 6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@patternfly/react-icons': 6.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@patternfly/react-styles': 6.5.1
'@patternfly/react-tokens': 6.5.1
react: 19.2.5
@@ -4126,6 +4265,8 @@ snapshots:
'@patternfly/react-tokens@6.5.1': {}
+ '@patternfly/react-tokens@6.6.1': {}
+
'@playwright/test@1.62.0':
dependencies:
playwright: 1.62.0
@@ -4254,6 +4395,12 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
+ '@tanem/svg-injector@10.1.68':
+ dependencies:
+ '@babel/runtime': 7.29.2
+ content-type: 1.0.5
+ tslib: 2.8.1
+
'@tanstack/query-core@5.99.2': {}
'@tanstack/react-query@5.99.2(react@19.2.5)':
@@ -4353,6 +4500,8 @@ snapshots:
undici-types: 7.16.0
optional: true
+ '@types/prop-types@15.7.15': {}
+
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -4766,6 +4915,8 @@ snapshots:
tree-kill: 1.2.2
yargs: 18.0.0
+ content-type@1.0.5: {}
+
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
@@ -6101,6 +6252,15 @@ snapshots:
optionalDependencies:
react-dom: 19.2.5(react@19.2.5)
+ react-svg@16.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@tanem/svg-injector': 10.1.68
+ '@types/prop-types': 15.7.15
+ prop-types: 15.8.1
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+
react@19.2.5: {}
react@19.2.7: {}