From ed02d19a619f24cd4ce60cbf06cabc252fceb159 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:55:06 +0000 Subject: [PATCH] feat(#4028): add multi-version list to VersionListCard Replace the single-badge rendering in VersionListCard with a full version list that queries sibling version entities from the catalog. The card now uses catalogApiRef.getEntities() to find all entities sharing the same rhdh.io/ai-asset-name annotation. Results are sorted by version (semver-aware, newest first) and rendered as a list with: - "current" label on the version matching the viewed entity - "recommended" badge for rhdh.io/ai-asset-recommended entities - Navigation links on non-current versions via entityHref() - Loading skeleton while the catalog query is in flight - Graceful fallback to single-version display on fetch error Also adds: - i18n keys for versionRecommended and versionNavigate - A second fixture entity (code-review-skill-v1 at 1.0.0) for the dev app to exercise multi-version behavior - Unit tests covering single version, multiple versions, recommended badge, no-version, loading state, and nav links Closes #4028 --- .../boost/fixtures/ai-catalog-fixtures.yaml | 29 +++ .../catalog/entity/VersionListCard.test.tsx | 202 ++++++++++++++++++ .../catalog/entity/VersionListCard.tsx | 149 ++++++++++++- .../plugins/boost/src/translations/ref.ts | 2 + 4 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 workspaces/boost/plugins/boost/src/components/catalog/entity/VersionListCard.test.tsx diff --git a/workspaces/boost/fixtures/ai-catalog-fixtures.yaml b/workspaces/boost/fixtures/ai-catalog-fixtures.yaml index f6f6e80923b..e32a367cddd 100644 --- a/workspaces/boost/fixtures/ai-catalog-fixtures.yaml +++ b/workspaces/boost/fixtures/ai-catalog-fixtures.yaml @@ -69,7 +69,9 @@ metadata: annotations: rhdh.io/ai-asset-category: skill rhdh.io/ai-asset-version: '1.2.0' + rhdh.io/ai-asset-name: code-review-skill rhdh.io/ai-asset-source: github + rhdh.io/ai-asset-recommended: 'true' backstage.io/techdocs-ref: dir:. links: - url: https://github.com/example/code-review-skill @@ -88,6 +90,33 @@ spec: type: git target: https://github.com/example/code-review-skill +--- +# --------------------------------------------------------------------------- +# AiResource / skill (second version of code-review-skill) +# --------------------------------------------------------------------------- +apiVersion: backstage.io/v1alpha1 +kind: AiResource +metadata: + name: code-review-skill-v1 + title: Code Review Skill (Legacy) + description: Automated code review skill (legacy version). + tags: + - code-review + - security + - quality + annotations: + rhdh.io/ai-asset-category: skill + rhdh.io/ai-asset-version: '1.0.0' + rhdh.io/ai-asset-name: code-review-skill + rhdh.io/ai-asset-source: github +spec: + type: skill + lifecycle: production + owner: team-ai-platform + location: + type: git + target: https://github.com/example/code-review-skill + --- # --------------------------------------------------------------------------- # AiResource / rule diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/VersionListCard.test.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/VersionListCard.test.tsx new file mode 100644 index 00000000000..8871a714007 --- /dev/null +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/VersionListCard.test.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import type { CatalogApi } from '@backstage/plugin-catalog-react'; +import { catalogApiRef, EntityProvider } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen, waitFor } from '@testing-library/react'; + +import { VersionListCard } from './VersionListCard'; + +const currentEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'code-review-skill', + namespace: 'default', + uid: 'uid-v120', + annotations: { + 'rhdh.io/ai-asset-version': '1.2.0', + 'rhdh.io/ai-asset-name': 'code-review-skill', + }, + }, + spec: { + type: 'skill', + lifecycle: 'production', + owner: 'team-ai-platform', + }, +}; + +const olderSibling: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'code-review-skill-v1', + namespace: 'default', + uid: 'uid-v100', + annotations: { + 'rhdh.io/ai-asset-version': '1.0.0', + 'rhdh.io/ai-asset-name': 'code-review-skill', + }, + }, + spec: { + type: 'skill', + lifecycle: 'production', + owner: 'team-ai-platform', + }, +}; + +const middleSibling: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'code-review-skill-v11', + namespace: 'default', + uid: 'uid-v110', + annotations: { + 'rhdh.io/ai-asset-version': '1.1.0', + 'rhdh.io/ai-asset-name': 'code-review-skill', + 'rhdh.io/ai-asset-recommended': 'true', + }, + }, + spec: { + type: 'skill', + lifecycle: 'production', + owner: 'team-ai-platform', + }, +}; + +const noVersionEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'no-version-asset', + namespace: 'default', + uid: 'uid-noversion', + }, + spec: { + type: 'skill', + lifecycle: 'production', + owner: 'team-ai-platform', + }, +}; + +const mockCatalogApi: Pick, 'getEntities'> = { + getEntities: jest.fn(), +}; + +function renderWithEntity(entity: Entity) { + return renderInTestApp( + + + + + , + ); +} + +describe('VersionListCard', () => { + beforeEach(() => { + mockCatalogApi.getEntities.mockReset(); + }); + + it('returns null when entity has no version annotation', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ items: [] }); + const { container } = await renderWithEntity(noVersionEntity); + + expect(container.innerHTML).toBe(''); + }); + + it('renders single version with current label when only one version exists', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [currentEntity], + }); + + await renderWithEntity(currentEntity); + + await waitFor(() => { + expect(screen.getByText('1.2.0')).toBeInTheDocument(); + }); + expect(screen.getByText('current')).toBeInTheDocument(); + expect(screen.queryByRole('link')).toBeNull(); + }); + + it('renders all versions with current marked and navigation for others', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [olderSibling, currentEntity, middleSibling], + }); + + await renderWithEntity(currentEntity); + + await waitFor(() => { + expect(screen.getByText('1.2.0')).toBeInTheDocument(); + }); + expect(screen.getByText('1.1.0')).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + expect(screen.getByText('current')).toBeInTheDocument(); + + // Non-current versions should have navigation links + const links = screen.getAllByRole('link'); + expect(links.length).toBe(2); + + // Check that the navigation links point to correct entity pages + const hrefs = links.map(link => link.getAttribute('href')); + expect(hrefs).toContain( + '/catalog/default/airesource/code-review-skill-v11', + ); + expect(hrefs).toContain('/catalog/default/airesource/code-review-skill-v1'); + }); + + it('renders recommended badge for recommended sibling', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [currentEntity, middleSibling], + }); + + await renderWithEntity(currentEntity); + + await waitFor(() => { + expect(screen.getByText('recommended')).toBeInTheDocument(); + }); + }); + + it('shows loading state while fetching siblings', async () => { + mockCatalogApi.getEntities.mockReturnValue(new Promise(() => {})); + + await renderWithEntity(currentEntity); + + // Title should render immediately + expect(screen.getByText('Versions')).toBeInTheDocument(); + // Version badges should not be visible yet (loading skeleton shown) + expect(screen.queryByText('1.2.0')).toBeNull(); + }); + + it('renders navigation link with correct aria-label', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [currentEntity, olderSibling], + }); + + await renderWithEntity(currentEntity); + + await waitFor(() => { + expect( + screen.getByLabelText('Navigate to version 1.0.0'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/workspaces/boost/plugins/boost/src/components/catalog/entity/VersionListCard.tsx b/workspaces/boost/plugins/boost/src/components/catalog/entity/VersionListCard.tsx index 4e5653f58cf..5cf4e622c5f 100644 --- a/workspaces/boost/plugins/boost/src/components/catalog/entity/VersionListCard.tsx +++ b/workspaces/boost/plugins/boost/src/components/catalog/entity/VersionListCard.tsx @@ -14,22 +14,118 @@ * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog-react'; -import { Badge, Card, CardBody, CardHeader, Flex, Text } from '@backstage/ui'; +import { useEffect, useState } from 'react'; +import type { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef, useEntity } from '@backstage/plugin-catalog-react'; +import { + Badge, + Card, + CardBody, + CardHeader, + Flex, + Link, + Skeleton, + Text, +} from '@backstage/ui'; import { useTranslation } from '../../../hooks/useTranslation'; +import { entityHref } from '../../../utils/entityHelpers'; + +function getVersion(e: Entity): string { + return e.metadata.annotations?.['rhdh.io/ai-asset-version'] ?? ''; +} + +function getAssetName(e: Entity): string { + return e.metadata.annotations?.['rhdh.io/ai-asset-name'] ?? e.metadata.name; +} + +function isRecommended(e: Entity): boolean { + return e.metadata.annotations?.['rhdh.io/ai-asset-recommended'] === 'true'; +} + +/** + * Compare two version strings with semver-aware sorting. + * Falls back to lexicographic comparison for non-semver strings. + */ +function compareVersions(a: string, b: string): number { + const pa = a.split('.'); + const pb = b.split('.'); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const na = parseInt(pa[i] ?? '0', 10); + const nb = parseInt(pb[i] ?? '0', 10); + if (Number.isNaN(na) || Number.isNaN(nb)) { + return a.localeCompare(b); + } + if (na !== nb) return nb - na; // descending — newest first + } + return 0; +} export const VersionListCard = () => { const { entity } = useEntity(); const { t } = useTranslation(); + const catalogApi = useApi(catalogApiRef); + + const version = getVersion(entity); + const assetName = getAssetName(entity); + + const [siblings, setSiblings] = useState([]); + const [loading, setLoading] = useState(true); - const version = - entity.metadata.annotations?.['rhdh.io/ai-asset-version'] ?? ''; + useEffect(() => { + if (!version) { + setLoading(false); + return undefined; + } + + let cancelled = false; + setLoading(true); + + catalogApi + .getEntities({ + filter: { + 'metadata.annotations.rhdh.io/ai-asset-name': assetName, + }, + }) + .then(response => { + if (!cancelled) { + const items = response.items + .filter(e => getVersion(e) !== '') + .sort((a, b) => compareVersions(getVersion(a), getVersion(b))); + setSiblings(items); + } + }) + .catch(() => { + // On error, fall back to showing only the current version + if (!cancelled) { + setSiblings([]); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [catalogApi, version, assetName]); if (!version) { return null; } + const currentName = entity.metadata.name; + const currentNamespace = entity.metadata.namespace ?? 'default'; + + const isCurrent = (e: Entity): boolean => + e.metadata.name === currentName && + (e.metadata.namespace ?? 'default') === currentNamespace; + + // When loading or no siblings found, show current version only + const versionEntities = siblings.length > 0 ? siblings : [entity]; + return ( @@ -37,12 +133,45 @@ export const VersionListCard = () => { - - {version} - - {t('catalog.card.versionCurrent')} - - + {loading ? ( + + + + + ) : ( + versionEntities.map(e => { + const v = getVersion(e); + const current = isCurrent(e); + const recommended = isRecommended(e); + + return ( + + {current || siblings.length <= 1 ? ( + {v} + ) : ( + + {v} + + )} + {current && ( + + {t('catalog.card.versionCurrent')} + + )} + {recommended && ( + + {t('catalog.card.versionRecommended')} + + )} + + ); + }) + )} diff --git a/workspaces/boost/plugins/boost/src/translations/ref.ts b/workspaces/boost/plugins/boost/src/translations/ref.ts index bbf944b4cdc..2ac91d16077 100644 --- a/workspaces/boost/plugins/boost/src/translations/ref.ts +++ b/workspaces/boost/plugins/boost/src/translations/ref.ts @@ -40,6 +40,8 @@ export const boostMessages = { adoptionTitle: 'Get Started', versionTitle: 'Versions', versionCurrent: 'current', + versionRecommended: 'recommended', + versionNavigate: 'Navigate to version {{version}}', copyCommand: 'Copy', copied: 'Copied', copyAriaLabel: 'Copy command to clipboard',